diff --git a/README.md b/README.md index 9d23c3e..1a03e7d 100644 --- a/README.md +++ b/README.md @@ -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). ![The quarry](preview-p1-quarry.png) -![The Tauri window](preview-tauri.png) +![The self-contained Tauri window](preview-p1-tauri-host.png) + +![The Tauri window, earlier](preview-tauri.png) ![P1b · restored project](preview-p1b-restored.png) @@ -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). diff --git a/app/README.md b/app/README.md index 6366000..862a203 100644 --- a/app/README.md +++ b/app/README.md @@ -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. diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index d4bbe7f..8c4bb6b 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -41,12 +63,68 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atk" version = "0.18.2" @@ -124,6 +202,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -133,6 +224,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -310,6 +410,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -322,6 +433,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.9" @@ -332,6 +489,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.2" @@ -382,6 +551,56 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni 0.22.4", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -391,6 +610,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -425,6 +653,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.37.0" @@ -488,6 +725,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -566,8 +815,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -708,6 +968,20 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -725,6 +999,34 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1016,6 +1318,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1177,6 +1480,18 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1245,6 +1560,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -1470,6 +1794,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1569,6 +1899,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1640,6 +2000,12 @@ dependencies = [ "serde", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libappindicator" version = "0.9.0" @@ -1698,6 +2064,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.3" @@ -1719,6 +2096,12 @@ version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "markup5ever" version = "0.39.0" @@ -1730,6 +2113,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1803,6 +2195,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndk" version = "0.9.0" @@ -1839,12 +2237,32 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1900,6 +2318,32 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -1911,6 +2355,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -1928,7 +2395,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2000,6 +2469,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2078,6 +2548,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2357,6 +2833,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2465,6 +2958,36 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "rtrb" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5974e397833d890f2f74963408e0d4a6cd8eda2f6a445ae06750291b5d7a997e" + +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2740,6 +3263,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2747,8 +3281,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", ] [[package]] @@ -2757,12 +3300,38 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -2839,6 +3408,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2892,6 +3471,139 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -2975,7 +3687,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3031,7 +3743,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3139,7 +3851,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3162,7 +3874,7 @@ checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3277,6 +3989,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.55" @@ -3333,10 +4054,35 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -3498,9 +4244,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3508,6 +4266,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3538,6 +4326,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.21", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3596,6 +4400,12 @@ dependencies = [ "utf_types", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utf_types" version = "1.0.0" @@ -3614,6 +4424,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4157,7 +4979,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4288,6 +5110,78 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tokio", + "tracing-subscriber", + "zhila-daemon", +] + +[[package]] +name = "zhila-core" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.21", + "zhila-protocol", +] + +[[package]] +name = "zhila-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "futures-util", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "zhila-core", + "zhila-engine", + "zhila-material", + "zhila-project", + "zhila-protocol", +] + +[[package]] +name = "zhila-engine" +version = "0.1.0" +dependencies = [ + "cpal", + "rtrb", + "thiserror 2.0.21", + "zhila-core", + "zhila-protocol", +] + +[[package]] +name = "zhila-material" +version = "0.1.0" +dependencies = [ + "blake3", + "symphonia", + "thiserror 2.0.21", + "zhila-core", + "zhila-protocol", +] + +[[package]] +name = "zhila-project" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde_json", + "thiserror 2.0.21", + "zhila-core", + "zhila-material", + "zhila-protocol", +] + +[[package]] +name = "zhila-protocol" +version = "0.1.0" +dependencies = [ + "serde", ] [[package]] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index ef3f17a..de0f059 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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] diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index d588524..275d185 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -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"); diff --git a/crates/zhila-daemon/Cargo.toml b/crates/zhila-daemon/Cargo.toml index 8c03071..2d36d03 100644 --- a/crates/zhila-daemon/Cargo.toml +++ b/crates/zhila-daemon/Cargo.toml @@ -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" diff --git a/crates/zhila-daemon/src/lib.rs b/crates/zhila-daemon/src/lib.rs new file mode 100644 index 0000000..8d9cd71 --- /dev/null +++ b/crates/zhila-daemon/src/lib.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, Message>; + +#[derive(Debug, Clone)] +pub struct HostConfig { + pub port: u16, + pub token: String, + pub no_audio: bool, + pub project: Option, +} + +#[derive(Clone)] +enum Outbound { + Text(String), + Binary(Vec), +} + +#[derive(Clone)] +struct Shared { + session: Arc>, + store: Arc>, + out: broadcast::Sender, + token: Arc, + session_id: Arc, +} + +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 = 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::(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::(); + 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 { + 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, code: ErrorCode, message: impl Into) -> 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() +} diff --git a/crates/zhila-daemon/src/main.rs b/crates/zhila-daemon/src/main.rs index b6f75b8..b23823b 100644 --- a/crates/zhila-daemon/src/main.rs +++ b/crates/zhila-daemon/src/main.rs @@ -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, 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, } -#[derive(Clone)] -enum Outbound { - Text(String), - Binary(Vec), -} - -#[derive(Clone)] -struct Shared { - session: Arc>, - store: Arc>, - out: broadcast::Sender, - token: Arc, - session_id: Arc, -} - #[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 = 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::(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::(); - 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 { - 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, code: ErrorCode, message: impl Into) -> 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 } diff --git a/docs/P0.md b/docs/P0.md index 0cab87a..f8bedff 100644 --- a/docs/P0.md +++ b/docs/P0.md @@ -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 diff --git a/docs/P1.md b/docs/P1.md index 1ef71e3..4a950fa 100644 --- a/docs/P1.md +++ b/docs/P1.md @@ -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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 7f83584..af1fb86 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 4ab9423..afb2769 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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. diff --git a/preview-p1-tauri-host.png b/preview-p1-tauri-host.png new file mode 100644 index 0000000..ef0d0b0 Binary files /dev/null and b/preview-p1-tauri-host.png differ