P1f: self-contained Tauri host; complete P1
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
|
||||
Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs.
|
||||
|
||||
This repository is **P1e**: the engine, the wire, the descent, the material room, a project that survives a restart, a microphone, a waveform you can cut, and an undo stack that comes back with the project. A found or recorded sound is cut into sixteen faces by hand or by analysis, laid on a vein and heard in the loop; tempo, gains, pattern and face edits are autosaved and reversible across restarts. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md).
|
||||
This repository is **P1 complete**: the Rust engine, the wire, the descent, the quarry, a project that survives restarts, recording, manual faces and trim, an undo stack that comes back with the project, and a native Tauri window that hosts the engine itself. A found or recorded sound is cut into sixteen faces by hand or by analysis, laid on a vein and heard in the loop; every project edit is autosaved and reversible. The next phase is the arrangement and the agent. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md).
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -26,9 +28,10 @@ This repository is **P1e**: the engine, the wire, the descent, the material room
|
||||
- **Protocol v1.** JSON control frames, a 31-byte binary meter frame, and a binary import path (an import header followed by the audio bytes). Snapshot, results, events, token and origin guard.
|
||||
- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains, pattern faces and steps); play, stop and strikes are performance, not history.
|
||||
- **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gains; transport; keyboard.
|
||||
- **Native host.** The Tauri app starts the same host inside its own process: engine, session, SQLite project, assets and loopback wire. The built window is self-contained; the browser path uses the standalone daemon. One process owns `127.0.0.1:4317` at a time.
|
||||
- **Verification.** Rust unit tests, a live wire test, a material import test, a browser quarry test and the P0 regression smoke, plus `svelte-check` and a production build.
|
||||
|
||||
The Tauri 2 shell is **built and running**: a native window loading the same surface, connected to the daemon. It is built with Tauri 2.12.5 / CLI 2.12.4 against WebKitGTK 2.54.2, GTK 3.24.52 and dbus 1.16.2. The binary is `app/src-tauri/target/release/zhila-app` (13 MB); installers are a later step.
|
||||
The Tauri 2 app is **built and running with its own engine**: the native window loads the embedded surface and the host starts the engine, session, project and wire in the app process. It is built with Tauri 2.12.5 / CLI 2.12.4 against WebKitGTK 2.54.2, GTK 3.24.52 and dbus 1.16.2. The binary is `app/src-tauri/target/release/zhila-app` (14 MB); installers are a later step.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -64,7 +67,7 @@ cd app
|
||||
./src-tauri/target/release/zhila-app
|
||||
```
|
||||
|
||||
The built window embeds the surface and connects to the daemon at `ws://127.0.0.1:4317`; start the daemon first. `tauri dev` uses the Vite server at `127.0.0.1:5173`. The engine is still the separate daemon in this phase. Rebuild the native app after any UI change; the binary embeds `ui/dist` at compile time. The recording and cutting UI are in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine.
|
||||
The built window embeds the surface and starts the host itself; it does not need the daemon. The host owns `127.0.0.1:4317`, loads the default project, and stops when the app exits. For browser development use the standalone daemon (`cargo run -p zhila-daemon -- --port 4317`) and stop the native app, because both bind the same port. `tauri dev` uses the Vite server at `127.0.0.1:5173`. Rebuild the native app after any UI change; the binary embeds `ui/dist` at compile time. The recording and cutting UI are in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine.
|
||||
|
||||
Controls: `space` play/stop · `s` strike the selected face in the quarry or the selected vein in the nave · `1`–`5` select a vein (nave) or a pattern row (quarry) · click a face to select, double-click to strike · tempo `−` / `+`.
|
||||
|
||||
@@ -81,7 +84,7 @@ crates/zhila-project SQLite project, assets, load/save, undo persistence
|
||||
crates/zhila-daemon WebSocket daemon, auth, binary import, autosave
|
||||
ui Svelte 5 surface: descent + quarry
|
||||
fixtures a small click track for the smoke tests
|
||||
app Tauri 2 shell (built; engine remains the daemon)
|
||||
app Tauri 2 app with the embedded host
|
||||
docs plan, protocol, P0/P1 notes
|
||||
reference/demo the earlier design demo
|
||||
```
|
||||
@@ -98,21 +101,23 @@ npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, scr
|
||||
npm --prefix ui run project-smoke # daemon + Vite: project, undo, import, screenshots
|
||||
npm --prefix ui run record-smoke # daemon + Vite: fake microphone take, faces, seal
|
||||
npm --prefix ui run faces-smoke # daemon + Vite: drag a seam, trim, save, screenshot
|
||||
cd app && ./node_modules/.bin/tauri build --no-bundle # native app with the embedded host
|
||||
npm --prefix ui run restored-smoke # after restarting the daemon on the saved project
|
||||
```
|
||||
|
||||
`npm --prefix ui run smoke` writes `preview-p0*.png`; `quarry-smoke` writes `preview-p1-*.png`.
|
||||
|
||||
## P1e limits (next, not hidden)
|
||||
## P1 limits (next, not hidden)
|
||||
|
||||
- Recording is mono, 16-bit, requested 48 kHz, up to 30 s, no monitoring and no input device choice. WebKitGTK/Tauri microphone permission is untested; the browser path is verified with Chromium's fake capture.
|
||||
- Face editing is seventeen contiguous cuts: no zoom, fades or per-face loop points. The trim is the outer pair of cuts; the source bytes are untouched.
|
||||
- Undo history is a bounded snapshot stack stored as JSON; imports are still not undoable, and the journal is an audit trail rather than the undo source.
|
||||
- The Tauri binary still embeds an older surface and starts no engine; it is a viewer in this phase.
|
||||
- The native app uses the development token and a fixed port; the browser and the app cannot share 4317 at the same time.
|
||||
- The arrangement is still one two-bar loop on five veins; no render, no 8-bar timeline, no agent yet.
|
||||
- One fixed two-bar phrase and five veins; pattern rows are veins, one face per row, no per-step face or pitch.
|
||||
- The Tauri binary currently embeds an older surface build; rebuild it after UI changes (`npm --prefix ui run build`, then `tauri build`). The engine remains the separate daemon.
|
||||
- Formats depend on symphonia; WAV is the tested path. AIFF, multi-channel files and very long files are not specifically handled.
|
||||
- No agent/MCP in this phase.
|
||||
- The Tauri shell now builds and runs; the engine is still the separate daemon (embedding is next).
|
||||
|
||||
The next slice is P1f: the engine host inside the Tauri backend, so the native window is self-contained. Then the arrangement and the agent. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md).
|
||||
P1 is complete at the "first playing loop" milestone: material, sampler, project, recording, cutting, history and the native host. The next phase is P2: the arrangement across bars, render to WAV, and the agent on the same verbs. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md).
|
||||
|
||||
+5
-2
@@ -36,14 +36,17 @@ npm --prefix app run tauri build -- --bundles deb
|
||||
|
||||
The bundle targets are `deb` and `appimage`. The icon at `src-tauri/icons/icon.png` is a P0 placeholder.
|
||||
|
||||
## Verified build
|
||||
## Self-contained app
|
||||
|
||||
The app now starts the ZHILA host (engine, session, SQLite project, assets, loopback wire) inside its own process. The built window does not need the daemon.
|
||||
|
||||
```bash
|
||||
npm --prefix ui run build
|
||||
cd app
|
||||
./node_modules/.bin/tauri build --no-bundle
|
||||
# with the daemon running:
|
||||
./src-tauri/target/release/zhila-app
|
||||
```
|
||||
|
||||
The host binds `127.0.0.1:4317` with the development token `dev` and loads `~/.local/share/zhila/descent-04`. For browser development run the standalone daemon instead; both cannot own the port at the same time.
|
||||
|
||||
`cargo check --manifest-path app/src-tauri/Cargo.toml` and the release build pass. The earlier `dbus-1`/`webkit2gtk`/`gtk` pkg-config failure is historical; those packages are installed. The window screenshot is `preview-tauri.png` at the repository root.
|
||||
|
||||
Generated
+903
-9
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,8 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zhila-daemon = { path = "../../crates/zhila-daemon" }
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use zhila_daemon::{HostConfig, run};
|
||||
|
||||
fn main() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "zhila_daemon=info,zhila_engine=info".into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.try_init();
|
||||
|
||||
// The native app is self-contained: the engine, session, project store and
|
||||
// wire live in this process. The webview talks to the same loopback wire as
|
||||
// the browser-first surface.
|
||||
std::thread::spawn(|| {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
eprintln!("zhila host runtime: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async {
|
||||
if let Err(error) = run(HostConfig {
|
||||
port: 4317,
|
||||
token: "dev".into(),
|
||||
no_audio: false,
|
||||
project: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
eprintln!("zhila host: {error:#}");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("ZHILA could not open its window");
|
||||
|
||||
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "zhila_daemon"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "zhila-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
//! ZHILA local daemon: one WebSocket endpoint, one session, one engine.
|
||||
//!
|
||||
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||
//! binds to loopback only and requires a token in the hello.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Context;
|
||||
use futures_util::stream::SplitSink;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio::time::{interval, timeout};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_project::{ProjectSnapshot, ProjectStore};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const METER_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostConfig {
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
pub no_audio: bool,
|
||||
pub project: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Outbound {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
session: Arc<Mutex<Session>>,
|
||||
store: Arc<std::sync::Mutex<ProjectStore>>,
|
||||
out: broadcast::Sender<Outbound>,
|
||||
token: Arc<String>,
|
||||
session_id: Arc<String>,
|
||||
}
|
||||
|
||||
pub async fn run(config: HostConfig) -> anyhow::Result<()> {
|
||||
let HostConfig {
|
||||
port,
|
||||
token,
|
||||
no_audio,
|
||||
project,
|
||||
} = config;
|
||||
|
||||
let pattern = default_pattern();
|
||||
let audio_holder = if no_audio {
|
||||
None
|
||||
} else {
|
||||
match AudioEngine::start(&pattern) {
|
||||
Ok(engine) => Some(engine),
|
||||
Err(error) => {
|
||||
warn!("audio offline: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let control: Arc<dyn Engine> = match &audio_holder {
|
||||
Some(engine) => engine.control(),
|
||||
None => Arc::new(NullEngine::new()),
|
||||
};
|
||||
let shutdown = control.clone();
|
||||
let info = control.info();
|
||||
|
||||
let project_path = project.clone().unwrap_or_else(default_project_dir);
|
||||
let store = ProjectStore::open(&project_path).context("could not open the project")?;
|
||||
let loaded = store.load().context("could not load the project")?;
|
||||
let (session, project_name) = match loaded {
|
||||
Some(loaded) => {
|
||||
for warning in &loaded.warnings {
|
||||
warn!("project: {warning}");
|
||||
}
|
||||
let name = loaded.data.name.clone();
|
||||
info!(project = %name, path = %project_path.display(), "project loaded");
|
||||
(Session::from_project(control.clone(), loaded.data), name)
|
||||
}
|
||||
None => {
|
||||
let name = project_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("descent-04")
|
||||
.to_string();
|
||||
let mut session = Session::with_pattern(control.clone(), pattern.clone());
|
||||
session.set_project_name(name.clone());
|
||||
info!(project = %name, path = %project_path.display(), "new project");
|
||||
(session, name)
|
||||
}
|
||||
};
|
||||
let session = Arc::new(Mutex::new(session));
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||
let session_id = format!("zhila-{:x}", now_ms());
|
||||
let shared = Shared {
|
||||
session,
|
||||
store,
|
||||
out: out.clone(),
|
||||
token: Arc::new(token.clone()),
|
||||
session_id: Arc::new(session_id.clone()),
|
||||
};
|
||||
save_now(&shared).await;
|
||||
|
||||
let meter_engine = control.clone();
|
||||
let meter_out = out.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = interval(METER_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let frame = meter_engine.meter();
|
||||
let _ = meter_out.send(Outbound::Binary(frame.encode()));
|
||||
}
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))
|
||||
.await
|
||||
.with_context(|| format!("could not bind 127.0.0.1:{port}"))?;
|
||||
|
||||
print_banner(
|
||||
port,
|
||||
&token,
|
||||
&info,
|
||||
&session_id,
|
||||
&project_name,
|
||||
&project_path,
|
||||
);
|
||||
if token == "dev" {
|
||||
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (stream, peer) = accepted.context("accept failed")?;
|
||||
let shared = shared.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = handle(stream, shared).await {
|
||||
warn!("connection {peer}: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner(
|
||||
port: u16,
|
||||
token: &str,
|
||||
info: &zhila_protocol::AudioInfo,
|
||||
session_id: &str,
|
||||
project_name: &str,
|
||||
project_path: &std::path::Path,
|
||||
) {
|
||||
println!();
|
||||
println!(" ZHILA engine");
|
||||
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||
println!(" project {project_name} · {}", project_path.display());
|
||||
if info.running {
|
||||
let buffer = if info.buffer_frames == 0 {
|
||||
"default".to_string()
|
||||
} else {
|
||||
info.buffer_frames.to_string()
|
||||
};
|
||||
println!(
|
||||
" audio {} · {} Hz · {} ch · buffer {}",
|
||||
info.device, info.sample_rate, info.channels, buffer
|
||||
);
|
||||
} else {
|
||||
println!(" audio offline · the surface will connect, but nothing will sound");
|
||||
}
|
||||
println!(" session {session_id}");
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn save_now(shared: &Shared) {
|
||||
let session = shared.session.lock().await;
|
||||
let state = session.snapshot();
|
||||
let gains = session.gains();
|
||||
let snapshot = ProjectSnapshot {
|
||||
name: session.project_name(),
|
||||
revision: state.revision,
|
||||
bpm: session.bpm(),
|
||||
gains: &gains,
|
||||
pattern: session.pattern(),
|
||||
cores: &state.cores,
|
||||
journal: session.journal(),
|
||||
history: session.history(),
|
||||
redo: session.redo(),
|
||||
};
|
||||
let result = {
|
||||
let mut store = shared.store.lock().expect("project store lock poisoned");
|
||||
store.save(&snapshot)
|
||||
};
|
||||
drop(session);
|
||||
if let Err(error) = result {
|
||||
warn!("project save failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_project_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("zhila").join("descent-04")
|
||||
}
|
||||
|
||||
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let ws = accept_hdr_async(stream, handshake)
|
||||
.await
|
||||
.context("websocket handshake failed")?;
|
||||
|
||||
let (mut sink, mut source) = ws.split();
|
||||
|
||||
let first = match timeout(HELLO_TIMEOUT, source.next()).await {
|
||||
Ok(Some(Ok(message))) => message,
|
||||
Ok(Some(Err(error))) => return Err(error.into()),
|
||||
Ok(None) => return Ok(()),
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
"expected a hello within 5 seconds",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let Message::Text(text) = first else {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "the first frame must be a hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let hello: ClientMessage = match serde_json::from_str(text.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::BadRequest, "malformed hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let (client, protocol, token) = match hello {
|
||||
ClientMessage::Hello {
|
||||
client,
|
||||
protocol,
|
||||
token,
|
||||
} => (client, protocol, token),
|
||||
ClientMessage::Command { .. } | ClientMessage::ImportCore { .. } => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if protocol != PROTOCOL_VERSION {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
format!(
|
||||
"protocol mismatch: this daemon speaks {PROTOCOL_VERSION}, the client sent {protocol}"
|
||||
),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
if token.as_str() != shared.token.as_str() {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Unauthorized, "the token was not accepted"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = shared.session.lock().await.snapshot();
|
||||
info!(?peer, client = %client, "wire opened");
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Hello {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
session: shared.session_id.as_ref().clone(),
|
||||
engine: state.audio.clone(),
|
||||
})
|
||||
.expect("hello serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let revision = state.revision;
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Snapshot { revision, state })
|
||||
.expect("snapshot serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::<Outbound>();
|
||||
let mut events = shared.out.subscribe();
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
direct = direct_rx.recv() => {
|
||||
match direct {
|
||||
Some(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
broadcast = events.recv() => {
|
||||
match broadcast {
|
||||
Ok(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut pending_import: Option<(u64, String, Seal)> = None;
|
||||
while let Some(frame) = source.next().await {
|
||||
let message = match frame {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
let Some((id, name, seal)) = pending_import.take() else {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"a binary frame arrived without an import header",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let source_name = name.clone();
|
||||
let audio = bytes.to_vec();
|
||||
let asset_audio = audio.clone();
|
||||
let import = tokio::task::spawn_blocking(move || {
|
||||
zhila_material::import_bytes(&name, seal, audio)
|
||||
})
|
||||
.await;
|
||||
match import {
|
||||
Ok(Ok(core)) => {
|
||||
let summary = core.summary.clone();
|
||||
let asset = {
|
||||
let mut store =
|
||||
shared.store.lock().expect("project store lock poisoned");
|
||||
store.write_asset(&summary, &source_name, &asset_audio)
|
||||
};
|
||||
if let Err(error) = asset {
|
||||
warn!("project asset failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the asset could not be saved",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let state = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.import_core(core, Actor::Hand)
|
||||
};
|
||||
save_now(&shared).await;
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Imported { id, core: summary })
|
||||
.expect("import serializes"),
|
||||
));
|
||||
let snapshot = ServerMessage::Snapshot {
|
||||
revision: state.revision,
|
||||
state,
|
||||
};
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&snapshot).unwrap()));
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("import task failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the import task failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(error) => {
|
||||
warn!("wire read error: {error}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let message: ClientMessage = match serde_json::from_str(message.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"malformed message",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
ClientMessage::Hello { .. } => {
|
||||
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
||||
}
|
||||
ClientMessage::ImportCore { id, name, seal } => {
|
||||
if pending_import.is_some() {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
"the previous import has not received its bytes yet",
|
||||
));
|
||||
} else {
|
||||
pending_import = Some((id, name, seal));
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let should_save = !matches!(
|
||||
&command,
|
||||
Command::Play
|
||||
| Command::Stop
|
||||
| Command::Strike { .. }
|
||||
| Command::StrikeFace { .. }
|
||||
| Command::GetSnapshot
|
||||
);
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.apply(command, Actor::Hand)
|
||||
};
|
||||
match outcome {
|
||||
Ok((result, emitted)) => {
|
||||
if should_save {
|
||||
save_now(&shared).await;
|
||||
}
|
||||
let revision = match &result {
|
||||
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||
state.revision
|
||||
}
|
||||
};
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Result { id, result })
|
||||
.expect("result serializes"),
|
||||
));
|
||||
for event in emitted {
|
||||
let message = ServerMessage::Event { revision, event };
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&message).unwrap()));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
error_code(&error),
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(direct_tx);
|
||||
let _ = writer.await;
|
||||
info!(?peer, "wire closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn handshake(request: &Request, response: Response) -> Result<Response, ErrorResponse> {
|
||||
if let Some(origin) = request
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& !origin_allowed(origin)
|
||||
{
|
||||
return Err(ErrorResponse::new(Some(
|
||||
"origin is not allowed to talk to ZHILA".into(),
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn origin_allowed(origin: &str) -> bool {
|
||||
if origin == "null" {
|
||||
return true;
|
||||
}
|
||||
let Some((scheme, rest)) = origin.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(scheme, "http" | "https" | "tauri") {
|
||||
return false;
|
||||
}
|
||||
let authority = rest.split('/').next().unwrap_or_default();
|
||||
let host = authority.split(':').next().unwrap_or_default();
|
||||
matches!(host, "localhost" | "127.0.0.1" | "tauri.localhost")
|
||||
}
|
||||
|
||||
fn error_code(error: &CoreError) -> ErrorCode {
|
||||
match error {
|
||||
CoreError::InvalidTempo
|
||||
| CoreError::InvalidVelocity
|
||||
| CoreError::InvalidGain
|
||||
| CoreError::InvalidRow
|
||||
| CoreError::InvalidStep
|
||||
| CoreError::UnknownFace
|
||||
| CoreError::InvalidFaces
|
||||
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_message(id: Option<u64>, code: ErrorCode, message: impl Into<String>) -> Outbound {
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Error {
|
||||
id,
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
.expect("error serializes"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_out(sink: &mut WsSink, outbound: Outbound) -> anyhow::Result<()> {
|
||||
let message = match outbound {
|
||||
Outbound::Text(text) => Message::Text(text.into()),
|
||||
Outbound::Binary(bytes) => Message::Binary(bytes.into()),
|
||||
};
|
||||
sink.send(message).await.context("websocket send failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -1,41 +1,11 @@
|
||||
//! ZHILA local daemon: one WebSocket endpoint, one session, one engine.
|
||||
//!
|
||||
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||
//! binds to loopback only and requires a token in the hello.
|
||||
//! ZHILA daemon binary: parse flags and run the local engine host.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use futures_util::stream::SplitSink;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio::time::{interval, timeout};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_project::{ProjectSnapshot, ProjectStore};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const METER_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "zhila-daemon",
|
||||
version,
|
||||
about = "ZHILA local engine daemon (P0)"
|
||||
)]
|
||||
#[command(name = "zhila-daemon", version, about = "ZHILA local engine host")]
|
||||
struct Args {
|
||||
/// WebSocket port on loopback.
|
||||
#[arg(long, default_value_t = 4317)]
|
||||
@@ -51,21 +21,6 @@ struct Args {
|
||||
project: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Outbound {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
session: Arc<Mutex<Session>>,
|
||||
store: Arc<std::sync::Mutex<ProjectStore>>,
|
||||
out: broadcast::Sender<Outbound>,
|
||||
token: Arc<String>,
|
||||
session_id: Arc<String>,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
@@ -77,539 +32,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let args = Args::parse();
|
||||
let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token);
|
||||
|
||||
let pattern = default_pattern();
|
||||
let audio_holder = if args.no_audio {
|
||||
None
|
||||
} else {
|
||||
match AudioEngine::start(&pattern) {
|
||||
Ok(engine) => Some(engine),
|
||||
Err(error) => {
|
||||
warn!("audio offline: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let control: Arc<dyn Engine> = match &audio_holder {
|
||||
Some(engine) => engine.control(),
|
||||
None => Arc::new(NullEngine::new()),
|
||||
};
|
||||
let shutdown = control.clone();
|
||||
let info = control.info();
|
||||
|
||||
let project_path = args.project.clone().unwrap_or_else(default_project_dir);
|
||||
let store = ProjectStore::open(&project_path).context("could not open the project")?;
|
||||
let loaded = store.load().context("could not load the project")?;
|
||||
let (session, project_name) = match loaded {
|
||||
Some(loaded) => {
|
||||
for warning in &loaded.warnings {
|
||||
warn!("project: {warning}");
|
||||
}
|
||||
let name = loaded.data.name.clone();
|
||||
info!(project = %name, path = %project_path.display(), "project loaded");
|
||||
(Session::from_project(control.clone(), loaded.data), name)
|
||||
}
|
||||
None => {
|
||||
let name = project_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("descent-04")
|
||||
.to_string();
|
||||
let mut session = Session::with_pattern(control.clone(), pattern.clone());
|
||||
session.set_project_name(name.clone());
|
||||
info!(project = %name, path = %project_path.display(), "new project");
|
||||
(session, name)
|
||||
}
|
||||
};
|
||||
let session = Arc::new(Mutex::new(session));
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||
let session_id = format!("zhila-{:x}", now_ms());
|
||||
let shared = Shared {
|
||||
session,
|
||||
store,
|
||||
out: out.clone(),
|
||||
token: Arc::new(token.clone()),
|
||||
session_id: Arc::new(session_id.clone()),
|
||||
};
|
||||
save_now(&shared).await;
|
||||
|
||||
let meter_engine = control.clone();
|
||||
let meter_out = out.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = interval(METER_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let frame = meter_engine.meter();
|
||||
let _ = meter_out.send(Outbound::Binary(frame.encode()));
|
||||
}
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", args.port))
|
||||
.await
|
||||
.with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?;
|
||||
|
||||
print_banner(
|
||||
args.port,
|
||||
&token,
|
||||
&info,
|
||||
&session_id,
|
||||
&project_name,
|
||||
&project_path,
|
||||
);
|
||||
if token == "dev" {
|
||||
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (stream, peer) = accepted.context("accept failed")?;
|
||||
let shared = shared.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = handle(stream, shared).await {
|
||||
warn!("connection {peer}: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner(
|
||||
port: u16,
|
||||
token: &str,
|
||||
info: &zhila_protocol::AudioInfo,
|
||||
session_id: &str,
|
||||
project_name: &str,
|
||||
project_path: &std::path::Path,
|
||||
) {
|
||||
println!();
|
||||
println!(" ZHILA engine");
|
||||
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||
println!(" project {project_name} · {}", project_path.display());
|
||||
if info.running {
|
||||
let buffer = if info.buffer_frames == 0 {
|
||||
"default".to_string()
|
||||
} else {
|
||||
info.buffer_frames.to_string()
|
||||
};
|
||||
println!(
|
||||
" audio {} · {} Hz · {} ch · buffer {}",
|
||||
info.device, info.sample_rate, info.channels, buffer
|
||||
);
|
||||
} else {
|
||||
println!(" audio offline · the surface will connect, but nothing will sound");
|
||||
}
|
||||
println!(" session {session_id}");
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn save_now(shared: &Shared) {
|
||||
let session = shared.session.lock().await;
|
||||
let state = session.snapshot();
|
||||
let gains = session.gains();
|
||||
let snapshot = ProjectSnapshot {
|
||||
name: session.project_name(),
|
||||
revision: state.revision,
|
||||
bpm: session.bpm(),
|
||||
gains: &gains,
|
||||
pattern: session.pattern(),
|
||||
cores: &state.cores,
|
||||
journal: session.journal(),
|
||||
history: session.history(),
|
||||
redo: session.redo(),
|
||||
};
|
||||
let result = {
|
||||
let mut store = shared.store.lock().expect("project store lock poisoned");
|
||||
store.save(&snapshot)
|
||||
};
|
||||
drop(session);
|
||||
if let Err(error) = result {
|
||||
warn!("project save failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_project_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("zhila").join("descent-04")
|
||||
}
|
||||
|
||||
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let ws = accept_hdr_async(stream, handshake)
|
||||
.await
|
||||
.context("websocket handshake failed")?;
|
||||
|
||||
let (mut sink, mut source) = ws.split();
|
||||
|
||||
let first = match timeout(HELLO_TIMEOUT, source.next()).await {
|
||||
Ok(Some(Ok(message))) => message,
|
||||
Ok(Some(Err(error))) => return Err(error.into()),
|
||||
Ok(None) => return Ok(()),
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
"expected a hello within 5 seconds",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let Message::Text(text) = first else {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "the first frame must be a hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let hello: ClientMessage = match serde_json::from_str(text.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::BadRequest, "malformed hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let (client, protocol, token) = match hello {
|
||||
ClientMessage::Hello {
|
||||
client,
|
||||
protocol,
|
||||
token,
|
||||
} => (client, protocol, token),
|
||||
ClientMessage::Command { .. } | ClientMessage::ImportCore { .. } => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if protocol != PROTOCOL_VERSION {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
format!(
|
||||
"protocol mismatch: this daemon speaks {PROTOCOL_VERSION}, the client sent {protocol}"
|
||||
),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
if token.as_str() != shared.token.as_str() {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Unauthorized, "the token was not accepted"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = shared.session.lock().await.snapshot();
|
||||
info!(?peer, client = %client, "wire opened");
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Hello {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
session: shared.session_id.as_ref().clone(),
|
||||
engine: state.audio.clone(),
|
||||
})
|
||||
.expect("hello serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let revision = state.revision;
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Snapshot { revision, state })
|
||||
.expect("snapshot serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::<Outbound>();
|
||||
let mut events = shared.out.subscribe();
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
direct = direct_rx.recv() => {
|
||||
match direct {
|
||||
Some(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
broadcast = events.recv() => {
|
||||
match broadcast {
|
||||
Ok(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut pending_import: Option<(u64, String, Seal)> = None;
|
||||
while let Some(frame) = source.next().await {
|
||||
let message = match frame {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
let Some((id, name, seal)) = pending_import.take() else {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"a binary frame arrived without an import header",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let source_name = name.clone();
|
||||
let audio = bytes.to_vec();
|
||||
let asset_audio = audio.clone();
|
||||
let import = tokio::task::spawn_blocking(move || {
|
||||
zhila_material::import_bytes(&name, seal, audio)
|
||||
})
|
||||
.await;
|
||||
match import {
|
||||
Ok(Ok(core)) => {
|
||||
let summary = core.summary.clone();
|
||||
let asset = {
|
||||
let mut store =
|
||||
shared.store.lock().expect("project store lock poisoned");
|
||||
store.write_asset(&summary, &source_name, &asset_audio)
|
||||
};
|
||||
if let Err(error) = asset {
|
||||
warn!("project asset failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the asset could not be saved",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let state = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.import_core(core, Actor::Hand)
|
||||
};
|
||||
save_now(&shared).await;
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Imported { id, core: summary })
|
||||
.expect("import serializes"),
|
||||
));
|
||||
let snapshot = ServerMessage::Snapshot {
|
||||
revision: state.revision,
|
||||
state,
|
||||
};
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&snapshot).unwrap()));
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("import task failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the import task failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(error) => {
|
||||
warn!("wire read error: {error}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let message: ClientMessage = match serde_json::from_str(message.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"malformed message",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
ClientMessage::Hello { .. } => {
|
||||
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
||||
}
|
||||
ClientMessage::ImportCore { id, name, seal } => {
|
||||
if pending_import.is_some() {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::BadRequest,
|
||||
"the previous import has not received its bytes yet",
|
||||
));
|
||||
} else {
|
||||
pending_import = Some((id, name, seal));
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let should_save = !matches!(
|
||||
&command,
|
||||
Command::Play
|
||||
| Command::Stop
|
||||
| Command::Strike { .. }
|
||||
| Command::StrikeFace { .. }
|
||||
| Command::GetSnapshot
|
||||
);
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.apply(command, Actor::Hand)
|
||||
};
|
||||
match outcome {
|
||||
Ok((result, emitted)) => {
|
||||
if should_save {
|
||||
save_now(&shared).await;
|
||||
}
|
||||
let revision = match &result {
|
||||
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||
state.revision
|
||||
}
|
||||
};
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Result { id, result })
|
||||
.expect("result serializes"),
|
||||
));
|
||||
for event in emitted {
|
||||
let message = ServerMessage::Event { revision, event };
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&message).unwrap()));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
error_code(&error),
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(direct_tx);
|
||||
let _ = writer.await;
|
||||
info!(?peer, "wire closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn handshake(request: &Request, response: Response) -> Result<Response, ErrorResponse> {
|
||||
if let Some(origin) = request
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& !origin_allowed(origin)
|
||||
{
|
||||
return Err(ErrorResponse::new(Some(
|
||||
"origin is not allowed to talk to ZHILA".into(),
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn origin_allowed(origin: &str) -> bool {
|
||||
if origin == "null" {
|
||||
return true;
|
||||
}
|
||||
let Some((scheme, rest)) = origin.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(scheme, "http" | "https" | "tauri") {
|
||||
return false;
|
||||
}
|
||||
let authority = rest.split('/').next().unwrap_or_default();
|
||||
let host = authority.split(':').next().unwrap_or_default();
|
||||
matches!(host, "localhost" | "127.0.0.1" | "tauri.localhost")
|
||||
}
|
||||
|
||||
fn error_code(error: &CoreError) -> ErrorCode {
|
||||
match error {
|
||||
CoreError::InvalidTempo
|
||||
| CoreError::InvalidVelocity
|
||||
| CoreError::InvalidGain
|
||||
| CoreError::InvalidRow
|
||||
| CoreError::InvalidStep
|
||||
| CoreError::UnknownFace
|
||||
| CoreError::InvalidFaces
|
||||
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_message(id: Option<u64>, code: ErrorCode, message: impl Into<String>) -> Outbound {
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Error {
|
||||
id,
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
.expect("error serializes"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_out(sink: &mut WsSink, outbound: Outbound) -> anyhow::Result<()> {
|
||||
let message = match outbound {
|
||||
Outbound::Text(text) => Message::Text(text.into()),
|
||||
Outbound::Binary(bytes) => Message::Binary(bytes.into()),
|
||||
};
|
||||
sink.send(message).await.context("websocket send failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
zhila_daemon::run(zhila_daemon::HostConfig {
|
||||
port: args.port,
|
||||
token,
|
||||
no_audio: args.no_audio,
|
||||
project: args.project,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ cd app
|
||||
|
||||
`cargo check --manifest-path app/src-tauri/Cargo.toml` and the release build pass. The native window loads the embedded surface, opens the wire (`wire open`, `engine live`), and renders the descent and the engine panel. The screenshot is `preview-tauri.png` at the repository root.
|
||||
|
||||
The engine is still the separate daemon: the Tauri shell is a viewer in P0/P1a. Embedding the engine in the Tauri backend is the next step after the session/persistence slice. The earlier `dbus-1`/`webkit2gtk`/`gtk` failure is historical; those packages are now present.
|
||||
In P1f the Tauri app was given its own host: the engine, session, project store and wire run inside `zhila-app`, so the native window is self-contained. The standalone daemon remains the browser-development path. The earlier `dbus-1`/`webkit2gtk`/`gtk` failure is historical; those packages are now present.
|
||||
|
||||
## P0 exit criteria
|
||||
|
||||
|
||||
+32
@@ -200,3 +200,35 @@ The snapshot carries the cores' cuts, not the audio; the audio is still content-
|
||||
- Imports are not undoable; the history is snapshots, not the complete journal.
|
||||
- The cap is 64 edits; older history is dropped.
|
||||
- Corrupt history JSON is ignored with a warning and an empty stack.
|
||||
|
||||
---
|
||||
|
||||
# P1f · the host inside Tauri
|
||||
|
||||
The native window is now self-contained.
|
||||
|
||||
## Shape
|
||||
|
||||
- `crates/zhila-daemon` became a library (`zhila_daemon::run(HostConfig)`) plus the thin daemon binary. `HostConfig` is port, token, no_audio and project.
|
||||
- `app/src-tauri` depends on the daemon library. On start it spawns one thread with a current-thread Tokio runtime and calls `run`; the Tauri window runs on the main thread. The engine, session, SQLite store, assets and WebSocket server live in the app process.
|
||||
- The embedded Svelte surface talks to `ws://127.0.0.1:4317` exactly as the browser does; the token is `dev`, the project is the default one.
|
||||
- The standalone daemon still exists for browser-first development; the two cannot share port 4317.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd app && ./node_modules/.bin/tauri build --no-bundle` builds `zhila-app` with the host; the release build passes.
|
||||
- The standalone daemon was stopped; `ss -ltnp` shows `zhila-app` owning `127.0.0.1:4317`.
|
||||
- The app log shows `project loaded ... descent-04`, the audio device, and `wire opened ... client=zhila-ui`; the window shows `engine live`, the project name, the 2 cores and the pattern.
|
||||
- Screenshot: `preview-p1-tauri-host.png`.
|
||||
- Final P1 checks: `./scripts/check.sh`, `node scripts/persistence-smoke.mjs` (manual cut + undo + redo across a daemon restart), the browser smokes.
|
||||
|
||||
## Limits
|
||||
|
||||
- The app uses the development token and the default project; no first-run project picker.
|
||||
- The app and the browser daemon contend for 4317.
|
||||
- WebKitGTK microphone permission for recording is not verified on this machine.
|
||||
- Installers (deb/AppImage) are still not built; the binary is the deliverable.
|
||||
|
||||
## P1 acceptance
|
||||
|
||||
The "first playing loop" is reachable end-to-end: import or record → sixteen faces, cut and trimmed by hand → assign to a vein → pattern plays → save/load, undo/redo across restarts → the same material and history in the self-contained native app.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# ZHILA — план проекта
|
||||
|
||||
Черновик v1.1 · 1 ноября 2026
|
||||
Статус: **P0, P1a, P1b, P1c, P1d и P1e выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база.
|
||||
Статус: **P1 полностью выполнен** (P0, P1a–P1f; см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. Следующая фаза — аранжировка, рендер и агент.
|
||||
|
||||
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
|
||||
|
||||
|
||||
@@ -164,3 +164,7 @@ The project is loaded by the daemon from its `--project` folder and saved after
|
||||
## P1e addition
|
||||
|
||||
No new wire messages. The daemon now persists the session's bounded `history` and `redo` stacks in the project and restores them on load; `State.can_undo`/`can_redo` therefore describe the restored stacks after a restart. The stacks are server-side JSON snapshots of bpm, gains, pattern and core face boundaries.
|
||||
|
||||
## P1f note
|
||||
|
||||
The Tauri app starts the same host in-process, so the wire contract is unchanged. The embedded surface connects to `ws://127.0.0.1:4317` with the same hello, token and origin rules; the daemon remains the browser-development host.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
Reference in New Issue
Block a user