From 5f74bf2f0e49b653e51af3247ae9aa56678f7cc7 Mon Sep 17 00:00:00 2001 From: Amit Raikwar Date: Wed, 24 Jun 2026 21:03:23 +0530 Subject: [PATCH 1/2] fix(FAS-9): add gRPC proto definitions and refactor Axum server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define DeckService and ActionService proto files in protos/proto/services/. - Generate TypeScript bindings via buf + bufbuild/es + connectrpc/es into protos/gen/es/services/. - Refactor Axum server from mesh-networking model to gRPC DeckService implementation. - Remove obsolete mesh handlers, room_registry, time_sync, and topology modules. - Add build.rs for tonic/prost proto compilation in the server crate. - Wire generated proto types into shared client service layers (apiClient, hooks, meshConnection). - Distribute generated TS types into shared/client-common and shared/desktop-host workspaces. - Add test_client.rs scaffolding for server integration testing. - Update package.json, Cargo.toml dependencies, and yarn.lock for gRPC stack. - Addresses FAS-9 requirement: establish typed gRPC contract as the single source of truth for FastDeck client–server communication. --- .../java/com/fastdeck/mobile/MainActivity.kt | 11 + apps/server/Cargo.lock | 901 +++-- apps/server/Cargo.toml | 23 +- apps/server/README.md | 4 +- apps/server/build.rs | 12 + apps/server/package.json | 2 +- apps/server/profiles_cache.json | 136 + apps/server/src/config.rs | 39 +- .../src/handlers/__tests__/mesh.test.rs | 262 -- apps/server/src/handlers/__tests__/mod.rs | 69 - apps/server/src/handlers/mesh.rs | 549 --- apps/server/src/handlers/mod.rs | 57 - apps/server/src/lib.rs | 475 ++- apps/server/src/main.rs | 4 +- apps/server/src/services/mesh/mod.rs | 94 - .../server/src/services/mesh/room_registry.rs | 321 -- apps/server/src/services/mesh/time_sync.rs | 112 - apps/server/src/services/mesh/topology.rs | 313 -- apps/server/src/services/mod.rs | 1 - apps/server/src/testing/test_client.rs | 68 + apps/server/testing/README.md | 45 - apps/server/testing/test_real_api.sh | 225 -- package.json | 60 +- profiles_cache.json | 136 + protos/Makefile | 15 + protos/README.md | 113 + protos/buf.gen.yaml | 8 + protos/gen/es/services/action_pb.ts | 600 ++++ protos/gen/es/services/deck_connect.ts | 62 + protos/gen/es/services/deck_pb.ts | 777 ++++ protos/proto/buf.yaml | 8 + protos/proto/services/action.proto | 83 + protos/proto/services/deck.proto | 110 + scripts/rename.js | 70 + scripts/test-all.js | 8 +- scripts/test-server.sh | 45 + shared/client-common/package.json | 9 +- .../client-common/src/services/apiClient.ts | 121 +- .../src/services/gen/action_pb.ts | 600 ++++ .../src/services/gen/deck_connect.ts | 62 + .../client-common/src/services/gen/deck_pb.ts | 777 ++++ shared/client-common/src/services/hooks.ts | 41 +- .../src/services/meshConnection.ts | 301 +- shared/client-common/src/services/types.ts | 5 + shared/common/package.json | 2 +- shared/desktop-host/package.json | 10 +- .../src/components/DisconnectedScreen.tsx | 138 + .../src/components/layout/ScreenLayout.tsx | 23 + .../src/components/layout/Sidebar.tsx | 99 + .../src/components/layout/TopBar.tsx | 100 + .../src/components/layout/index.ts | 3 + .../deck-configurator/DeckConfigurator.tsx | 875 +++++ .../src/screens/deck-configurator/index.ts | 1 + .../multi-action-editor/MultiActionEditor.tsx | 251 ++ .../src/screens/multi-action-editor/index.ts | 1 + .../screens/plugins/PluginsMarketplace.tsx | 265 ++ .../desktop-host/src/screens/plugins/index.ts | 1 + .../screens/profiles/ProfilesAndDevices.tsx | 332 ++ .../src/screens/profiles/index.ts | 1 + shared/desktop-host/src/services/apiClient.ts | 121 +- .../src/services/gen/action_pb.ts | 600 ++++ .../src/services/gen/deck_connect.ts | 62 + .../desktop-host/src/services/gen/deck_pb.ts | 777 ++++ shared/desktop-host/src/services/hooks.ts | 41 +- .../src/services/meshConnection.ts | 301 +- shared/desktop-host/src/services/types.ts | 5 + yarn.lock | 3125 +++++++++-------- 67 files changed, 10160 insertions(+), 4708 deletions(-) create mode 100644 apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt create mode 100644 apps/server/build.rs create mode 100644 apps/server/profiles_cache.json delete mode 100644 apps/server/src/handlers/__tests__/mesh.test.rs delete mode 100644 apps/server/src/handlers/__tests__/mod.rs delete mode 100644 apps/server/src/handlers/mesh.rs delete mode 100644 apps/server/src/handlers/mod.rs delete mode 100644 apps/server/src/services/mesh/mod.rs delete mode 100644 apps/server/src/services/mesh/room_registry.rs delete mode 100644 apps/server/src/services/mesh/time_sync.rs delete mode 100644 apps/server/src/services/mesh/topology.rs delete mode 100644 apps/server/src/services/mod.rs create mode 100644 apps/server/src/testing/test_client.rs delete mode 100644 apps/server/testing/README.md delete mode 100755 apps/server/testing/test_real_api.sh create mode 100644 profiles_cache.json create mode 100644 protos/Makefile create mode 100644 protos/README.md create mode 100644 protos/buf.gen.yaml create mode 100644 protos/gen/es/services/action_pb.ts create mode 100644 protos/gen/es/services/deck_connect.ts create mode 100644 protos/gen/es/services/deck_pb.ts create mode 100644 protos/proto/buf.yaml create mode 100644 protos/proto/services/action.proto create mode 100644 protos/proto/services/deck.proto create mode 100644 scripts/rename.js create mode 100755 scripts/test-server.sh create mode 100644 shared/client-common/src/services/gen/action_pb.ts create mode 100644 shared/client-common/src/services/gen/deck_connect.ts create mode 100644 shared/client-common/src/services/gen/deck_pb.ts create mode 100644 shared/desktop-host/src/components/DisconnectedScreen.tsx create mode 100644 shared/desktop-host/src/components/layout/ScreenLayout.tsx create mode 100644 shared/desktop-host/src/components/layout/Sidebar.tsx create mode 100644 shared/desktop-host/src/components/layout/TopBar.tsx create mode 100644 shared/desktop-host/src/components/layout/index.ts create mode 100644 shared/desktop-host/src/screens/deck-configurator/DeckConfigurator.tsx create mode 100644 shared/desktop-host/src/screens/deck-configurator/index.ts create mode 100644 shared/desktop-host/src/screens/multi-action-editor/MultiActionEditor.tsx create mode 100644 shared/desktop-host/src/screens/multi-action-editor/index.ts create mode 100644 shared/desktop-host/src/screens/plugins/PluginsMarketplace.tsx create mode 100644 shared/desktop-host/src/screens/plugins/index.ts create mode 100644 shared/desktop-host/src/screens/profiles/ProfilesAndDevices.tsx create mode 100644 shared/desktop-host/src/screens/profiles/index.ts create mode 100644 shared/desktop-host/src/services/gen/action_pb.ts create mode 100644 shared/desktop-host/src/services/gen/deck_connect.ts create mode 100644 shared/desktop-host/src/services/gen/deck_pb.ts diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt b/apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt new file mode 100644 index 0000000..064a362 --- /dev/null +++ b/apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt @@ -0,0 +1,11 @@ +package com.fastdeck.mobile + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/apps/server/Cargo.lock b/apps/server/Cargo.lock index 07aef9b..3ffa50b 100644 --- a/apps/server/Cargo.lock +++ b/apps/server/Cargo.lock @@ -27,10 +27,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "async-trait" -version = "0.1.89" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", @@ -38,28 +49,14 @@ dependencies = [ ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "audiomesh-server" -version = "0.0.1" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "axum", - "chrono", - "dashmap", - "futures-util", - "serde", - "serde_json", - "tokio", - "tokio-tungstenite", - "tower 0.4.13", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -70,83 +67,66 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.7.9" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", "axum-core", - "base64", + "bitflags 1.3.2", "bytes", "futures-util", "http", "http-body", - "http-body-util", "hyper", - "hyper-util", "itoa", "matchit", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower 0.5.3", + "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] name = "axum-core" -version = "0.4.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" dependencies = [ "async-trait", "bytes", "futures-util", "http", "http-body", - "http-body-util", "mime", - "pin-project-lite", "rustversion", - "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] name = "base64" -version = "0.22.1" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "bitflags" -version = "2.11.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" @@ -154,23 +134,17 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -184,9 +158,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -202,31 +176,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -242,29 +197,10 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "digest" -version = "0.10.7" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "equivalent" @@ -279,9 +215,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastdeck-server" +version = "0.0.1" +dependencies = [ + "chrono", + "dashmap", + "futures-util", + "prost", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "tonic-web", + "tracing", + "tracing-subscriber", + "uuid", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -289,19 +251,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "foldhash" -version = "0.1.5" +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures-channel" @@ -349,22 +308,11 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", - "futures-sink", "futures-task", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -378,31 +326,45 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" @@ -418,36 +380,31 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.1" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", + "fnv", "itoa", ] [[package]] name = "http-body" -version = "1.0.1" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http", + "pin-project-lite", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "http-range-header" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" [[package]] name = "httparse" @@ -463,37 +420,38 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "atomic-waker", "bytes", "futures-channel", "futures-core", + "futures-util", + "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "smallvec", + "socket2 0.5.10", "tokio", + "tower-service", + "tracing", + "want", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "hyper-timeout" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "bytes", - "http", - "http-body", "hyper", "pin-project-lite", "tokio", - "tower-service", + "tokio-io-timeout", ] [[package]] @@ -521,10 +479,14 @@ dependencies = [ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] [[package]] name = "indexmap" @@ -534,8 +496,15 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", ] [[package]] @@ -546,13 +515,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -562,18 +530,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -585,9 +553,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -606,9 +574,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -624,25 +592,14 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "multer" -version = "3.1.0" +name = "multimap" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "nu-ansi-term" @@ -650,7 +607,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -697,6 +654,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -751,11 +718,64 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -802,7 +822,19 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -818,21 +850,28 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "scopeguard" @@ -840,12 +879,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -889,40 +922,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -956,31 +955,35 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] -name = "spin" -version = "0.9.8" +name = "socket2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -989,28 +992,21 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "1.0.2" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] -name = "thiserror" -version = "1.0.69" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -1034,9 +1030,19 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", ] [[package]] @@ -1051,27 +1057,84 @@ dependencies = [ ] [[package]] -name = "tokio-tungstenite" -version = "0.24.0" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "futures-util", - "log", + "futures-core", + "pin-project-lite", "tokio", - "tungstenite", ] [[package]] -name = "tower" -version = "0.4.13" +name = "tokio-util" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ + "bytes", "futures-core", - "futures-util", - "pin-project", + "futures-sink", "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64", + "bytes", + "http", + "http-body", + "hyper", + "pin-project", + "tokio-stream", + "tonic", + "tower-http", "tower-layer", "tower-service", "tracing", @@ -1079,15 +1142,19 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.3" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", + "indexmap 1.9.3", + "pin-project", "pin-project-lite", - "sync_wrapper", + "rand", + "slab", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -1095,19 +1162,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", + "futures-core", + "futures-util", "http", "http-body", - "http-body-util", + "http-range-header", "pin-project-lite", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1128,7 +1196,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1185,28 +1252,10 @@ dependencies = [ ] [[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.20.1" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" @@ -1214,25 +1263,13 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -1244,10 +1281,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] -name = "version_check" -version = "0.9.5" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] [[package]] name = "wasi" @@ -1255,29 +1295,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -1288,9 +1310,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1298,9 +1320,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -1311,47 +1333,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -1411,6 +1399,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1421,98 +1418,68 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "wit-bindgen-rust-macro", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "wit-component" -version = "0.244.0" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "wit-parser" -version = "0.244.0" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "zerocopy" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 0791ca2..fd2835c 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -1,21 +1,24 @@ [package] -name = "audiomesh-server" +name = "fastdeck-server" version = "0.0.1" edition = "2021" +default-run = "fastdeck-server" [lib] -name = "audiomesh_server" +name = "fastdeck_server" path = "src/lib.rs" [[bin]] -name = "audiomesh-server" +name = "fastdeck-server" path = "src/main.rs" +[[bin]] +name = "test_client" +path = "src/testing/test_client.rs" + [dependencies] -axum = { version = "0.7.4", features = ["multipart", "ws"] } tokio = { version = "1.36.0", features = ["full"] } -tokio-tungstenite = "0.24" -tower-http = { version = "0.5.1", features = ["cors", "trace"] } +tokio-stream = "0.1" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" uuid = { version = "1.7.0", features = ["v4"] } @@ -24,7 +27,9 @@ futures-util = "0.3.30" dashmap = "6.1" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tonic = "0.11" +tonic-web = "0.11" +prost = "0.12" -[dev-dependencies] -tower = { version = "0.4.13", features = ["util"] } - +[build-dependencies] +tonic-build = "0.11" diff --git a/apps/server/README.md b/apps/server/README.md index f3d7161..330deeb 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -1,6 +1,6 @@ -# AudioMesh Server — Mesh Audio Gateway +# FastDeck Server — Mesh Audio Gateway -The AudioMesh server is a high-performance local gateway daemon written in Rust. It manages room lifecycle, peer connections, real-time tree topologies, low-latency audio packet routing (SFU), and sub-millisecond clock synchronization across local client devices. +The FastDeck server is a high-performance local gateway daemon written in Rust. It manages room lifecycle, peer connections, real-time tree topologies, low-latency audio packet routing (SFU), and sub-millisecond clock synchronization across local client devices. --- diff --git a/apps/server/build.rs b/apps/server/build.rs new file mode 100644 index 0000000..ea22bf8 --- /dev/null +++ b/apps/server/build.rs @@ -0,0 +1,12 @@ +fn main() -> Result<(), Box> { + tonic_build::configure() + .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") + .compile( + &[ + "../../protos/proto/services/action.proto", + "../../protos/proto/services/deck.proto", + ], + &["../../protos/proto"], + )?; + Ok(()) +} diff --git a/apps/server/package.json b/apps/server/package.json index 1b0287a..918e4ce 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,5 +1,5 @@ { - "name": "audiomesh-server", + "name": "fastdeck-server", "version": "0.0.1", "private": true, "scripts": { diff --git a/apps/server/profiles_cache.json b/apps/server/profiles_cache.json new file mode 100644 index 0000000..76a427a --- /dev/null +++ b/apps/server/profiles_cache.json @@ -0,0 +1,136 @@ +{ + "active_profile_id": "default", + "profiles": { + "dev": { + "id": "dev", + "name": "Developer Profile", + "grid": { + "rows": 3, + "cols": 5, + "cells": [ + { + "row": 0, + "col": 0, + "label": "Create Dir", + "icon": "📁", + "background": "#4c566a", + "action": { + "action": { + "SingleAction": { + "id": "create_dir", + "name": "Create Demo Dir", + "type": 1, + "action_details": { + "CreateFolder": { + "path": "~/fastdeck-demo" + } + } + } + } + }, + "is_enabled": true + } + ] + } + }, + "default": { + "id": "default", + "name": "Default Profile", + "grid": { + "rows": 3, + "cols": 5, + "cells": [ + { + "row": 0, + "col": 0, + "label": "Terminal", + "icon": "💻", + "background": "#2e3440", + "action": { + "action": { + "SingleAction": { + "id": "launch_terminal", + "name": "Launch Terminal", + "type": 4, + "action_details": { + "LaunchApp": { + "path_or_name": "Terminal", + "args": [] + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 0, + "col": 1, + "label": "GitHub", + "icon": "🌐", + "background": "#181717", + "action": { + "action": { + "SingleAction": { + "id": "open_github", + "name": "Open GitHub", + "type": 5, + "action_details": { + "OpenUrl": { + "url": "https://github.com" + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 0, + "col": 2, + "label": "Play/Pause", + "icon": "⏯️", + "background": "#bf616a", + "action": { + "action": { + "SingleAction": { + "id": "media_play_pause", + "name": "Play or Pause", + "type": 6, + "action_details": { + "MediaControl": { + "command": 3 + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 1, + "col": 0, + "label": "Say Hello", + "icon": "👋", + "background": "#a3be8c", + "action": { + "action": { + "SingleAction": { + "id": "run_hello", + "name": "Run Echo Hello", + "type": 3, + "action_details": { + "RunCommand": { + "command": "echo 'Hello from FastDeck!'" + } + } + } + } + }, + "is_enabled": true + } + ] + } + } + } +} \ No newline at end of file diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index 9065db5..9a3a8f3 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -4,53 +4,16 @@ use std::env; pub struct AppConfig { pub host: String, pub port: u16, - /// Maximum number of concurrent mesh rooms. - pub max_rooms: usize, - /// Maximum number of nodes per room (default, can be overridden per room). - pub max_nodes_per_room: usize, - /// Broadcast channel buffer size for SFU audio forwarding. - pub sfu_buffer_size: usize, - /// Maximum children per node in Daisy-Chain topology. - pub max_children_per_node: usize, } impl AppConfig { pub fn from_env() -> Self { - // Load configuration from environment variables, using sensible defaults let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port = env::var("PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(50065); - let max_rooms = env::var("MAX_ROOMS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10); - - let max_nodes_per_room = env::var("MAX_NODES_PER_ROOM") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(50); - - let sfu_buffer_size = env::var("SFU_BUFFER_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(256); - - let max_children_per_node = env::var("MAX_CHILDREN_PER_NODE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(3); - - Self { - host, - port, - max_rooms, - max_nodes_per_room, - sfu_buffer_size, - max_children_per_node, - } + Self { host, port } } } diff --git a/apps/server/src/handlers/__tests__/mesh.test.rs b/apps/server/src/handlers/__tests__/mesh.test.rs deleted file mode 100644 index c99be77..0000000 --- a/apps/server/src/handlers/__tests__/mesh.test.rs +++ /dev/null @@ -1,262 +0,0 @@ -use super::*; - -#[tokio::test] -async fn test_create_room() { - let app = setup_app(); - let (status, body) = post_json( - app, - "/mesh/rooms", - serde_json::json!({ - "name": "Living Room Party", - "mode": "sfu", - "host_peer_id": "host-device-1" - }), - ) - .await; - - assert_eq!(status, StatusCode::CREATED); - assert_eq!(body["name"], "Living Room Party"); - assert_eq!(body["mode"], "sfu"); - assert_eq!(body["host_peer_id"], "host-device-1"); - assert!(body["id"].is_string()); - assert_eq!(body["max_nodes"], 50); // SFU default -} - -#[tokio::test] -async fn test_create_room_direct_p2p_defaults() { - let app = setup_app(); - let (status, body) = post_json( - app, - "/mesh/rooms", - serde_json::json!({ - "name": "P2P Room", - "mode": "direct_p2p", - "host_peer_id": "host-1" - }), - ) - .await; - - assert_eq!(status, StatusCode::CREATED); - assert_eq!(body["max_nodes"], 15); // DirectP2P default -} - -#[tokio::test] -async fn test_create_room_daisy_chain_defaults() { - let app = setup_app(); - let (status, body) = post_json( - app, - "/mesh/rooms", - serde_json::json!({ - "name": "Daisy Room", - "mode": "daisy_chain", - "host_peer_id": "host-1" - }), - ) - .await; - - assert_eq!(status, StatusCode::CREATED); - assert_eq!(body["max_nodes"], 30); // DaisyChain default -} - -#[tokio::test] -async fn test_create_room_custom_max_nodes() { - let app = setup_app(); - let (status, body) = post_json( - app, - "/mesh/rooms", - serde_json::json!({ - "name": "Small Room", - "mode": "sfu", - "host_peer_id": "host-1", - "max_nodes": 5 - }), - ) - .await; - - assert_eq!(status, StatusCode::CREATED); - assert_eq!(body["max_nodes"], 5); -} - -#[tokio::test] -async fn test_list_rooms() { - let app = setup_app(); - - // Create two rooms - let _ = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "Room A", - "mode": "sfu", - "host_peer_id": "host-1" - }), - ) - .await; - - let _ = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "Room B", - "mode": "direct_p2p", - "host_peer_id": "host-2" - }), - ) - .await; - - let (status, body) = get_json(app, "/mesh/rooms").await; - - assert_eq!(status, StatusCode::OK); - assert!(body.as_array().unwrap().len() >= 2); -} - -#[tokio::test] -async fn test_get_room() { - let app = setup_app(); - - let (_, created) = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "Get Test", - "mode": "sfu", - "host_peer_id": "host-1" - }), - ) - .await; - - let room_id = created["id"].as_str().unwrap(); - let (status, body) = get_json(app, &format!("/mesh/rooms/{}", room_id)).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["room"]["name"], "Get Test"); - assert!(body["peers"].as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_get_room_not_found() { - let app = setup_app(); - let (status, body) = get_json(app, "/mesh/rooms/nonexistent-id").await; - - assert_eq!(status, StatusCode::NOT_FOUND); - assert!(body["error"].as_str().unwrap().contains("not found")); -} - -#[tokio::test] -async fn test_delete_room() { - let app = setup_app(); - - let (_, created) = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "Delete Test", - "mode": "sfu", - "host_peer_id": "host-1" - }), - ) - .await; - - let room_id = created["id"].as_str().unwrap(); - let (status, body) = delete_json(app.clone(), &format!("/mesh/rooms/{}", room_id)).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["success"], true); - - // Verify it's gone - let (status, _) = get_json(app, &format!("/mesh/rooms/{}", room_id)).await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_delete_room_not_found() { - let app = setup_app(); - let (status, _) = delete_json(app, "/mesh/rooms/nonexistent-id").await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_get_topology_empty() { - let app = setup_app(); - - let (_, created) = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "Topo Test", - "mode": "daisy_chain", - "host_peer_id": "host-1" - }), - ) - .await; - - let room_id = created["id"].as_str().unwrap(); - let (status, body) = get_json(app, &format!("/mesh/rooms/{}/topology", room_id)).await; - - assert_eq!(status, StatusCode::OK); - assert!(body["edges"].as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_get_topology_not_found() { - let app = setup_app(); - let (status, _) = get_json(app, "/mesh/rooms/nonexistent/topology").await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_report_rtt() { - let app = setup_app(); - - let (_, created) = post_json( - app.clone(), - "/mesh/rooms", - serde_json::json!({ - "name": "RTT Test", - "mode": "daisy_chain", - "host_peer_id": "host-1" - }), - ) - .await; - - let room_id = created["id"].as_str().unwrap(); - let (status, body) = post_json( - app, - &format!("/mesh/rooms/{}/topology/rtt", room_id), - serde_json::json!({ - "from_peer_id": "host-1", - "to_peer_id": "peer-1", - "rtt_ms": 12.5 - }), - ) - .await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["success"], true); -} - -#[tokio::test] -async fn test_report_rtt_room_not_found() { - let app = setup_app(); - let (status, _) = post_json( - app, - "/mesh/rooms/nonexistent/topology/rtt", - serde_json::json!({ - "from_peer_id": "a", - "to_peer_id": "b", - "rtt_ms": 10.0 - }), - ) - .await; - - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_health_check_still_works() { - let app = setup_app(); - let (status, body) = get_json(app, "/health").await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(body["status"], "ok"); -} diff --git a/apps/server/src/handlers/__tests__/mod.rs b/apps/server/src/handlers/__tests__/mod.rs deleted file mode 100644 index 44e5ede..0000000 --- a/apps/server/src/handlers/__tests__/mod.rs +++ /dev/null @@ -1,69 +0,0 @@ -#[path = "mesh.test.rs"] -mod mesh_test; - -// Common test helper imports -use crate::config::AppConfig; -use crate::handlers::create_router; -use axum::{ - body::{Body, Bytes}, - http::{Request, StatusCode}, - Router, -}; -use serde_json::Value; -use tower::ServiceExt; // for oneshot - -/// Default test configuration with permissive limits. -fn test_config() -> AppConfig { - AppConfig { - host: "127.0.0.1".to_string(), - port: 50065, - max_rooms: 100, - max_nodes_per_room: 50, - sfu_buffer_size: 256, - max_children_per_node: 3, - } -} - -// Helper function to create the test router -fn setup_app() -> Router { - create_router(&test_config()) -} - -// Helper to send a general request to the router -async fn send_request(app: Router, method: &str, uri: &str, body: Body) -> (StatusCode, Bytes) { - let req = Request::builder() - .method(method) - .uri(uri) - .header("content-type", "application/json") - .body(body) - .unwrap(); - - let res = app.oneshot(req).await.unwrap(); - let status = res.status(); - let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024) - .await - .unwrap(); - (status, body_bytes) -} - -// Helper to send a POST request with JSON payload -async fn post_json(app: Router, uri: &str, payload: Value) -> (StatusCode, Value) { - let body = Body::from(payload.to_string()); - let (status, bytes) = send_request(app, "POST", uri, body).await; - let json_val: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - (status, json_val) -} - -// Helper to send a GET request -async fn get_json(app: Router, uri: &str) -> (StatusCode, Value) { - let (status, bytes) = send_request(app, "GET", uri, Body::empty()).await; - let json_val: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - (status, json_val) -} - -// Helper to send a DELETE request -async fn delete_json(app: Router, uri: &str) -> (StatusCode, Value) { - let (status, bytes) = send_request(app, "DELETE", uri, Body::empty()).await; - let json_val: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - (status, json_val) -} diff --git a/apps/server/src/handlers/mesh.rs b/apps/server/src/handlers/mesh.rs deleted file mode 100644 index 69c1fad..0000000 --- a/apps/server/src/handlers/mesh.rs +++ /dev/null @@ -1,549 +0,0 @@ -use crate::services::mesh::{ - room_registry::RoomRegistry, time_sync::TimeSyncService, topology::TopologyManager, - ConnectionType, DeviceType, MeshMode, Peer, -}; -use axum::{ - extract::{ - ws::{Message, WebSocket}, - Path, State, WebSocketUpgrade, - }, - http::StatusCode, - response::IntoResponse, - Json, -}; -use chrono::Utc; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -/// Shared application state for all mesh endpoints. -#[derive(Clone)] -pub struct MeshState { - pub registry: Arc, - pub topology: Arc, - pub time_sync: Arc, -} - -// ─── Request / Response Payloads ─────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -pub struct CreateRoomPayload { - pub name: String, - pub mode: MeshMode, - #[serde(default)] - pub max_nodes: Option, - pub host_peer_id: String, -} - -#[derive(Debug, Deserialize)] -pub struct ReportRttPayload { - pub from_peer_id: String, - pub to_peer_id: String, - pub rtt_ms: f64, -} - -/// Signaling messages exchanged over the signaling WebSocket. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SignalingMessage { - /// Client -> Server: Join the signaling room. - Join { - peer_id: String, - display_name: String, - device_type: DeviceType, - connection_type: ConnectionType, - }, - /// Server -> Client: Current list of peers in the room. - Peers { peers: Vec }, - /// Client -> Server: SDP offer to a specific peer. - Offer { - #[serde(default, skip_serializing_if = "Option::is_none")] - from_peer_id: Option, - to_peer_id: String, - sdp: String, - }, - /// Client -> Server: SDP answer to a specific peer. - Answer { - #[serde(default, skip_serializing_if = "Option::is_none")] - from_peer_id: Option, - to_peer_id: String, - sdp: String, - }, - /// Client -> Server: ICE candidate for a specific peer. - Ice { - #[serde(default, skip_serializing_if = "Option::is_none")] - from_peer_id: Option, - to_peer_id: String, - candidate: String, - }, - /// Server -> Client: Topology assignment (Daisy-Chain mode). - TopologyAssign { parent_peer_id: String }, - /// Server -> Client: A peer has left the room. - PeerLeft { peer_id: String }, - /// Server -> Client: Error message. - Error { message: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PeerInfo { - pub id: String, - pub display_name: String, - pub device_type: DeviceType, -} - -// ─── REST Handlers ───────────────────────────────────────────────────────────── - -/// POST /mesh/rooms — Create a new mesh room. -pub async fn create_room( - State(state): State, - Json(payload): Json, -) -> impl IntoResponse { - match state.registry.create_room( - payload.name, - payload.mode.clone(), - payload.host_peer_id, - payload.max_nodes, - ) { - Ok(room) => { - // Initialize topology tree for Daisy-Chain mode - if payload.mode == MeshMode::DaisyChain { - state.topology.init_room(&room.id); - } - (StatusCode::CREATED, Json(serde_json::json!(room))).into_response() - } - Err(err) => ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -/// GET /mesh/rooms — List all active rooms. -pub async fn list_rooms(State(state): State) -> impl IntoResponse { - let rooms = state.registry.list_rooms(); - Json(serde_json::json!(rooms)) -} - -/// GET /mesh/rooms/:id — Get a specific room with its peer list. -pub async fn get_room( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - match state.registry.get_room(&id) { - Some(room) => { - let peers = state.registry.get_peers(&id); - ( - StatusCode::OK, - Json(serde_json::json!({ "room": room, "peers": peers })), - ) - .into_response() - } - None => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Room not found." })), - ) - .into_response(), - } -} - -/// DELETE /mesh/rooms/:id — Close and delete a room. -pub async fn delete_room( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - state.topology.remove_room(&id); - if state.registry.delete_room(&id) { - ( - StatusCode::OK, - Json(serde_json::json!({ "success": true })), - ) - .into_response() - } else { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Room not found." })), - ) - .into_response() - } -} - -/// GET /mesh/rooms/:id/topology — Get the current connection tree. -pub async fn get_topology( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - if state.registry.get_room(&id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Room not found." })), - ) - .into_response(); - } - - let tree = state.topology.get_tree(&id); - (StatusCode::OK, Json(serde_json::json!({ "edges": tree }))).into_response() -} - -/// POST /mesh/rooms/:id/topology/rtt — Report RTT measurement between two peers. -pub async fn report_rtt( - State(state): State, - Path(id): Path, - Json(payload): Json, -) -> impl IntoResponse { - if state.registry.get_room(&id).is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Room not found." })), - ) - .into_response(); - } - - state - .topology - .update_rtt(&id, &payload.from_peer_id, &payload.to_peer_id, payload.rtt_ms); - - ( - StatusCode::OK, - Json(serde_json::json!({ "success": true })), - ) - .into_response() -} - -// ─── WebSocket Handlers ──────────────────────────────────────────────────────── - -/// GET /ws/signaling/:room_id — WebSocket signaling for SDP/ICE exchange. -pub async fn ws_signaling( - ws: WebSocketUpgrade, - State(state): State, - Path(room_id): Path, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_signaling(socket, state, room_id)) -} - -/// GET /ws/sfu/:room_id — WebSocket SFU media channel. -pub async fn ws_sfu( - ws: WebSocketUpgrade, - State(state): State, - Path(room_id): Path, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_sfu(socket, state, room_id)) -} - -/// GET /ws/sync/:room_id — WebSocket time synchronization channel. -pub async fn ws_sync( - ws: WebSocketUpgrade, - State(state): State, - Path(room_id): Path, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_sync(socket, state, room_id)) -} - -// ─── WebSocket Implementation ────────────────────────────────────────────────── - -/// Handles the signaling WebSocket connection for a single peer. -/// -/// Protocol: -/// 1. Client sends a `join` message with peer info. -/// 2. Server registers the peer and broadcasts the updated peer list. -/// 3. Client sends `offer`, `answer`, `ice` messages targeted at specific peers. -/// 4. Server forwards these messages to the targeted peer. -/// 5. On disconnect, server removes the peer and broadcasts `peer_left`. -async fn handle_signaling(socket: WebSocket, state: MeshState, room_id: String) { - let (mut sender, mut receiver) = socket.split(); - let mut my_peer_id: Option = None; - - while let Some(msg) = receiver.next().await { - let msg = match msg { - Ok(Message::Text(text)) => text, - Ok(Message::Close(_)) | Err(_) => break, - _ => continue, - }; - - let parsed: SignalingMessage = match serde_json::from_str(&msg) { - Ok(m) => m, - Err(e) => { - let err = SignalingMessage::Error { - message: format!("Invalid message: {}", e), - }; - let _ = sender - .send(Message::Text(serde_json::to_string(&err).unwrap().into())) - .await; - continue; - } - }; - - match parsed { - SignalingMessage::Join { - peer_id, - display_name, - device_type, - connection_type, - } => { - let peer = Peer { - id: peer_id.clone(), - display_name: display_name.clone(), - device_type: device_type.clone(), - connection_type, - joined_at: Utc::now(), - parent_peer_id: None, - }; - - if let Err(e) = state.registry.add_peer(&room_id, peer) { - let err = SignalingMessage::Error { message: e }; - let _ = sender - .send(Message::Text(serde_json::to_string(&err).unwrap().into())) - .await; - continue; - } - - my_peer_id = Some(peer_id.clone()); - - // If Daisy-Chain mode, assign a parent and notify the peer - if let Some(room) = state.registry.get_room(&room_id) { - if room.mode == MeshMode::DaisyChain { - let all_peers: Vec = state - .registry - .get_peers(&room_id) - .iter() - .map(|p| p.id.clone()) - .collect(); - - if let Some(parent) = state.topology.assign_parent( - &room_id, - &peer_id, - &room.host_peer_id, - &all_peers, - ) { - state - .registry - .set_peer_parent(&room_id, &peer_id, Some(parent.clone())); - - let assign = SignalingMessage::TopologyAssign { - parent_peer_id: parent, - }; - let _ = sender - .send(Message::Text( - serde_json::to_string(&assign).unwrap().into(), - )) - .await; - } - } - } - - // Send current peer list - let peers: Vec = state - .registry - .get_peers(&room_id) - .iter() - .map(|p| PeerInfo { - id: p.id.clone(), - display_name: p.display_name.clone(), - device_type: p.device_type.clone(), - }) - .collect(); - - let peers_msg = SignalingMessage::Peers { peers }; - let _ = sender - .send(Message::Text( - serde_json::to_string(&peers_msg).unwrap().into(), - )) - .await; - } - - // For offer/answer/ice: In a full implementation, we would maintain - // a map of peer_id -> sender and forward messages to the target peer. - // For now, we acknowledge receipt. The forwarding mechanism requires - // a shared sender registry (covered by the broadcast channel pattern). - SignalingMessage::Offer { to_peer_id, sdp, .. } => { - if let Some(ref pid) = my_peer_id { - tracing::info!( - "Signaling: {} -> {} (offer, {} bytes SDP)", - pid, - to_peer_id, - sdp.len() - ); - } - // TODO: Forward to target peer via shared sender registry - } - - SignalingMessage::Answer { to_peer_id, sdp, .. } => { - if let Some(ref pid) = my_peer_id { - tracing::info!( - "Signaling: {} -> {} (answer, {} bytes SDP)", - pid, - to_peer_id, - sdp.len() - ); - } - // TODO: Forward to target peer via shared sender registry - } - - SignalingMessage::Ice { - to_peer_id, - candidate, - .. - } => { - if let Some(ref pid) = my_peer_id { - tracing::info!( - "Signaling: {} -> {} (ice, {} bytes)", - pid, - to_peer_id, - candidate.len() - ); - } - // TODO: Forward to target peer via shared sender registry - } - - _ => { - // Ignore server-originated message types from clients - } - } - } - - // Cleanup on disconnect - if let Some(peer_id) = my_peer_id { - // Remove from topology first (to re-parent children) - let orphans = state.topology.remove_node(&room_id, &peer_id); - for orphan in &orphans { - tracing::info!( - "Daisy-chain self-heal: re-parented '{}' after '{}' disconnected", - orphan, - peer_id - ); - } - - state.registry.remove_peer(&room_id, &peer_id); - tracing::info!("Peer '{}' left room '{}'", peer_id, room_id); - } -} - -/// Handles the SFU WebSocket connection. -/// -/// Protocol: -/// - First binary message determines the role: -/// - Text message `{"role":"host"}` -> this is the audio uplink (host publishes) -/// - Text message `{"role":"client"}` -> this is a downlink (client subscribes) -/// - Host sends binary frames: [8-byte timestamp][Opus audio data] -/// - Server fans out binary frames to all subscribed clients via broadcast channel. -async fn handle_sfu(socket: WebSocket, state: MeshState, room_id: String) { - let (mut sender, mut receiver) = socket.split(); - - // Wait for the role identification message - let role_msg = match receiver.next().await { - Some(Ok(Message::Text(text))) => text.to_string(), - _ => return, - }; - - #[derive(Deserialize)] - struct RoleMsg { - role: String, - } - - let role: RoleMsg = match serde_json::from_str(&role_msg) { - Ok(r) => r, - Err(_) => return, - }; - - match role.role.as_str() { - "host" => { - // Host uplink: read binary frames and publish to broadcast channel - let sfu_sender = match state.registry.get_sfu_sender(&room_id) { - Some(s) => s, - None => return, - }; - - tracing::info!("SFU: Host connected as uplink for room '{}'", room_id); - - while let Some(msg) = receiver.next().await { - match msg { - Ok(Message::Binary(data)) => { - // Publish to all subscribers; ignore send errors - // (happens when no subscribers are connected) - let _ = sfu_sender.send(Arc::new(data.to_vec())); - } - Ok(Message::Close(_)) | Err(_) => break, - _ => continue, - } - } - - tracing::info!("SFU: Host disconnected from room '{}'", room_id); - } - - "client" => { - // Client downlink: subscribe to broadcast and forward frames - let mut sfu_receiver = match state.registry.subscribe_sfu(&room_id) { - Some(r) => r, - None => return, - }; - - tracing::info!("SFU: Client subscribed to room '{}'", room_id); - - loop { - match sfu_receiver.recv().await { - Ok(data) => { - if sender - .send(Message::Binary(data.as_ref().clone().into())) - .await - .is_err() - { - break; - } - } - Err(broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("SFU: Client lagged by {} frames, skipping", n); - continue; - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - - tracing::info!("SFU: Client disconnected from room '{}'", room_id); - } - - _ => { - tracing::warn!("SFU: Unknown role '{}', disconnecting", role.role); - } - } -} - -use tokio::sync::broadcast; - -/// Handles the time sync WebSocket connection. -/// -/// Protocol: Client sends JSON `{ "client_send_us": }`, -/// server responds with `{ "client_send_us", "server_recv_us", "server_send_us" }`. -/// Client performs multiple rounds and averages the clock offset. -async fn handle_sync(socket: WebSocket, state: MeshState, room_id: String) { - if state.registry.get_room(&room_id).is_none() { - return; - } - - let (mut sender, mut receiver) = socket.split(); - - while let Some(msg) = receiver.next().await { - let text = match msg { - Ok(Message::Text(t)) => t.to_string(), - Ok(Message::Close(_)) | Err(_) => break, - _ => continue, - }; - - let request: crate::services::mesh::time_sync::TimeSyncRequest = - match serde_json::from_str(&text) { - Ok(r) => r, - Err(_) => continue, - }; - - let response = state.time_sync.process_sync(request); - - if sender - .send(Message::Text( - serde_json::to_string(&response).unwrap().into(), - )) - .await - .is_err() - { - break; - } - } -} diff --git a/apps/server/src/handlers/mod.rs b/apps/server/src/handlers/mod.rs deleted file mode 100644 index c98b115..0000000 --- a/apps/server/src/handlers/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::config::AppConfig; -use crate::services::mesh::{ - room_registry::RoomRegistry, time_sync::TimeSyncService, topology::TopologyManager, -}; -use axum::{ - response::IntoResponse, - routing::{delete, get, post}, - Json, Router, -}; -use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; - -pub mod mesh; - -#[cfg(test)] -mod __tests__; - -async fn health_check() -> impl IntoResponse { - (axum::http::StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))) -} - -pub fn create_router(config: &AppConfig) -> Router { - // Setup CORS layer allowing local development requests - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - // Initialize mesh services - let mesh_state = mesh::MeshState { - registry: Arc::new(RoomRegistry::new(config.max_rooms, config.sfu_buffer_size)), - topology: Arc::new(TopologyManager::new(config.max_children_per_node)), - time_sync: Arc::new(TimeSyncService::new()), - }; - - // Mesh routes (no auth required — mesh uses its own room-level access) - let mesh_routes = Router::new() - .route("/mesh/rooms", post(mesh::create_room)) - .route("/mesh/rooms", get(mesh::list_rooms)) - .route("/mesh/rooms/:id", get(mesh::get_room)) - .route("/mesh/rooms/:id", delete(mesh::delete_room)) - .route("/mesh/rooms/:id/topology", get(mesh::get_topology)) - .route("/mesh/rooms/:id/topology/rtt", post(mesh::report_rtt)) - // WebSocket endpoints - .route("/ws/signaling/:room_id", get(mesh::ws_signaling)) - .route("/ws/sfu/:room_id", get(mesh::ws_sfu)) - .route("/ws/sync/:room_id", get(mesh::ws_sync)) - .with_state(mesh_state); - - Router::new() - // Health check endpoint - .route("/health", get(health_check)) - // Merge mesh routes - .merge(mesh_routes) - .layer(cors) - .layer(axum::extract::DefaultBodyLimit::max(20 * 1024 * 1024)) -} diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index d6914b7..78be937 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -1,24 +1,473 @@ pub mod config; -pub mod handlers; -pub mod services; -use config::AppConfig; -use handlers::create_router; +pub mod proto { + pub mod services { + pub mod v1 { + tonic::include_proto!("fastdeck.services.v1"); + } + } +} + +use dashmap::DashMap; +use futures_util::lock::Mutex as FutureMutex; +use std::fs::File; +use std::io::{Read, Write}; use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status, transport::Server}; -pub async fn run_server(addr: SocketAddr) -> Result<(), Box> { - // Load configuration - let config = AppConfig::from_env(); - tracing::info!("Loaded server configuration: {:?}", config); +use proto::services::v1::{ + Action, ActionType, Cell, CellAction, CreateFolderAction, Grid, LaunchAppAction, + MediaCommand, MediaControlAction, OpenUrlAction, Profile, RunCommandAction, + ServerInfo, GetDeckInfoRequest, GetDeckInfoResponse, + StreamDeckUpdatesRequest, StreamDeckUpdatesResponse, + TriggerActionRequest, TriggerActionResponse, + SwitchProfileRequest, SwitchProfileResponse, + UpdateCellRequest, UpdateCellResponse, + deck_service_server::{DeckService, DeckServiceServer}, +}; + +const CACHE_FILE_PATH: &str = "profiles_cache.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct CacheData { + active_profile_id: String, + profiles: std::collections::HashMap, +} + +fn load_cache() -> (String, DashMap) { + let path = Path::new(CACHE_FILE_PATH); + if path.exists() { + if let Ok(mut file) = File::open(path) { + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_ok() { + if let Ok(cache_data) = serde_json::from_str::(&contents) { + let dash_map = DashMap::new(); + for (k, v) in cache_data.profiles { + dash_map.insert(k, v); + } + tracing::info!("Loaded active profile '{}' and {} profiles from cache file", cache_data.active_profile_id, dash_map.len()); + return (cache_data.active_profile_id, dash_map); + } + } + } + tracing::warn!("Failed to load cache from file; resetting to defaults"); + } + + // Default configuration if cache not found/corrupted + let default_profile = create_default_profile(); + let dev_profile = create_dev_profile(); + let dash_map = DashMap::new(); + dash_map.insert(default_profile.id.clone(), default_profile); + dash_map.insert(dev_profile.id.clone(), dev_profile); + + let active_id = "default".to_string(); + + // Save defaults to cache immediately + let cache_data = CacheData { + active_profile_id: active_id.clone(), + profiles: dash_map.iter().map(|ref_val| (ref_val.key().clone(), ref_val.value().clone())).collect(), + }; + if let Ok(serialized) = serde_json::to_string_pretty(&cache_data) { + if let Ok(mut file) = File::create(path) { + let _ = file.write_all(serialized.as_bytes()); + } + } + + (active_id, dash_map) +} + +fn save_cache(active_profile_id: &str, profiles: &DashMap) { + let cache_data = CacheData { + active_profile_id: active_profile_id.to_string(), + profiles: profiles.iter().map(|ref_val| (ref_val.key().clone(), ref_val.value().clone())).collect(), + }; + if let Ok(serialized) = serde_json::to_string_pretty(&cache_data) { + let path = Path::new(CACHE_FILE_PATH); + // Write asynchronously to prevent blocking the gRPC thread + tokio::spawn(async move { + if let Err(err) = tokio::fs::write(path, serialized.as_bytes()).await { + tracing::error!("Failed to write profile cache to file: {}", err); + } else { + tracing::debug!("Saved profile cache successfully"); + } + }); + } +} + +type ClientSender = mpsc::Sender>; + +pub struct MyDeckService { + active_profile_id: RwLock, + profiles: DashMap, + clients: Arc>>, +} + +impl MyDeckService { + pub fn new() -> Self { + let (active_id, profiles) = load_cache(); + + Self { + active_profile_id: RwLock::new(active_id), + profiles, + clients: Arc::new(FutureMutex::new(Vec::new())), + } + } + + async fn broadcast_update(&self, update: proto::services::v1::stream_deck_updates_response::Update) { + let mut clients = self.clients.lock().await; + let mut to_remove = Vec::new(); + + for (idx, client) in clients.iter().enumerate() { + let msg = StreamDeckUpdatesResponse { + update: Some(update.clone()), + }; + if client.send(Ok(msg)).await.is_err() { + to_remove.push(idx); + } + } + + // Clean up disconnected clients in reverse order + for idx in to_remove.into_iter().rev() { + clients.swap_remove(idx); + } + } +} + +#[tonic::async_trait] +impl DeckService for MyDeckService { + async fn get_deck_info( + &self, + _request: Request, + ) -> Result, Status> { + let active_id = self.active_profile_id.read().unwrap().clone(); + let active_profile = self.profiles.get(&active_id).map(|ref_val| ref_val.value().clone()); + + let server_info = ServerInfo { + name: "FastDeck Local Server".to_string(), + version: "0.1.0".to_string(), + active_profile_id: active_id, + available_profiles: self.profiles.iter().map(|kv| kv.key().clone()).collect(), + }; + + Ok(Response::new(GetDeckInfoResponse { + server_info: Some(server_info), + active_profile, + })) + } + + type StreamDeckUpdatesStream = ReceiverStream>; + + async fn stream_deck_updates( + &self, + _request: Request, + ) -> Result, Status> { + let (tx, rx) = mpsc::channel(100); + + // Send initial state to the client immediately + let active_id = self.active_profile_id.read().unwrap().clone(); + if let Some(profile) = self.profiles.get(&active_id) { + let initial_msg = StreamDeckUpdatesResponse { + update: Some(proto::services::v1::stream_deck_updates_response::Update::ActiveProfileUpdate( + profile.value().clone(), + )), + }; + let _ = tx.send(Ok(initial_msg)).await; + } - // Create Axum Router with mesh services - let app = create_router(&config); + let mut clients = self.clients.lock().await; + clients.push(tx); - tracing::info!("AudioMesh Rust backend server listening on {}", addr); + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn trigger_action( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + match req.trigger { + Some(proto::services::v1::trigger_action_request::Trigger::ActionId(id)) => { + tracing::info!("Triggering action by ID: {}", id); + Ok(Response::new(TriggerActionResponse { + success: true, + error_message: String::new(), + })) + } + Some(proto::services::v1::trigger_action_request::Trigger::Coordinate(coord)) => { + tracing::info!("Triggering action at coordinate: {}, {}", coord.row, coord.col); + + let active_id = self.active_profile_id.read().unwrap().clone(); + if let Some(profile) = self.profiles.get(&active_id) { + if let Some(grid) = &profile.grid { + if let Some(cell) = grid.cells.iter().find(|c| c.row == coord.row && c.col == coord.col) { + if cell.is_enabled { + if let Some(cell_act) = &cell.action { + match &cell_act.action { + Some(proto::services::v1::cell_action::Action::SingleAction(act)) => { + tracing::info!("Executing Single Action: {} (Type: {:?})", act.name, act.r#type); + } + Some(proto::services::v1::cell_action::Action::MultiAction(multi)) => { + tracing::info!("Executing Multi Action: {} with {} steps", multi.name, multi.steps.len()); + } + None => {} + } + } + } + } + } + } + + Ok(Response::new(TriggerActionResponse { + success: true, + error_message: String::new(), + })) + } + None => Err(Status::invalid_argument("No action trigger specified")), + } + } + + async fn switch_profile( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + if self.profiles.contains_key(&req.profile_id) { + { + let mut active_id = self.active_profile_id.write().unwrap(); + *active_id = req.profile_id.clone(); + } + + tracing::info!("Switched active profile to: {}", req.profile_id); + + // Save cache + save_cache(&req.profile_id, &self.profiles); + + // Broadcast the active profile update to all stream subscribers + if let Some(profile) = self.profiles.get(&req.profile_id) { + self.broadcast_update( + proto::services::v1::stream_deck_updates_response::Update::ActiveProfileUpdate( + profile.value().clone(), + ) + ).await; + } + + Ok(Response::new(SwitchProfileResponse { + success: true, + error_message: String::new(), + })) + } else { + Ok(Response::new(SwitchProfileResponse { + success: false, + error_message: format!("Profile '{}' not found", req.profile_id), + })) + } + } + + async fn update_cell( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let profile_id = req.profile_id; + let Some(cell) = req.cell else { + return Ok(Response::new(UpdateCellResponse { + success: false, + error_message: "No cell configuration provided".to_string(), + })); + }; + + let mut success = false; + let mut error_message = String::new(); + + if let Some(mut profile) = self.profiles.get_mut(&profile_id) { + if let Some(grid) = &mut profile.grid { + if let Some(idx) = grid.cells.iter().position(|c| c.row == cell.row && c.col == cell.col && c.page == cell.page) { + grid.cells[idx] = cell.clone(); + } else { + grid.cells.push(cell.clone()); + } + success = true; + } else { + error_message = "Profile grid is missing".to_string(); + } + } else { + error_message = format!("Profile '{}' not found", profile_id); + } + + if success { + save_cache(&profile_id, &self.profiles); + + // Broadcast the cell update to all stream subscribers + self.broadcast_update( + proto::services::v1::stream_deck_updates_response::Update::CellUpdate(cell), + ).await; + } + + Ok(Response::new(UpdateCellResponse { + success, + error_message, + })) + } +} + +fn create_default_profile() -> Profile { + let mut cells = Vec::new(); + + // Cell (0, 0): Launch App (Terminal) + cells.push(Cell { + row: 0, + col: 0, + label: "Terminal".to_string(), + icon: "💻".to_string(), + background: "#2e3440".to_string(), + action: Some(CellAction { + action: Some(proto::services::v1::cell_action::Action::SingleAction(Action { + id: "launch_terminal".to_string(), + name: "Launch Terminal".to_string(), + r#type: ActionType::LaunchApp as i32, + action_details: Some(proto::services::v1::action::ActionDetails::LaunchApp( + LaunchAppAction { + path_or_name: "Terminal".to_string(), + args: vec![], + }, + )), + })), + }), + is_enabled: true, + }); + + // Cell (0, 1): Open URL (GitHub) + cells.push(Cell { + row: 0, + col: 1, + label: "GitHub".to_string(), + icon: "🌐".to_string(), + background: "#181717".to_string(), + action: Some(CellAction { + action: Some(proto::services::v1::cell_action::Action::SingleAction(Action { + id: "open_github".to_string(), + name: "Open GitHub".to_string(), + r#type: ActionType::OpenUrl as i32, + action_details: Some(proto::services::v1::action::ActionDetails::OpenUrl( + OpenUrlAction { + url: "https://github.com".to_string(), + }, + )), + })), + }), + is_enabled: true, + }); + + // Cell (0, 2): Media Play/Pause + cells.push(Cell { + row: 0, + col: 2, + label: "Play/Pause".to_string(), + icon: "⏯️".to_string(), + background: "#bf616a".to_string(), + action: Some(CellAction { + action: Some(proto::services::v1::cell_action::Action::SingleAction(Action { + id: "media_play_pause".to_string(), + name: "Play or Pause".to_string(), + r#type: ActionType::MediaControl as i32, + action_details: Some(proto::services::v1::action::ActionDetails::MediaControl( + MediaControlAction { + command: MediaCommand::PlayPause as i32, + }, + )), + })), + }), + is_enabled: true, + }); + + // Cell (1, 0): Run Command + cells.push(Cell { + row: 1, + col: 0, + label: "Say Hello".to_string(), + icon: "👋".to_string(), + background: "#a3be8c".to_string(), + action: Some(CellAction { + action: Some(proto::services::v1::cell_action::Action::SingleAction(Action { + id: "run_hello".to_string(), + name: "Run Echo Hello".to_string(), + r#type: ActionType::RunCommand as i32, + action_details: Some(proto::services::v1::action::ActionDetails::RunCommand( + RunCommandAction { + command: "echo 'Hello from FastDeck!'".to_string(), + }, + )), + })), + }), + is_enabled: true, + }); + + Profile { + id: "default".to_string(), + name: "Default Profile".to_string(), + grid: Some(Grid { + rows: 3, + cols: 5, + cells, + }), + } +} + +fn create_dev_profile() -> Profile { + let mut cells = Vec::new(); + + // Cell (0, 0): Create Folder + cells.push(Cell { + row: 0, + col: 0, + label: "Create Dir".to_string(), + icon: "📁".to_string(), + background: "#4c566a".to_string(), + action: Some(CellAction { + action: Some(proto::services::v1::cell_action::Action::SingleAction(Action { + id: "create_dir".to_string(), + name: "Create Demo Dir".to_string(), + r#type: ActionType::CreateFolder as i32, + action_details: Some(proto::services::v1::action::ActionDetails::CreateFolder( + CreateFolderAction { + path: "~/fastdeck-demo".to_string(), + }, + )), + })), + }), + is_enabled: true, + }); + + Profile { + id: "dev".to_string(), + name: "Developer Profile".to_string(), + grid: Some(Grid { + rows: 3, + cols: 5, + cells, + }), + } +} + +pub async fn run_server(addr: SocketAddr) -> Result<(), Box> { + let my_service = MyDeckService::new(); + let service_server = DeckServiceServer::new(my_service); + let web_service = tonic_web::enable(service_server); - let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("FastDeck gRPC/gRPC-Web server listening on {}", addr); - axum::serve(listener, app).await?; + Server::builder() + .accept_http1(true) + .add_service(web_service) + .serve(addr) + .await?; Ok(()) } diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 28bd644..ec313c3 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use audiomesh_server::{config::AppConfig, run_server}; +use fastdeck_server::{config::AppConfig, run_server}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -8,7 +8,7 @@ async fn main() { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "audiomesh_server=info,tower_http=debug".into()), + .unwrap_or_else(|_| "fastdeck_server=info,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); diff --git a/apps/server/src/services/mesh/mod.rs b/apps/server/src/services/mesh/mod.rs deleted file mode 100644 index e9dc8ca..0000000 --- a/apps/server/src/services/mesh/mod.rs +++ /dev/null @@ -1,94 +0,0 @@ -pub mod room_registry; -pub mod time_sync; -pub mod topology; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// The mesh operating mode for a room. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum MeshMode { - #[serde(rename = "direct_p2p")] - DirectP2P, - #[serde(rename = "daisy_chain")] - DaisyChain, - #[serde(rename = "sfu")] - SFU, -} - -/// A mesh audio room — an isolated session where devices connect to play synchronized audio. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Room { - pub id: String, - pub name: String, - pub mode: MeshMode, - pub host_peer_id: String, - pub created_at: DateTime, - pub max_nodes: usize, -} - -/// The type of physical device in the mesh. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum DeviceType { - Desktop, - Phone, - Tablet, - Speaker, - Browser, -} - -/// The transport protocol a peer is using. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ConnectionType { - WiFi, - Bluetooth, - WebSocket, -} - -/// A connected device (peer) in a mesh room. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Peer { - pub id: String, - pub display_name: String, - pub device_type: DeviceType, - pub connection_type: ConnectionType, - pub joined_at: DateTime, - /// In Daisy-Chain mode, the peer this node receives audio from. - /// `None` for the host node or in non-daisy-chain modes. - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_peer_id: Option, -} - -/// The health status of a connection edge between two peers. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum EdgeStatus { - Connecting, - Synced, - Degraded, - Disconnected, -} - -/// A directed connection edge between two peers in the topology graph. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TopologyEdge { - pub from_peer_id: String, - pub to_peer_id: String, - /// Measured round-trip time in milliseconds between the two peers. - pub rtt_ms: Option, - pub status: EdgeStatus, -} - -/// Default node limits per mesh mode. -impl MeshMode { - pub fn default_max_nodes(&self) -> usize { - match self { - MeshMode::DirectP2P => 15, - MeshMode::DaisyChain => 30, - MeshMode::SFU => 50, - } - } -} diff --git a/apps/server/src/services/mesh/room_registry.rs b/apps/server/src/services/mesh/room_registry.rs deleted file mode 100644 index 3e0cced..0000000 --- a/apps/server/src/services/mesh/room_registry.rs +++ /dev/null @@ -1,321 +0,0 @@ -use super::{MeshMode, Peer, Room}; -use chrono::Utc; -use dashmap::DashMap; -use std::sync::Arc; -use tokio::sync::broadcast; -use uuid::Uuid; - -/// Thread-safe room registry backed by DashMap. -/// Manages the lifecycle of rooms and their connected peers. -#[derive(Debug)] -pub struct RoomRegistry { - rooms: DashMap, - peers: DashMap>, - /// Broadcast channels for SFU audio forwarding (room_id -> sender). - sfu_channels: DashMap>>>, - /// Maximum number of concurrent rooms. - pub max_rooms: usize, - /// Default broadcast channel buffer size. - pub sfu_buffer_size: usize, -} - -impl RoomRegistry { - /// Creates a new RoomRegistry with the given limits. - pub fn new(max_rooms: usize, sfu_buffer_size: usize) -> Self { - Self { - rooms: DashMap::new(), - peers: DashMap::new(), - sfu_channels: DashMap::new(), - max_rooms, - sfu_buffer_size, - } - } - - /// Creates a new room and returns it. - pub fn create_room( - &self, - name: String, - mode: MeshMode, - host_peer_id: String, - max_nodes: Option, - ) -> Result { - if self.rooms.len() >= self.max_rooms { - return Err(format!( - "Maximum number of rooms ({}) reached.", - self.max_rooms - )); - } - - let id = Uuid::new_v4().to_string(); - let max_nodes = max_nodes.unwrap_or_else(|| mode.default_max_nodes()); - - let room = Room { - id: id.clone(), - name, - mode, - host_peer_id, - created_at: Utc::now(), - max_nodes, - }; - - self.rooms.insert(id.clone(), room.clone()); - self.peers.insert(id.clone(), Vec::new()); - - // Create a broadcast channel for SFU mode - let (tx, _rx) = broadcast::channel(self.sfu_buffer_size); - self.sfu_channels.insert(id, tx); - - Ok(room) - } - - /// Returns a room by ID. - pub fn get_room(&self, room_id: &str) -> Option { - self.rooms.get(room_id).map(|r| r.value().clone()) - } - - /// Lists all active rooms. - pub fn list_rooms(&self) -> Vec { - self.rooms.iter().map(|r| r.value().clone()).collect() - } - - /// Deletes a room and all associated state. - pub fn delete_room(&self, room_id: &str) -> bool { - let removed = self.rooms.remove(room_id).is_some(); - self.peers.remove(room_id); - self.sfu_channels.remove(room_id); - removed - } - - /// Adds a peer to a room. - pub fn add_peer(&self, room_id: &str, peer: Peer) -> Result<(), String> { - let room = self - .rooms - .get(room_id) - .ok_or_else(|| format!("Room '{}' not found.", room_id))?; - - let mut peers = self - .peers - .get_mut(room_id) - .ok_or_else(|| format!("Room '{}' peer list not found.", room_id))?; - - if peers.len() >= room.max_nodes { - return Err(format!( - "Room '{}' is full ({}/{} nodes).", - room_id, - peers.len(), - room.max_nodes - )); - } - - // Prevent duplicate peer IDs - if peers.iter().any(|p| p.id == peer.id) { - return Err(format!( - "Peer '{}' already exists in room '{}'.", - peer.id, room_id - )); - } - - peers.push(peer); - Ok(()) - } - - /// Removes a peer from a room by peer ID. - pub fn remove_peer(&self, room_id: &str, peer_id: &str) -> bool { - if let Some(mut peers) = self.peers.get_mut(room_id) { - let before = peers.len(); - peers.retain(|p| p.id != peer_id); - peers.len() < before - } else { - false - } - } - - /// Gets all peers in a room. - pub fn get_peers(&self, room_id: &str) -> Vec { - self.peers - .get(room_id) - .map(|p| p.value().clone()) - .unwrap_or_default() - } - - /// Returns the SFU broadcast sender for a room (for the host to publish audio). - pub fn get_sfu_sender(&self, room_id: &str) -> Option>>> { - self.sfu_channels.get(room_id).map(|s| s.value().clone()) - } - - /// Subscribes to the SFU broadcast channel for a room (for clients to receive audio). - pub fn subscribe_sfu(&self, room_id: &str) -> Option>>> { - self.sfu_channels.get(room_id).map(|s| s.subscribe()) - } - - /// Updates a peer's parent_peer_id (used in Daisy-Chain mode for topology assignment). - pub fn set_peer_parent( - &self, - room_id: &str, - peer_id: &str, - parent_peer_id: Option, - ) -> bool { - if let Some(mut peers) = self.peers.get_mut(room_id) { - if let Some(peer) = peers.iter_mut().find(|p| p.id == peer_id) { - peer.parent_peer_id = parent_peer_id; - return true; - } - } - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::services::mesh::{ConnectionType, DeviceType, MeshMode}; - - fn make_peer(id: &str, name: &str) -> Peer { - Peer { - id: id.to_string(), - display_name: name.to_string(), - device_type: DeviceType::Phone, - connection_type: ConnectionType::WiFi, - joined_at: Utc::now(), - parent_peer_id: None, - } - } - - #[test] - fn test_create_and_get_room() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room( - "Test Room".into(), - MeshMode::SFU, - "host-1".into(), - None, - ) - .unwrap(); - - assert_eq!(room.name, "Test Room"); - assert_eq!(room.mode, MeshMode::SFU); - assert_eq!(room.max_nodes, 50); - - let fetched = registry.get_room(&room.id).unwrap(); - assert_eq!(fetched.id, room.id); - } - - #[test] - fn test_list_rooms() { - let registry = RoomRegistry::new(10, 256); - registry - .create_room("Room A".into(), MeshMode::DirectP2P, "host-1".into(), None) - .unwrap(); - registry - .create_room("Room B".into(), MeshMode::SFU, "host-2".into(), None) - .unwrap(); - - let rooms = registry.list_rooms(); - assert_eq!(rooms.len(), 2); - } - - #[test] - fn test_delete_room() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("Temp".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - - assert!(registry.delete_room(&room.id)); - assert!(registry.get_room(&room.id).is_none()); - } - - #[test] - fn test_max_rooms_limit() { - let registry = RoomRegistry::new(1, 256); - registry - .create_room("Room 1".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - let result = registry.create_room("Room 2".into(), MeshMode::SFU, "host-2".into(), None); - assert!(result.is_err()); - } - - #[test] - fn test_add_and_get_peers() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - - registry - .add_peer(&room.id, make_peer("peer-1", "iPhone 15")) - .unwrap(); - registry - .add_peer(&room.id, make_peer("peer-2", "iPad Pro")) - .unwrap(); - - let peers = registry.get_peers(&room.id); - assert_eq!(peers.len(), 2); - assert_eq!(peers[0].display_name, "iPhone 15"); - } - - #[test] - fn test_remove_peer() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - - registry - .add_peer(&room.id, make_peer("peer-1", "Phone")) - .unwrap(); - assert!(registry.remove_peer(&room.id, "peer-1")); - assert_eq!(registry.get_peers(&room.id).len(), 0); - } - - #[test] - fn test_duplicate_peer_rejected() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - - registry - .add_peer(&room.id, make_peer("peer-1", "Phone")) - .unwrap(); - let result = registry.add_peer(&room.id, make_peer("peer-1", "Phone Again")); - assert!(result.is_err()); - } - - #[test] - fn test_max_nodes_limit() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("Tiny".into(), MeshMode::SFU, "host-1".into(), Some(2)) - .unwrap(); - - registry - .add_peer(&room.id, make_peer("p1", "Dev 1")) - .unwrap(); - registry - .add_peer(&room.id, make_peer("p2", "Dev 2")) - .unwrap(); - let result = registry.add_peer(&room.id, make_peer("p3", "Dev 3")); - assert!(result.is_err()); - } - - #[test] - fn test_sfu_channel() { - let registry = RoomRegistry::new(10, 256); - let room = registry - .create_room("SFU Test".into(), MeshMode::SFU, "host-1".into(), None) - .unwrap(); - - let sender = registry.get_sfu_sender(&room.id); - assert!(sender.is_some()); - - let mut receiver = registry.subscribe_sfu(&room.id).unwrap(); - - let data = Arc::new(vec![1u8, 2, 3, 4]); - sender.unwrap().send(data.clone()).unwrap(); - - let received = receiver.try_recv().unwrap(); - assert_eq!(*received, vec![1u8, 2, 3, 4]); - } -} diff --git a/apps/server/src/services/mesh/time_sync.rs b/apps/server/src/services/mesh/time_sync.rs deleted file mode 100644 index fa0ac94..0000000 --- a/apps/server/src/services/mesh/time_sync.rs +++ /dev/null @@ -1,112 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::time::Instant; - -/// Request payload sent by clients for time synchronization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TimeSyncRequest { - /// Timestamp (in microseconds since some client epoch) when the client sent this request. - pub client_send_us: i64, -} - -/// Response payload returned by the server for time synchronization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TimeSyncResponse { - /// Echo of the client's send timestamp. - pub client_send_us: i64, - /// Server timestamp (in microseconds since server epoch) when the request was received. - pub server_recv_us: i64, - /// Server timestamp (in microseconds since server epoch) when the response is sent. - pub server_send_us: i64, -} - -/// Server-side time sync service. -/// -/// Provides a reference clock for all peers in a mesh room. -/// Clients perform multiple round-trip exchanges and compute their clock offset: -/// -/// ```text -/// offset = ((server_recv - client_send) + (server_send - client_recv)) / 2 -/// ``` -/// -/// This follows the NTP symmetric algorithm. Multiple rounds are averaged -/// for sub-millisecond precision on local networks. -#[derive(Debug)] -pub struct TimeSyncService { - /// The instant when this service was created, used as the server's epoch. - epoch: Instant, -} - -impl TimeSyncService { - pub fn new() -> Self { - Self { - epoch: Instant::now(), - } - } - - /// Returns the current server time in microseconds since the server epoch. - pub fn now_us(&self) -> i64 { - self.epoch.elapsed().as_micros() as i64 - } - - /// Processes a time sync request and produces a response. - pub fn process_sync(&self, request: TimeSyncRequest) -> TimeSyncResponse { - let server_recv_us = self.now_us(); - - TimeSyncResponse { - client_send_us: request.client_send_us, - server_recv_us, - server_send_us: self.now_us(), - } - } -} - -impl Default for TimeSyncService { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread::sleep; - use std::time::Duration; - - #[test] - fn test_time_sync_response_has_correct_echo() { - let service = TimeSyncService::new(); - let request = TimeSyncRequest { - client_send_us: 123456789, - }; - - let response = service.process_sync(request); - assert_eq!(response.client_send_us, 123456789); - } - - #[test] - fn test_server_timestamps_are_ordered() { - let service = TimeSyncService::new(); - - // Small delay to ensure measurable time difference - sleep(Duration::from_millis(1)); - - let request = TimeSyncRequest { - client_send_us: 1000, - }; - - let response = service.process_sync(request); - assert!(response.server_recv_us > 0); - assert!(response.server_send_us >= response.server_recv_us); - } - - #[test] - fn test_monotonic_clock() { - let service = TimeSyncService::new(); - - let t1 = service.now_us(); - sleep(Duration::from_millis(1)); - let t2 = service.now_us(); - - assert!(t2 > t1); - } -} diff --git a/apps/server/src/services/mesh/topology.rs b/apps/server/src/services/mesh/topology.rs deleted file mode 100644 index 64790a6..0000000 --- a/apps/server/src/services/mesh/topology.rs +++ /dev/null @@ -1,313 +0,0 @@ -use super::{EdgeStatus, TopologyEdge}; -use dashmap::DashMap; - -/// Manages the Daisy-Chain tree topology for a mesh room. -/// -/// In Daisy-Chain mode, the host sends audio to a subset of peers, and those peers -/// relay the audio stream to further peers. This forms a tree rooted at the host. -/// The TopologyManager tracks this tree and handles self-healing when nodes disconnect. -#[derive(Debug)] -pub struct TopologyManager { - /// room_id -> list of directed edges in the topology tree - edges: DashMap>, - /// Maximum number of children a single node should relay to. - /// Keeps the tree balanced and prevents overloading any single device. - pub max_children_per_node: usize, -} - -impl TopologyManager { - pub fn new(max_children_per_node: usize) -> Self { - Self { - edges: DashMap::new(), - max_children_per_node, - } - } - - /// Initializes the topology tree for a room (called when a room is created). - pub fn init_room(&self, room_id: &str) { - self.edges.insert(room_id.to_string(), Vec::new()); - } - - /// Removes all topology state for a room. - pub fn remove_room(&self, room_id: &str) { - self.edges.remove(room_id); - } - - /// Assigns the best parent node for a new peer joining the room. - /// - /// Strategy: Find the node in the tree with the fewest children that hasn't - /// exceeded `max_children_per_node`. This produces a balanced tree that minimizes - /// the maximum number of hops from host to any leaf node. - /// - /// Returns the peer_id of the assigned parent, or `None` if the tree is empty - /// (meaning this peer should connect directly to the host). - pub fn assign_parent( - &self, - room_id: &str, - new_peer_id: &str, - host_peer_id: &str, - _all_peer_ids: &[String], - ) -> Option { - let mut edges = match self.edges.get_mut(room_id) { - Some(e) => e, - None => return None, - }; - - // Count children for each node currently in the tree (the host + all connected peers) - let mut child_count: std::collections::HashMap = - std::collections::HashMap::new(); - child_count.insert(host_peer_id.to_string(), 0); - for edge in edges.iter() { - child_count.insert(edge.to_peer_id.clone(), 0); - } - for edge in edges.iter() { - *child_count.entry(edge.from_peer_id.clone()).or_insert(0) += 1; - } - - // Find the node with the fewest children below the limit - let best_parent = child_count - .iter() - .filter(|(_, &count)| count < self.max_children_per_node) - .min_by_key(|(id, &count)| { - // Prefer fewer children; break ties by preferring the host - // (to keep the tree shallow) - let depth = self.get_depth(&edges, id, host_peer_id); - (depth, count) - }) - .map(|(id, _)| id.clone()); - - if let Some(ref parent_id) = best_parent { - edges.push(TopologyEdge { - from_peer_id: parent_id.clone(), - to_peer_id: new_peer_id.to_string(), - rtt_ms: None, - status: EdgeStatus::Connecting, - }); - } - - best_parent - } - - /// Returns the depth of a node in the tree (0 = host). - fn get_depth(&self, edges: &[TopologyEdge], node_id: &str, host_peer_id: &str) -> usize { - if node_id == host_peer_id { - return 0; - } - - let mut depth = 0; - let mut current = node_id.to_string(); - - for _ in 0..100 { - // Safety limit to prevent infinite loops - if let Some(edge) = edges.iter().find(|e| e.to_peer_id == current) { - depth += 1; - current = edge.from_peer_id.clone(); - if current == host_peer_id { - return depth; - } - } else { - break; - } - } - depth - } - - /// Removes a node from the topology and re-parents its children to its parent. - /// This provides self-healing when a relay node disconnects. - /// - /// Returns the list of orphaned peer IDs that were re-parented. - pub fn remove_node(&self, room_id: &str, peer_id: &str) -> Vec { - let mut edges = match self.edges.get_mut(room_id) { - Some(e) => e, - None => return Vec::new(), - }; - - // Find the parent of the disconnected node - let parent_id = edges - .iter() - .find(|e| e.to_peer_id == peer_id) - .map(|e| e.from_peer_id.clone()); - - // Find all children of the disconnected node - let children: Vec = edges - .iter() - .filter(|e| e.from_peer_id == peer_id) - .map(|e| e.to_peer_id.clone()) - .collect(); - - // Remove all edges involving the disconnected node - edges.retain(|e| e.from_peer_id != peer_id && e.to_peer_id != peer_id); - - // Re-parent children to the disconnected node's parent - if let Some(parent) = parent_id { - for child in &children { - edges.push(TopologyEdge { - from_peer_id: parent.clone(), - to_peer_id: child.clone(), - rtt_ms: None, - status: EdgeStatus::Connecting, - }); - } - } - - children - } - - /// Returns the full topology tree for a room. - pub fn get_tree(&self, room_id: &str) -> Vec { - self.edges - .get(room_id) - .map(|e| e.value().clone()) - .unwrap_or_default() - } - - /// Updates the RTT measurement between two peers. - pub fn update_rtt(&self, room_id: &str, from: &str, to: &str, rtt_ms: f64) -> bool { - if let Some(mut edges) = self.edges.get_mut(room_id) { - if let Some(edge) = edges - .iter_mut() - .find(|e| e.from_peer_id == from && e.to_peer_id == to) - { - edge.rtt_ms = Some(rtt_ms); - edge.status = if rtt_ms < 50.0 { - EdgeStatus::Synced - } else if rtt_ms < 150.0 { - EdgeStatus::Degraded - } else { - EdgeStatus::Disconnected - }; - return true; - } - } - false - } - - /// Updates the status of an edge between two peers. - pub fn update_edge_status( - &self, - room_id: &str, - from: &str, - to: &str, - status: EdgeStatus, - ) -> bool { - if let Some(mut edges) = self.edges.get_mut(room_id) { - if let Some(edge) = edges - .iter_mut() - .find(|e| e.from_peer_id == from && e.to_peer_id == to) - { - edge.status = status; - return true; - } - } - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_assign_parent_first_peer_gets_host() { - let topo = TopologyManager::new(3); - topo.init_room("room-1"); - - let parent = topo.assign_parent("room-1", "peer-1", "host", &["peer-1".into()]); - assert_eq!(parent, Some("host".to_string())); - } - - #[test] - fn test_assign_parent_balances_tree() { - let topo = TopologyManager::new(2); - topo.init_room("room-1"); - - let all_peers: Vec = - vec!["p1", "p2", "p3", "p4", "p5"] - .into_iter() - .map(String::from) - .collect(); - - // p1 -> host - let parent = topo.assign_parent("room-1", "p1", "host", &all_peers); - assert_eq!(parent, Some("host".to_string())); - - // p2 -> host (host has 1 child, still under limit of 2) - let parent = topo.assign_parent("room-1", "p2", "host", &all_peers); - assert_eq!(parent, Some("host".to_string())); - - // p3 -> should go to p1 or p2 (host is full at 2) - let parent = topo.assign_parent("room-1", "p3", "host", &all_peers); - assert!(parent == Some("p1".to_string()) || parent == Some("p2".to_string())); - } - - #[test] - fn test_remove_node_reparents_children() { - let topo = TopologyManager::new(1); - topo.init_room("room-1"); - - let peers: Vec = vec!["p1", "p2", "p3"] - .into_iter() - .map(String::from) - .collect(); - - // Build: host -> p1 -> p2 -> p3 (since limit is 1) - topo.assign_parent("room-1", "p1", "host", &peers); - topo.assign_parent("room-1", "p2", "host", &peers); - topo.assign_parent("room-1", "p3", "host", &peers); - - // Remove p1 — its child (p2) should be re-parented to host (p1's parent) - let orphans = topo.remove_node("room-1", "p1"); - assert_eq!(orphans, vec!["p2".to_string()]); - - let tree = topo.get_tree("room-1"); - // p1 should have no edges remaining - assert!(tree.iter().all(|e| e.from_peer_id != "p1" && e.to_peer_id != "p1")); - - // p2 should be re-parented to host, and p3 should still be connected to p2 - assert!(tree.iter().any(|e| e.from_peer_id == "host" && e.to_peer_id == "p2")); - assert!(tree.iter().any(|e| e.from_peer_id == "p2" && e.to_peer_id == "p3")); - assert_eq!(tree.len(), 2); - } - - #[test] - fn test_update_rtt() { - let topo = TopologyManager::new(3); - topo.init_room("room-1"); - - topo.assign_parent("room-1", "p1", "host", &["p1".into()]); - - assert!(topo.update_rtt("room-1", "host", "p1", 12.5)); - - let tree = topo.get_tree("room-1"); - let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); - assert_eq!(edge.rtt_ms, Some(12.5)); - assert_eq!(edge.status, EdgeStatus::Synced); - } - - #[test] - fn test_rtt_determines_status() { - let topo = TopologyManager::new(3); - topo.init_room("room-1"); - - topo.assign_parent("room-1", "p1", "host", &["p1".into()]); - - topo.update_rtt("room-1", "host", "p1", 100.0); - let tree = topo.get_tree("room-1"); - let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); - assert_eq!(edge.status, EdgeStatus::Degraded); - - topo.update_rtt("room-1", "host", "p1", 200.0); - let tree = topo.get_tree("room-1"); - let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); - assert_eq!(edge.status, EdgeStatus::Disconnected); - } - - #[test] - fn test_remove_room() { - let topo = TopologyManager::new(3); - topo.init_room("room-1"); - topo.assign_parent("room-1", "p1", "host", &["p1".into()]); - topo.remove_room("room-1"); - assert!(topo.get_tree("room-1").is_empty()); - } -} diff --git a/apps/server/src/services/mod.rs b/apps/server/src/services/mod.rs deleted file mode 100644 index cb3e16e..0000000 --- a/apps/server/src/services/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod mesh; diff --git a/apps/server/src/testing/test_client.rs b/apps/server/src/testing/test_client.rs new file mode 100644 index 0000000..445cc91 --- /dev/null +++ b/apps/server/src/testing/test_client.rs @@ -0,0 +1,68 @@ +use fastdeck_server::proto::services::v1::{ + deck_service_client::DeckServiceClient, GetDeckInfoRequest, SwitchProfileRequest, + TriggerActionRequest, CellCoordinate, StreamDeckUpdatesRequest, +}; +use tokio_stream::StreamExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Connecting to FastDeck gRPC server at http://127.0.0.1:50065..."); + let mut client = DeckServiceClient::connect("http://127.0.0.1:50065").await?; + println!("Connected successfully!"); + + // 1. Get Deck Info + println!("Testing get_deck_info..."); + let response = client.get_deck_info(GetDeckInfoRequest {}).await?; + let info = response.into_inner(); + let server_info = info.server_info.expect("ServerInfo missing"); + let active_profile = info.active_profile.expect("Active profile missing"); + println!("Server Name: {}", server_info.name); + println!("Active Profile: {}", active_profile.name); + assert_eq!(server_info.name, "FastDeck Local Server"); + assert_eq!(active_profile.id, "default"); + + // 2. Stream Updates (non-blocking test) + println!("Testing stream_deck_updates (reading first message)..."); + let mut stream = client.stream_deck_updates(StreamDeckUpdatesRequest { + client_id: "test-client-1".to_string(), + }).await?.into_inner(); + if let Some(msg_result) = stream.next().await { + let msg = msg_result?; + if let Some(update) = msg.update { + println!("Received initial stream update: {:?}", update); + } else { + panic!("Expected update in stream message"); + } + } + + // 3. Switch Profile to dev + println!("Testing switch_profile to 'dev'..."); + let switch_res = client.switch_profile(SwitchProfileRequest { + profile_id: "dev".to_string(), + }).await?; + let res = switch_res.into_inner(); + assert!(res.success, "Profile switch failed: {}", res.error_message); + println!("Profile switched successfully."); + + // 4. Verify Active Profile is dev + println!("Verifying active profile is 'dev'..."); + let response_dev = client.get_deck_info(GetDeckInfoRequest {}).await?; + let info_dev = response_dev.into_inner(); + let active_profile_dev = info_dev.active_profile.expect("Active profile missing"); + assert_eq!(active_profile_dev.id, "dev"); + assert_eq!(active_profile_dev.name, "Developer Profile"); + println!("Profile verification succeeded."); + + // 5. Trigger Action + println!("Testing trigger_action..."); + let trigger_res = client.trigger_action(TriggerActionRequest { + trigger: Some(fastdeck_server::proto::services::v1::trigger_action_request::Trigger::Coordinate( + CellCoordinate { row: 0, col: 0 } + )), + }).await?; + assert!(trigger_res.into_inner().success); + println!("Action triggered successfully."); + + println!("\nAll gRPC endpoints tested successfully! Server is healthy."); + Ok(()) +} diff --git a/apps/server/testing/README.md b/apps/server/testing/README.md deleted file mode 100644 index cdb0c7c..0000000 --- a/apps/server/testing/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# apps/server/testing - -Test scripts and tools for the AudioMesh Rust backend server. - ---- - -## Prerequisites - -| Tool | Purpose | Install | -| ------ | ------------------------- | ------------------------------------ | -| `curl` | HTTP requests | pre-installed on macOS/Linux | -| `jq` | JSON parsing & assertions | `brew install jq` / `apt install jq` | - ---- - -## Scripts & Testing Tools - -## 1. Interactive Integration Testing (`test_real_api.sh`) - -Since the server runs exclusively in **Real Mode** using official MTProto connections, this script helps you test the active API endpoints interactively by prompting for real SMS codes, phone numbers, and optional 2FA passwords. - -#### Usage: - -1. **Start the server**: - ```bash - cargo run - ``` -2. **Run the interactive test**: - ```bash - # From the project root - ./apps/server/testing/test_real_api.sh - ``` - ---- - -## 2. Unit & Integration Tests (`cargo test`) - -Unit and integration tests for route handlers run completely in-memory using localized test stubs without contacting any external Telegram networks. - -To run tests: - -```bash -cd apps/server/ -cargo test -``` diff --git a/apps/server/testing/test_real_api.sh b/apps/server/testing/test_real_api.sh deleted file mode 100755 index aebe3f8..0000000 --- a/apps/server/testing/test_real_api.sh +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# test_real_api.sh — API Test Suite for AudioMesh Backend -# -# Tests all AudioMesh REST endpoints (room CRUD, topology, RTT) and verifies -# pass/fail tracking across all test steps. -# ============================================================================= - -set -uo pipefail - -BASE_URL="${BASE_URL:-http://127.0.0.1:50065}" - -# ── Colour helpers ──────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# ── Stats trackers ──────────────────────────────────────────────────────────── -TESTS_PASSED=0 -TESTS_FAILED=0 -TESTS_TOTAL=0 - -# ── Helpers ─────────────────────────────────────────────────────────────────── -print_step() { - printf "\n${CYAN}${BOLD}▶ %s${RESET}\n" "$1" >&2 -} - -print_success() { - printf "${GREEN}✓ %s${RESET}\n" "$1" >&2 -} - -print_error() { - printf "${RED}✗ ERROR: %s${RESET}\n" "$1" >&2 -} - -# Low-level request helper -# Returns a JSON string containing "status" and "body" -request() { - local method="$1" - local path="$2" - local data="${3:-}" - - local response - if [ -n "$data" ]; then - response=$(curl -s -w "\n%{http_code}" -X "$method" \ - -H "Content-Type: application/json" \ - -d "$data" \ - "${BASE_URL}${path}" || printf "\n000") - else - response=$(curl -s -w "\n%{http_code}" -X "$method" \ - -H "Content-Type: application/json" \ - "${BASE_URL}${path}" || printf "\n000") - fi - - local status_code - status_code=$(printf "%s\n" "$response" | tail -n 1 | tr -d '\r' | xargs) - - local body - body=$(printf "%s\n" "$response" | sed '$d') - - if [ -z "$status_code" ] || [ "$status_code" = "" ]; then - status_code="000" - fi - - # Return JSON structure - jq -n --arg code "$status_code" --arg body "$body" '{"status": ($code|tonumber), "body": $body}' -} - -# Run a test step and update statistics -# Usage: run_api_test [payload_json] [expected_status] -run_api_test() { - local method="$1" - local path="$2" - local payload="${3:-}" - local expected_status="${4:-200}" - - TESTS_TOTAL=$((TESTS_TOTAL + 1)) - - local res - res=$(request "$method" "$path" "$payload") - local status - status=$(printf "%s\n" "$res" | jq -r '.status') - local body - body=$(printf "%s\n" "$res" | jq -r '.body') - - if [ "${status:-0}" -eq "${expected_status}" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "$method $path -> HTTP $status (Expected $expected_status)" - printf "%s\n" "$body" | jq '.' 2>/dev/null || printf "%s\n" "$body" - return 0 - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "$method $path -> Got HTTP $status, Expected $expected_status" - printf "%s\n" "$body" >&2 - return 1 - fi -} - -# ── Preflight Checks ────────────────────────────────────────────────────────── -clear -printf "${BOLD}====================================================${RESET}\n" -printf "${BOLD} AudioMesh REST API Test Tool ${RESET}\n" -printf "${BOLD}====================================================${RESET}\n" -printf "Target Server URL: ${CYAN}%s${RESET}\n" "${BASE_URL}" - -# Verify jq is installed -if ! command -v jq &> /dev/null; then - print_error "'jq' is required but not installed." - printf "Please install jq first (e.g. 'brew install jq').\n" - exit 1 -fi - -# Verify server connectivity / health -res=$(request GET "/health") -status_code=$(printf "%s\n" "$res" | jq -r '.status') -if [ "${status_code:-0}" -eq 0 ] || [ "${status_code:-0}" -ne 200 ]; then - print_error "Cannot connect to AudioMesh backend server at ${BASE_URL}." - printf "Ensure the server is running (e.g. 'cargo run' or similar).\n" - exit 1 -fi - -print_success "Server is reachable and healthy." - -# ── Step 1: Create an SFU Room ──────────────────────────────────────────────── -print_step "Step 1: Create an SFU Room" -sfu_room_payload=$(jq -n \ - --arg name "Living Room SFU" \ - --arg mode "sfu" \ - --arg host "host-device-sfu" \ - '{name: $name, mode: $mode, host_peer_id: $host}') - -sfu_create_res=$(request POST "/mesh/rooms" "$sfu_room_payload") -sfu_status=$(printf "%s\n" "$sfu_create_res" | jq -r '.status') -sfu_body=$(printf "%s\n" "$sfu_create_res" | jq -r '.body') - -TESTS_TOTAL=$((TESTS_TOTAL + 1)) -if [ "${sfu_status:-0}" -eq 201 ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - sfu_room_id=$(printf "%s\n" "$sfu_body" | jq -r '.id') - print_success "POST /mesh/rooms (SFU) -> HTTP 201 (Created room ID: $sfu_room_id)" -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Failed to create SFU room: $sfu_body" - exit 1 -fi - -# ── Step 2: Create a Daisy-Chain Room with Custom Nodes ───────────────────────── -print_step "Step 2: Create a Daisy-Chain Room with Custom Max Nodes" -daisy_room_payload=$(jq -n \ - --arg name "Hallway Chain" \ - --arg mode "daisy_chain" \ - --arg host "host-device-daisy" \ - --argjson max_nodes 10 \ - '{name: $name, mode: $mode, host_peer_id: $host, max_nodes: $max_nodes}') - -daisy_create_res=$(request POST "/mesh/rooms" "$daisy_room_payload") -daisy_status=$(printf "%s\n" "$daisy_create_res" | jq -r '.status') -daisy_body=$(printf "%s\n" "$daisy_create_res" | jq -r '.body') - -TESTS_TOTAL=$((TESTS_TOTAL + 1)) -if [ "${daisy_status:-0}" -eq 201 ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - daisy_room_id=$(printf "%s\n" "$daisy_body" | jq -r '.id') - print_success "POST /mesh/rooms (Daisy-Chain) -> HTTP 201 (Created room ID: $daisy_room_id)" -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Failed to create Daisy-Chain room: $daisy_body" - exit 1 -fi - -# ── Step 3: List Rooms ──────────────────────────────────────────────────────── -print_step "Step 3: List Active Rooms" -run_api_test GET "/mesh/rooms" "" 200 >/dev/null - -# ── Step 4: Get Room Details ────────────────────────────────────────────────── -print_step "Step 4: Get SFU Room Details" -run_api_test GET "/mesh/rooms/${sfu_room_id}" "" 200 >/dev/null - -# ── Step 5: Get Topology Tree ───────────────────────────────────────────────── -print_step "Step 5: Get Daisy-Chain Room Topology (Should be empty initially)" -run_api_test GET "/mesh/rooms/${daisy_room_id}/topology" "" 200 >/dev/null - -# ── Step 6: Negative Test - Get Non-Existent Room ───────────────────────────── -print_step "Step 6: Get Non-Existent Room (Expected 404)" -run_api_test GET "/mesh/rooms/nonexistent-room-id" "" 404 >/dev/null - -# ── Step 7: Negative Test - Report RTT on Non-Existent Room ─────────────────── -print_step "Step 7: Report RTT on Non-Existent Room (Expected 404)" -rtt_payload=$(jq -n \ - --arg from "host-device-daisy" \ - --arg to "peer-device-1" \ - --argjson rtt 15.5 \ - '{from_peer_id: $from, to_peer_id: $to, rtt_ms: $rtt}') - -run_api_test POST "/mesh/rooms/nonexistent-room-id/topology/rtt" "$rtt_payload" 404 >/dev/null - -# ── Step 8: Close and Delete Rooms ──────────────────────────────────────────── -print_step "Step 8: Close and Delete Active Rooms" -run_api_test DELETE "/mesh/rooms/${sfu_room_id}" "" 200 >/dev/null -run_api_test DELETE "/mesh/rooms/${daisy_room_id}" "" 200 >/dev/null - -# ── Step 9: Verify Deletion ─────────────────────────────────────────────────── -print_step "Step 9: Verify Rooms are Deleted (Expected 404)" -run_api_test GET "/mesh/rooms/${sfu_room_id}" "" 404 >/dev/null -run_api_test GET "/mesh/rooms/${daisy_room_id}" "" 404 >/dev/null - -# ── FINAL SUMMARY ───────────────────────────────────────────────────────────── -printf "\n${BOLD}====================================================${RESET}\n" -printf "${BOLD} API Test Suite Summary ${RESET}\n" -printf "${BOLD}====================================================${RESET}\n" -printf "Total Tests Executed: ${CYAN}%s${RESET}\n" "${TESTS_TOTAL}" -printf "Passed: ${GREEN}%s${RESET}\n" "${TESTS_PASSED}" -printf "Failed: ${RED}%s${RESET}\n" "${TESTS_FAILED}" -printf "${BOLD}====================================================${RESET}\n" - -if [ "${TESTS_FAILED:-0}" -eq 0 ]; then - printf "${GREEN}${BOLD} ALL TESTS COMPLETED SUCCESSFULLY! ${RESET}\n" - exit 0 -else - printf "${RED}${BOLD} SOME TESTS ENCOUNTERED FAILURES. ${RESET}\n" - exit 1 -fi diff --git a/package.json b/package.json index 36d2085..2743a05 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "audiomesh-monorepo", + "name": "fastdeck-monorepo", "private": true, "workspaces": [ "apps/web", @@ -11,40 +11,40 @@ "shared/desktop-host" ], "scripts": { - "web:dev": "yarn workspace audiomesh-web start", + "web:dev": "yarn workspace fastdeck-web start", "web:Dev": "yarn web:dev", - "web:build": "yarn workspace audiomesh-web build", - "web:test": "yarn workspace audiomesh-web test", - "common:test": "yarn workspace audiomesh-web test ../shared/common", - "common:build": "yarn workspace audiomesh-common build", - "web:lint": "yarn workspace audiomesh-web lint", - "server:start": "yarn workspace audiomesh-server start", - "server:build": "yarn workspace audiomesh-server build", - "server:test": "yarn workspace audiomesh-server test", - "desktop:start": "yarn workspace audiomesh-desktop start", - "desktop:build": "yarn workspace audiomesh-desktop build", - "desktop:test": "yarn workspace audiomesh-desktop test", - "desktop:lint": "yarn workspace audiomesh-desktop lint", - "desktop:dist:mac": "yarn workspace audiomesh-desktop dist:mac", - "desktop:dist:win": "yarn workspace audiomesh-desktop dist:win", - "desktop:dist:linux": "yarn workspace audiomesh-desktop dist:linux", - "desktop:dist:all": "yarn workspace audiomesh-desktop dist:all", - "mobile:dev": "yarn workspace audiomesh-mobile run tauri dev", - "mobile:build": "yarn workspace audiomesh-mobile run build", - "mobile:android": "yarn workspace audiomesh-mobile run tauri android dev", - "mobile:android:build:debug": "yarn workspace audiomesh-mobile run android:build:debug", - "mobile:android:build:release": "yarn workspace audiomesh-mobile run android:build:release", - "mobile:ios": "yarn workspace audiomesh-mobile run tauri ios dev", - "mobile:ios:build": "yarn workspace audiomesh-mobile run tauri ios build", - "mobile:dist:android": "yarn workspace audiomesh-mobile run tauri android build --apk", - "mobile:dist:ios": "yarn workspace audiomesh-mobile run tauri ios build", + "web:build": "yarn workspace fastdeck-web build", + "web:test": "yarn workspace fastdeck-web test", + "common:test": "yarn workspace fastdeck-web test ../shared/common", + "common:build": "yarn workspace fastdeck-common build", + "web:lint": "yarn workspace fastdeck-web lint", + "server:start": "yarn workspace fastdeck-server start", + "server:build": "yarn workspace fastdeck-server build", + "server:test": "yarn workspace fastdeck-server test", + "desktop:start": "yarn workspace fastdeck-desktop start", + "desktop:build": "yarn workspace fastdeck-desktop build", + "desktop:test": "yarn workspace fastdeck-desktop test", + "desktop:lint": "yarn workspace fastdeck-desktop lint", + "desktop:dist:mac": "yarn workspace fastdeck-desktop dist:mac", + "desktop:dist:win": "yarn workspace fastdeck-desktop dist:win", + "desktop:dist:linux": "yarn workspace fastdeck-desktop dist:linux", + "desktop:dist:all": "yarn workspace fastdeck-desktop dist:all", + "mobile:dev": "yarn workspace fastdeck-mobile run tauri dev", + "mobile:build": "yarn workspace fastdeck-mobile run build", + "mobile:android": "yarn workspace fastdeck-mobile run tauri android dev", + "mobile:android:build:debug": "yarn workspace fastdeck-mobile run android:build:debug", + "mobile:android:build:release": "yarn workspace fastdeck-mobile run android:build:release", + "mobile:ios": "yarn workspace fastdeck-mobile run tauri ios dev", + "mobile:ios:build": "yarn workspace fastdeck-mobile run tauri ios build", + "mobile:dist:android": "yarn workspace fastdeck-mobile run tauri android build --apk", + "mobile:dist:ios": "yarn workspace fastdeck-mobile run tauri ios build", "mobile:dist:all": "yarn mobile:dist:android && yarn mobile:dist:ios", - "mobile:test": "yarn workspace audiomesh-mobile test", + "mobile:test": "yarn workspace fastdeck-mobile test", "build:all": "yarn web:build && yarn desktop:build", "dist:all": "yarn web:build && yarn desktop:dist:all && yarn mobile:dist:all", "test:all": "node scripts/test-all.js", - "prettier:staged": "yarn workspace audiomesh-web run prettier:staged", - "lint:staged": "yarn workspace audiomesh-web run lint:staged", + "prettier:staged": "yarn workspace fastdeck-web run prettier:staged", + "lint:staged": "yarn workspace fastdeck-web run lint:staged", "run-staged-tests": "./scripts/run-staged-tests.sh", "run-pushed-files-tests": "./scripts/run-staged-tests.sh --pushed", "prepare": "husky" diff --git a/profiles_cache.json b/profiles_cache.json new file mode 100644 index 0000000..100a9fe --- /dev/null +++ b/profiles_cache.json @@ -0,0 +1,136 @@ +{ + "active_profile_id": "dev", + "profiles": { + "default": { + "id": "default", + "name": "Default Profile", + "grid": { + "rows": 3, + "cols": 5, + "cells": [ + { + "row": 0, + "col": 0, + "label": "Terminal", + "icon": "💻", + "background": "#2e3440", + "action": { + "action": { + "SingleAction": { + "id": "launch_terminal", + "name": "Launch Terminal", + "type": 4, + "action_details": { + "LaunchApp": { + "path_or_name": "Terminal", + "args": [] + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 0, + "col": 1, + "label": "GitHub", + "icon": "🌐", + "background": "#181717", + "action": { + "action": { + "SingleAction": { + "id": "open_github", + "name": "Open GitHub", + "type": 5, + "action_details": { + "OpenUrl": { + "url": "https://github.com" + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 0, + "col": 2, + "label": "Play/Pause", + "icon": "⏯️", + "background": "#bf616a", + "action": { + "action": { + "SingleAction": { + "id": "media_play_pause", + "name": "Play or Pause", + "type": 6, + "action_details": { + "MediaControl": { + "command": 3 + } + } + } + } + }, + "is_enabled": true + }, + { + "row": 1, + "col": 0, + "label": "Say Hello", + "icon": "👋", + "background": "#a3be8c", + "action": { + "action": { + "SingleAction": { + "id": "run_hello", + "name": "Run Echo Hello", + "type": 3, + "action_details": { + "RunCommand": { + "command": "echo 'Hello from FastDeck!'" + } + } + } + } + }, + "is_enabled": true + } + ] + } + }, + "dev": { + "id": "dev", + "name": "Developer Profile", + "grid": { + "rows": 3, + "cols": 5, + "cells": [ + { + "row": 0, + "col": 0, + "label": "Create Dir", + "icon": "📁", + "background": "#4c566a", + "action": { + "action": { + "SingleAction": { + "id": "create_dir", + "name": "Create Demo Dir", + "type": 1, + "action_details": { + "CreateFolder": { + "path": "~/fastdeck-demo" + } + } + } + } + }, + "is_enabled": true + } + ] + } + } + } +} \ No newline at end of file diff --git a/protos/Makefile b/protos/Makefile new file mode 100644 index 0000000..8b9d0fc --- /dev/null +++ b/protos/Makefile @@ -0,0 +1,15 @@ +.PHONY: all generate lint format clean + +all: lint generate + +generate: + buf generate proto + +lint: + buf lint proto + +format: + buf format -w proto + +clean: + rm -rf gen diff --git a/protos/README.md b/protos/README.md new file mode 100644 index 0000000..5b3d349 --- /dev/null +++ b/protos/README.md @@ -0,0 +1,113 @@ +# FastDeck Protocol Buffers & gRPC Services + +This folder contains the Protocol Buffer schemas and gRPC service definitions that serve as the single source of truth for communication between FastDeck clients (mobile/tablet panels) and the FastDeck desktop server. + +## Overview + +FastDeck uses **gRPC** via Protocol Buffers for fast, type-safe, bidirectional streaming communication over both local TCP networks (WiFi/hotspot) and Bluetooth (RFCOMM). + +``` + Mobile Client Desktop Server + ┌────────────────┐ ┌────────────────┐ + │ Deck Panel UI │ │ gRPC Service │ + │ │ ─── GetDeckInfoRequest ──► │ │ + │ │ ◄── GetDeckInfoResponse ── │ │ + │ │ │ │ + │ │ ◄── StreamDeckUpdates ──── │ │ + │ │ (Server-sent Stream) │ │ + │ │ │ │ + │ │ ─── TriggerActionRequest ─► │ (Executes OS │ + │ │ ◄── TriggerActionResponse ─ │ action flow) │ + └────────────────┘ └────────────────┘ +``` + +--- + +## Service Definition: `DeckService` + +The core API service is defined in [`proto/services/deck.proto`](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/protos/proto/services/deck.proto) under the package `fastdeck.services.v1`. + +### Endpoints + +| RPC Method | Request Type | Response Type | Pattern | Description | +| :--- | :--- | :--- | :--- | :--- | +| `GetDeckInfo` | `GetDeckInfoRequest` | `GetDeckInfoResponse` | Unary | Fetches server metadata (name, version, profiles) and the current active profile (grid dimensions, cell layouts, and bound actions). | +| `StreamDeckUpdates` | `StreamDeckUpdatesRequest` | `StreamDeckUpdatesResponse` | Server Streaming | Establishes a stream where the server pushes real-time grid changes, active profile switches, or single-cell modifications to the client. | +| `TriggerAction` | `TriggerActionRequest` | `TriggerActionResponse` | Unary | Requests the server to execute an action. Actions can be triggered either by a unique `action_id` or by specific `row` and `col` cell coordinates. | +| `SwitchProfile` | `SwitchProfileRequest` | `SwitchProfileResponse` | Unary | Switches the active panel configuration profile to a different layout on the server. | + +--- + +## Action System Shemas + +The action configuration payload schemas are defined in [`proto/services/action.proto`](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/protos/proto/services/action.proto). + +### 1. Action Types (`ActionType` Enum) +- `ACTION_TYPE_CREATE_FOLDER`: Create a directory at a path. +- `ACTION_TYPE_RUN_SCRIPT`: Execute a shell or script file. +- `ACTION_TYPE_RUN_COMMAND`: Run a terminal command. +- `ACTION_TYPE_LAUNCH_APP`: Start a desktop application. +- `ACTION_TYPE_OPEN_URL`: Open a browser URL. +- `ACTION_TYPE_MEDIA_CONTROL`: Control desktop media (Play, Pause, Volume, etc.). +- `ACTION_TYPE_KEY_BINDING`: Trigger a native keyboard shortcut. + +### 2. Multi-Actions +A `MultiAction` allows executing a sequence of actions sequentially. +- **`MultiActionStep`**: Combines an `Action` with an integer `delay_ms` specifying the duration to wait before firing the next step. +- **`MultiAction`**: Contains a list of `MultiActionStep` objects. + +--- + +## Grid Layout & Cell Schema + +Defined in [`proto/services/deck.proto`](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/protos/proto/services/deck.proto): + +- **`Cell`**: Represents an individual button on the panel grid. + - `row` / `col` (int32): Position coordinates in the grid layout. + - `label` (string): Text displayed on the button. + - `icon` (string): Button icon representation (could be an Emoji character, local/remote image path, or platform-native SF Symbol string). + - `background` (string): Button styling colors (HEX value or CSS-compliant gradient string). + - `action` (`CellAction`): A wrapper containing either a single `Action` or a `MultiAction` block. + - `is_enabled` (bool): Button active/disabled state. + +- **`Grid`**: Represents the layout matrix. + - `rows` / `cols` (int32): Total grid dimensions. + - `cells` (repeated `Cell`): Array of configured buttons inside the grid. + +- **`Profile`**: Defines a full panel layout mapping. + - `id` (string): Unique profile ID. + - `name` (string): Profile name. + - `grid` (`Grid`): Layout grid containing cells. + +--- + +## Building and Compiling Protos + +FastDeck manages schema generation, formatting, and lint rules using the **Buf Build** utility. + +### Prerequisite +Ensure `buf` is installed on your local machine: +```bash +brew install bufbuild/buf/buf +``` + +### Makefile CLI Help Commands +A [`Makefile`](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/protos/Makefile) is provided inside the `protos/` folder for convenience: + +- **Lint checking**: + ```bash + make lint + ``` + Runs `buf lint` validation over all proto files to verify schema correctness and consistency. + +- **Formatting checking**: + ```bash + make format + ``` + Runs `buf format -w` to format all proto files in-place according to the standard Protocol Buffer style guide. + +- **Code generation**: + ```bash + make generate + ``` + Generates TypeScript static classes and Connect-ES RPC clients under the `gen/` directory based on the `buf.gen.yaml` config. diff --git a/protos/buf.gen.yaml b/protos/buf.gen.yaml new file mode 100644 index 0000000..f9b1007 --- /dev/null +++ b/protos/buf.gen.yaml @@ -0,0 +1,8 @@ +version: v1 +plugins: + - name: es + out: gen/es + opt: target=ts + - name: connect-es + out: gen/es + opt: target=ts diff --git a/protos/gen/es/services/action_pb.ts b/protos/gen/es/services/action_pb.ts new file mode 100644 index 0000000..b44092a --- /dev/null +++ b/protos/gen/es/services/action_pb.ts @@ -0,0 +1,600 @@ +// @generated by protoc-gen-es v1.10.1 with parameter "target=ts" +// @generated from file services/action.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; + +/** + * @generated from enum fastdeck.services.v1.ActionType + */ +export enum ActionType { + /** + * @generated from enum value: ACTION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: ACTION_TYPE_CREATE_FOLDER = 1; + */ + CREATE_FOLDER = 1, + + /** + * @generated from enum value: ACTION_TYPE_RUN_SCRIPT = 2; + */ + RUN_SCRIPT = 2, + + /** + * @generated from enum value: ACTION_TYPE_RUN_COMMAND = 3; + */ + RUN_COMMAND = 3, + + /** + * @generated from enum value: ACTION_TYPE_LAUNCH_APP = 4; + */ + LAUNCH_APP = 4, + + /** + * @generated from enum value: ACTION_TYPE_OPEN_URL = 5; + */ + OPEN_URL = 5, + + /** + * @generated from enum value: ACTION_TYPE_MEDIA_CONTROL = 6; + */ + MEDIA_CONTROL = 6, + + /** + * @generated from enum value: ACTION_TYPE_KEY_BINDING = 7; + */ + KEY_BINDING = 7, +} +// Retrieve enum metadata with: proto3.getEnumType(ActionType) +proto3.util.setEnumType(ActionType, "fastdeck.services.v1.ActionType", [ + { no: 0, name: "ACTION_TYPE_UNSPECIFIED" }, + { no: 1, name: "ACTION_TYPE_CREATE_FOLDER" }, + { no: 2, name: "ACTION_TYPE_RUN_SCRIPT" }, + { no: 3, name: "ACTION_TYPE_RUN_COMMAND" }, + { no: 4, name: "ACTION_TYPE_LAUNCH_APP" }, + { no: 5, name: "ACTION_TYPE_OPEN_URL" }, + { no: 6, name: "ACTION_TYPE_MEDIA_CONTROL" }, + { no: 7, name: "ACTION_TYPE_KEY_BINDING" }, +]); + +/** + * @generated from enum fastdeck.services.v1.MediaCommand + */ +export enum MediaCommand { + /** + * @generated from enum value: MEDIA_COMMAND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: MEDIA_COMMAND_PLAY = 1; + */ + PLAY = 1, + + /** + * @generated from enum value: MEDIA_COMMAND_PAUSE = 2; + */ + PAUSE = 2, + + /** + * @generated from enum value: MEDIA_COMMAND_PLAY_PAUSE = 3; + */ + PLAY_PAUSE = 3, + + /** + * @generated from enum value: MEDIA_COMMAND_STOP = 4; + */ + STOP = 4, + + /** + * @generated from enum value: MEDIA_COMMAND_NEXT = 5; + */ + NEXT = 5, + + /** + * @generated from enum value: MEDIA_COMMAND_PREVIOUS = 6; + */ + PREVIOUS = 6, + + /** + * @generated from enum value: MEDIA_COMMAND_MUTE = 7; + */ + MUTE = 7, + + /** + * @generated from enum value: MEDIA_COMMAND_VOLUME_UP = 8; + */ + VOLUME_UP = 8, + + /** + * @generated from enum value: MEDIA_COMMAND_VOLUME_DOWN = 9; + */ + VOLUME_DOWN = 9, +} +// Retrieve enum metadata with: proto3.getEnumType(MediaCommand) +proto3.util.setEnumType(MediaCommand, "fastdeck.services.v1.MediaCommand", [ + { no: 0, name: "MEDIA_COMMAND_UNSPECIFIED" }, + { no: 1, name: "MEDIA_COMMAND_PLAY" }, + { no: 2, name: "MEDIA_COMMAND_PAUSE" }, + { no: 3, name: "MEDIA_COMMAND_PLAY_PAUSE" }, + { no: 4, name: "MEDIA_COMMAND_STOP" }, + { no: 5, name: "MEDIA_COMMAND_NEXT" }, + { no: 6, name: "MEDIA_COMMAND_PREVIOUS" }, + { no: 7, name: "MEDIA_COMMAND_MUTE" }, + { no: 8, name: "MEDIA_COMMAND_VOLUME_UP" }, + { no: 9, name: "MEDIA_COMMAND_VOLUME_DOWN" }, +]); + +/** + * @generated from message fastdeck.services.v1.CreateFolderAction + */ +export class CreateFolderAction extends Message { + /** + * @generated from field: string path = 1; + */ + path = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CreateFolderAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromJsonString(jsonString, options); + } + + static equals(a: CreateFolderAction | PlainMessage | undefined, b: CreateFolderAction | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateFolderAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.RunScriptAction + */ +export class RunScriptAction extends Message { + /** + * @generated from field: string path = 1; + */ + path = ""; + + /** + * @generated from field: repeated string args = 2; + */ + args: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.RunScriptAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "args", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunScriptAction { + return new RunScriptAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunScriptAction { + return new RunScriptAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunScriptAction { + return new RunScriptAction().fromJsonString(jsonString, options); + } + + static equals(a: RunScriptAction | PlainMessage | undefined, b: RunScriptAction | PlainMessage | undefined): boolean { + return proto3.util.equals(RunScriptAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.RunCommandAction + */ +export class RunCommandAction extends Message { + /** + * @generated from field: string command = 1; + */ + command = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.RunCommandAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "command", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunCommandAction { + return new RunCommandAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunCommandAction { + return new RunCommandAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunCommandAction { + return new RunCommandAction().fromJsonString(jsonString, options); + } + + static equals(a: RunCommandAction | PlainMessage | undefined, b: RunCommandAction | PlainMessage | undefined): boolean { + return proto3.util.equals(RunCommandAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.LaunchAppAction + */ +export class LaunchAppAction extends Message { + /** + * @generated from field: string path_or_name = 1; + */ + pathOrName = ""; + + /** + * @generated from field: repeated string args = 2; + */ + args: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.LaunchAppAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path_or_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "args", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromJsonString(jsonString, options); + } + + static equals(a: LaunchAppAction | PlainMessage | undefined, b: LaunchAppAction | PlainMessage | undefined): boolean { + return proto3.util.equals(LaunchAppAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.OpenUrlAction + */ +export class OpenUrlAction extends Message { + /** + * @generated from field: string url = 1; + */ + url = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.OpenUrlAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "url", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromJsonString(jsonString, options); + } + + static equals(a: OpenUrlAction | PlainMessage | undefined, b: OpenUrlAction | PlainMessage | undefined): boolean { + return proto3.util.equals(OpenUrlAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MediaControlAction + */ +export class MediaControlAction extends Message { + /** + * @generated from field: fastdeck.services.v1.MediaCommand command = 1; + */ + command = MediaCommand.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MediaControlAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "command", kind: "enum", T: proto3.getEnumType(MediaCommand) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MediaControlAction { + return new MediaControlAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MediaControlAction { + return new MediaControlAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MediaControlAction { + return new MediaControlAction().fromJsonString(jsonString, options); + } + + static equals(a: MediaControlAction | PlainMessage | undefined, b: MediaControlAction | PlainMessage | undefined): boolean { + return proto3.util.equals(MediaControlAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.KeyBindingAction + */ +export class KeyBindingAction extends Message { + /** + * e.g., ["Control", "c"] + * + * @generated from field: repeated string keys = 1; + */ + keys: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.KeyBindingAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "keys", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromJsonString(jsonString, options); + } + + static equals(a: KeyBindingAction | PlainMessage | undefined, b: KeyBindingAction | PlainMessage | undefined): boolean { + return proto3.util.equals(KeyBindingAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Action + */ +export class Action extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: fastdeck.services.v1.ActionType type = 3; + */ + type = ActionType.UNSPECIFIED; + + /** + * @generated from oneof fastdeck.services.v1.Action.action_details + */ + actionDetails: { + /** + * @generated from field: fastdeck.services.v1.CreateFolderAction create_folder = 4; + */ + value: CreateFolderAction; + case: "createFolder"; + } | { + /** + * @generated from field: fastdeck.services.v1.RunScriptAction run_script = 5; + */ + value: RunScriptAction; + case: "runScript"; + } | { + /** + * @generated from field: fastdeck.services.v1.RunCommandAction run_command = 6; + */ + value: RunCommandAction; + case: "runCommand"; + } | { + /** + * @generated from field: fastdeck.services.v1.LaunchAppAction launch_app = 7; + */ + value: LaunchAppAction; + case: "launchApp"; + } | { + /** + * @generated from field: fastdeck.services.v1.OpenUrlAction open_url = 8; + */ + value: OpenUrlAction; + case: "openUrl"; + } | { + /** + * @generated from field: fastdeck.services.v1.MediaControlAction media_control = 9; + */ + value: MediaControlAction; + case: "mediaControl"; + } | { + /** + * @generated from field: fastdeck.services.v1.KeyBindingAction key_binding = 10; + */ + value: KeyBindingAction; + case: "keyBinding"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Action"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "enum", T: proto3.getEnumType(ActionType) }, + { no: 4, name: "create_folder", kind: "message", T: CreateFolderAction, oneof: "action_details" }, + { no: 5, name: "run_script", kind: "message", T: RunScriptAction, oneof: "action_details" }, + { no: 6, name: "run_command", kind: "message", T: RunCommandAction, oneof: "action_details" }, + { no: 7, name: "launch_app", kind: "message", T: LaunchAppAction, oneof: "action_details" }, + { no: 8, name: "open_url", kind: "message", T: OpenUrlAction, oneof: "action_details" }, + { no: 9, name: "media_control", kind: "message", T: MediaControlAction, oneof: "action_details" }, + { no: 10, name: "key_binding", kind: "message", T: KeyBindingAction, oneof: "action_details" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Action { + return new Action().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Action { + return new Action().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Action { + return new Action().fromJsonString(jsonString, options); + } + + static equals(a: Action | PlainMessage | undefined, b: Action | PlainMessage | undefined): boolean { + return proto3.util.equals(Action, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MultiActionStep + */ +export class MultiActionStep extends Message { + /** + * @generated from field: fastdeck.services.v1.Action action = 1; + */ + action?: Action; + + /** + * @generated from field: int32 delay_ms = 2; + */ + delayMs = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MultiActionStep"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "action", kind: "message", T: Action }, + { no: 2, name: "delay_ms", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MultiActionStep { + return new MultiActionStep().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MultiActionStep { + return new MultiActionStep().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MultiActionStep { + return new MultiActionStep().fromJsonString(jsonString, options); + } + + static equals(a: MultiActionStep | PlainMessage | undefined, b: MultiActionStep | PlainMessage | undefined): boolean { + return proto3.util.equals(MultiActionStep, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MultiAction + */ +export class MultiAction extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: repeated fastdeck.services.v1.MultiActionStep steps = 3; + */ + steps: MultiActionStep[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MultiAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "steps", kind: "message", T: MultiActionStep, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MultiAction { + return new MultiAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MultiAction { + return new MultiAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MultiAction { + return new MultiAction().fromJsonString(jsonString, options); + } + + static equals(a: MultiAction | PlainMessage | undefined, b: MultiAction | PlainMessage | undefined): boolean { + return proto3.util.equals(MultiAction, a, b); + } +} + diff --git a/protos/gen/es/services/deck_connect.ts b/protos/gen/es/services/deck_connect.ts new file mode 100644 index 0000000..9150c4e --- /dev/null +++ b/protos/gen/es/services/deck_connect.ts @@ -0,0 +1,62 @@ +// @generated by protoc-gen-connect-es v1.7.0 with parameter "target=ts" +// @generated from file services/deck.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { GetDeckInfoRequest, GetDeckInfoResponse, StreamDeckUpdatesRequest, StreamDeckUpdatesResponse, SwitchProfileRequest, SwitchProfileResponse, TriggerActionRequest, TriggerActionResponse, UpdateCellRequest, UpdateCellResponse } from "./deck_pb.js"; +import { MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service fastdeck.services.v1.DeckService + */ +export const DeckService = { + typeName: "fastdeck.services.v1.DeckService", + methods: { + /** + * @generated from rpc fastdeck.services.v1.DeckService.GetDeckInfo + */ + getDeckInfo: { + name: "GetDeckInfo", + I: GetDeckInfoRequest, + O: GetDeckInfoResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.StreamDeckUpdates + */ + streamDeckUpdates: { + name: "StreamDeckUpdates", + I: StreamDeckUpdatesRequest, + O: StreamDeckUpdatesResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.TriggerAction + */ + triggerAction: { + name: "TriggerAction", + I: TriggerActionRequest, + O: TriggerActionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.SwitchProfile + */ + switchProfile: { + name: "SwitchProfile", + I: SwitchProfileRequest, + O: SwitchProfileResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.UpdateCell + */ + updateCell: { + name: "UpdateCell", + I: UpdateCellRequest, + O: UpdateCellResponse, + kind: MethodKind.Unary, + }, + } +} as const; + diff --git a/protos/gen/es/services/deck_pb.ts b/protos/gen/es/services/deck_pb.ts new file mode 100644 index 0000000..58d8097 --- /dev/null +++ b/protos/gen/es/services/deck_pb.ts @@ -0,0 +1,777 @@ +// @generated by protoc-gen-es v1.10.1 with parameter "target=ts" +// @generated from file services/deck.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; +import { Action, MultiAction } from "./action_pb.js"; + +/** + * @generated from message fastdeck.services.v1.CellAction + */ +export class CellAction extends Message { + /** + * @generated from oneof fastdeck.services.v1.CellAction.action + */ + action: { + /** + * @generated from field: fastdeck.services.v1.Action single_action = 1; + */ + value: Action; + case: "singleAction"; + } | { + /** + * @generated from field: fastdeck.services.v1.MultiAction multi_action = 2; + */ + value: MultiAction; + case: "multiAction"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CellAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "single_action", kind: "message", T: Action, oneof: "action" }, + { no: 2, name: "multi_action", kind: "message", T: MultiAction, oneof: "action" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CellAction { + return new CellAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CellAction { + return new CellAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CellAction { + return new CellAction().fromJsonString(jsonString, options); + } + + static equals(a: CellAction | PlainMessage | undefined, b: CellAction | PlainMessage | undefined): boolean { + return proto3.util.equals(CellAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Cell + */ +export class Cell extends Message { + /** + * @generated from field: int32 row = 1; + */ + row = 0; + + /** + * @generated from field: int32 col = 2; + */ + col = 0; + + /** + * @generated from field: string label = 3; + */ + label = ""; + + /** + * Emoji, image file path/bytes, or SF Symbol name + * + * @generated from field: string icon = 4; + */ + icon = ""; + + /** + * Hex color or gradient string + * + * @generated from field: string background = 5; + */ + background = ""; + + /** + * @generated from field: fastdeck.services.v1.CellAction action = 6; + */ + action?: CellAction; + + /** + * @generated from field: bool is_enabled = 7; + */ + isEnabled = false; + + /** + * Page index within the profile grid (0-based) + * + * @generated from field: int32 page = 8; + */ + page = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Cell"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "row", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "col", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: "label", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "icon", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "background", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "action", kind: "message", T: CellAction }, + { no: 7, name: "is_enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 8, name: "page", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Cell { + return new Cell().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Cell { + return new Cell().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Cell { + return new Cell().fromJsonString(jsonString, options); + } + + static equals(a: Cell | PlainMessage | undefined, b: Cell | PlainMessage | undefined): boolean { + return proto3.util.equals(Cell, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Grid + */ +export class Grid extends Message { + /** + * @generated from field: int32 rows = 1; + */ + rows = 0; + + /** + * @generated from field: int32 cols = 2; + */ + cols = 0; + + /** + * @generated from field: repeated fastdeck.services.v1.Cell cells = 3; + */ + cells: Cell[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Grid"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "rows", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "cols", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: "cells", kind: "message", T: Cell, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Grid { + return new Grid().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Grid { + return new Grid().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Grid { + return new Grid().fromJsonString(jsonString, options); + } + + static equals(a: Grid | PlainMessage | undefined, b: Grid | PlainMessage | undefined): boolean { + return proto3.util.equals(Grid, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Profile + */ +export class Profile extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: fastdeck.services.v1.Grid grid = 3; + */ + grid?: Grid; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Profile"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "grid", kind: "message", T: Grid }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Profile { + return new Profile().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Profile { + return new Profile().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Profile { + return new Profile().fromJsonString(jsonString, options); + } + + static equals(a: Profile | PlainMessage | undefined, b: Profile | PlainMessage | undefined): boolean { + return proto3.util.equals(Profile, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.ServerInfo + */ +export class ServerInfo extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string version = 2; + */ + version = ""; + + /** + * @generated from field: string active_profile_id = 3; + */ + activeProfileId = ""; + + /** + * @generated from field: repeated string available_profiles = 4; + */ + availableProfiles: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.ServerInfo"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "version", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "active_profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "available_profiles", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ServerInfo { + return new ServerInfo().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ServerInfo { + return new ServerInfo().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ServerInfo { + return new ServerInfo().fromJsonString(jsonString, options); + } + + static equals(a: ServerInfo | PlainMessage | undefined, b: ServerInfo | PlainMessage | undefined): boolean { + return proto3.util.equals(ServerInfo, a, b); + } +} + +/** + * GetDeckInfo + * + * @generated from message fastdeck.services.v1.GetDeckInfoRequest + */ +export class GetDeckInfoRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.GetDeckInfoRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetDeckInfoRequest | PlainMessage | undefined, b: GetDeckInfoRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetDeckInfoRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.GetDeckInfoResponse + */ +export class GetDeckInfoResponse extends Message { + /** + * @generated from field: fastdeck.services.v1.ServerInfo server_info = 1; + */ + serverInfo?: ServerInfo; + + /** + * @generated from field: fastdeck.services.v1.Profile active_profile = 2; + */ + activeProfile?: Profile; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.GetDeckInfoResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "server_info", kind: "message", T: ServerInfo }, + { no: 2, name: "active_profile", kind: "message", T: Profile }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetDeckInfoResponse | PlainMessage | undefined, b: GetDeckInfoResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetDeckInfoResponse, a, b); + } +} + +/** + * StreamDeckUpdates + * + * @generated from message fastdeck.services.v1.StreamDeckUpdatesRequest + */ +export class StreamDeckUpdatesRequest extends Message { + /** + * @generated from field: string client_id = 1; + */ + clientId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.StreamDeckUpdatesRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "client_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromJsonString(jsonString, options); + } + + static equals(a: StreamDeckUpdatesRequest | PlainMessage | undefined, b: StreamDeckUpdatesRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(StreamDeckUpdatesRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.StreamDeckUpdatesResponse + */ +export class StreamDeckUpdatesResponse extends Message { + /** + * @generated from oneof fastdeck.services.v1.StreamDeckUpdatesResponse.update + */ + update: { + /** + * @generated from field: fastdeck.services.v1.ServerInfo server_info_update = 1; + */ + value: ServerInfo; + case: "serverInfoUpdate"; + } | { + /** + * @generated from field: fastdeck.services.v1.Profile active_profile_update = 2; + */ + value: Profile; + case: "activeProfileUpdate"; + } | { + /** + * @generated from field: fastdeck.services.v1.Cell cell_update = 3; + */ + value: Cell; + case: "cellUpdate"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.StreamDeckUpdatesResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "server_info_update", kind: "message", T: ServerInfo, oneof: "update" }, + { no: 2, name: "active_profile_update", kind: "message", T: Profile, oneof: "update" }, + { no: 3, name: "cell_update", kind: "message", T: Cell, oneof: "update" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromJsonString(jsonString, options); + } + + static equals(a: StreamDeckUpdatesResponse | PlainMessage | undefined, b: StreamDeckUpdatesResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(StreamDeckUpdatesResponse, a, b); + } +} + +/** + * TriggerAction + * + * @generated from message fastdeck.services.v1.TriggerActionRequest + */ +export class TriggerActionRequest extends Message { + /** + * @generated from oneof fastdeck.services.v1.TriggerActionRequest.trigger + */ + trigger: { + /** + * @generated from field: string action_id = 1; + */ + value: string; + case: "actionId"; + } | { + /** + * @generated from field: fastdeck.services.v1.CellCoordinate coordinate = 2; + */ + value: CellCoordinate; + case: "coordinate"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.TriggerActionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "action_id", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "trigger" }, + { no: 2, name: "coordinate", kind: "message", T: CellCoordinate, oneof: "trigger" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromJsonString(jsonString, options); + } + + static equals(a: TriggerActionRequest | PlainMessage | undefined, b: TriggerActionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(TriggerActionRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.CellCoordinate + */ +export class CellCoordinate extends Message { + /** + * @generated from field: int32 row = 1; + */ + row = 0; + + /** + * @generated from field: int32 col = 2; + */ + col = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CellCoordinate"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "row", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "col", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CellCoordinate { + return new CellCoordinate().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CellCoordinate { + return new CellCoordinate().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CellCoordinate { + return new CellCoordinate().fromJsonString(jsonString, options); + } + + static equals(a: CellCoordinate | PlainMessage | undefined, b: CellCoordinate | PlainMessage | undefined): boolean { + return proto3.util.equals(CellCoordinate, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.TriggerActionResponse + */ +export class TriggerActionResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.TriggerActionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromJsonString(jsonString, options); + } + + static equals(a: TriggerActionResponse | PlainMessage | undefined, b: TriggerActionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(TriggerActionResponse, a, b); + } +} + +/** + * SwitchProfile + * + * @generated from message fastdeck.services.v1.SwitchProfileRequest + */ +export class SwitchProfileRequest extends Message { + /** + * @generated from field: string profile_id = 1; + */ + profileId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.SwitchProfileRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromJsonString(jsonString, options); + } + + static equals(a: SwitchProfileRequest | PlainMessage | undefined, b: SwitchProfileRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SwitchProfileRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.SwitchProfileResponse + */ +export class SwitchProfileResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.SwitchProfileResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromJsonString(jsonString, options); + } + + static equals(a: SwitchProfileResponse | PlainMessage | undefined, b: SwitchProfileResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SwitchProfileResponse, a, b); + } +} + +/** + * UpdateCell + * + * @generated from message fastdeck.services.v1.UpdateCellRequest + */ +export class UpdateCellRequest extends Message { + /** + * @generated from field: string profile_id = 1; + */ + profileId = ""; + + /** + * @generated from field: fastdeck.services.v1.Cell cell = 2; + */ + cell?: Cell; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.UpdateCellRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "cell", kind: "message", T: Cell }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateCellRequest | PlainMessage | undefined, b: UpdateCellRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateCellRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.UpdateCellResponse + */ +export class UpdateCellResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.UpdateCellResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromJsonString(jsonString, options); + } + + static equals(a: UpdateCellResponse | PlainMessage | undefined, b: UpdateCellResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateCellResponse, a, b); + } +} + diff --git a/protos/proto/buf.yaml b/protos/proto/buf.yaml new file mode 100644 index 0000000..a4d5f41 --- /dev/null +++ b/protos/proto/buf.yaml @@ -0,0 +1,8 @@ +version: v1 +name: buf.build/fastdeck/protos +deps: [] +lint: + use: + - STANDARD + except: + - PACKAGE_DIRECTORY_MATCH diff --git a/protos/proto/services/action.proto b/protos/proto/services/action.proto new file mode 100644 index 0000000..8ae4bb2 --- /dev/null +++ b/protos/proto/services/action.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package fastdeck.services.v1; + +enum ActionType { + ACTION_TYPE_UNSPECIFIED = 0; + ACTION_TYPE_CREATE_FOLDER = 1; + ACTION_TYPE_RUN_SCRIPT = 2; + ACTION_TYPE_RUN_COMMAND = 3; + ACTION_TYPE_LAUNCH_APP = 4; + ACTION_TYPE_OPEN_URL = 5; + ACTION_TYPE_MEDIA_CONTROL = 6; + ACTION_TYPE_KEY_BINDING = 7; +} + +enum MediaCommand { + MEDIA_COMMAND_UNSPECIFIED = 0; + MEDIA_COMMAND_PLAY = 1; + MEDIA_COMMAND_PAUSE = 2; + MEDIA_COMMAND_PLAY_PAUSE = 3; + MEDIA_COMMAND_STOP = 4; + MEDIA_COMMAND_NEXT = 5; + MEDIA_COMMAND_PREVIOUS = 6; + MEDIA_COMMAND_MUTE = 7; + MEDIA_COMMAND_VOLUME_UP = 8; + MEDIA_COMMAND_VOLUME_DOWN = 9; +} + +message CreateFolderAction { + string path = 1; +} + +message RunScriptAction { + string path = 1; + repeated string args = 2; +} + +message RunCommandAction { + string command = 1; +} + +message LaunchAppAction { + string path_or_name = 1; + repeated string args = 2; +} + +message OpenUrlAction { + string url = 1; +} + +message MediaControlAction { + MediaCommand command = 1; +} + +message KeyBindingAction { + repeated string keys = 1; // e.g., ["Control", "c"] +} + +message Action { + string id = 1; + string name = 2; + ActionType type = 3; + oneof action_details { + CreateFolderAction create_folder = 4; + RunScriptAction run_script = 5; + RunCommandAction run_command = 6; + LaunchAppAction launch_app = 7; + OpenUrlAction open_url = 8; + MediaControlAction media_control = 9; + KeyBindingAction key_binding = 10; + } +} + +message MultiActionStep { + Action action = 1; + int32 delay_ms = 2; +} + +message MultiAction { + string id = 1; + string name = 2; + repeated MultiActionStep steps = 3; +} diff --git a/protos/proto/services/deck.proto b/protos/proto/services/deck.proto new file mode 100644 index 0000000..b11a924 --- /dev/null +++ b/protos/proto/services/deck.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; + +package fastdeck.services.v1; + +import "services/action.proto"; + +message CellAction { + oneof action { + Action single_action = 1; + MultiAction multi_action = 2; + } +} + +message Cell { + int32 row = 1; + int32 col = 2; + string label = 3; + string icon = 4; // Emoji, image file path/bytes, or SF Symbol name + string background = 5; // Hex color or gradient string + CellAction action = 6; + bool is_enabled = 7; + int32 page = 8; // Page index within the profile grid (0-based) +} + +message Grid { + int32 rows = 1; + int32 cols = 2; + repeated Cell cells = 3; +} + +message Profile { + string id = 1; + string name = 2; + Grid grid = 3; +} + +message ServerInfo { + string name = 1; + string version = 2; + string active_profile_id = 3; + repeated string available_profiles = 4; +} + +// GetDeckInfo +message GetDeckInfoRequest {} + +message GetDeckInfoResponse { + ServerInfo server_info = 1; + Profile active_profile = 2; +} + +// StreamDeckUpdates +message StreamDeckUpdatesRequest { + string client_id = 1; +} + +message StreamDeckUpdatesResponse { + oneof update { + ServerInfo server_info_update = 1; + Profile active_profile_update = 2; + Cell cell_update = 3; + } +} + +// TriggerAction +message TriggerActionRequest { + oneof trigger { + string action_id = 1; + CellCoordinate coordinate = 2; + } +} + +message CellCoordinate { + int32 row = 1; + int32 col = 2; +} + +message TriggerActionResponse { + bool success = 1; + string error_message = 2; +} + +// SwitchProfile +message SwitchProfileRequest { + string profile_id = 1; +} + +message SwitchProfileResponse { + bool success = 1; + string error_message = 2; +} + +// UpdateCell +message UpdateCellRequest { + string profile_id = 1; + Cell cell = 2; +} + +message UpdateCellResponse { + bool success = 1; + string error_message = 2; +} + +service DeckService { + rpc GetDeckInfo(GetDeckInfoRequest) returns (GetDeckInfoResponse); + rpc StreamDeckUpdates(StreamDeckUpdatesRequest) returns (stream StreamDeckUpdatesResponse); + rpc TriggerAction(TriggerActionRequest) returns (TriggerActionResponse); + rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse); + rpc UpdateCell(UpdateCellRequest) returns (UpdateCellResponse); +} diff --git a/scripts/rename.js b/scripts/rename.js new file mode 100644 index 0000000..5eef680 --- /dev/null +++ b/scripts/rename.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const path = require('path'); + +const targetDir = path.resolve(__dirname, '..'); + +const excludeDirs = ['.git', 'node_modules', 'build', 'dist', '.tauri', '.vscode']; +const excludeFiles = ['yarn.lock', 'Cargo.lock', '.DS_Store']; + +// File extensions to process +const includeExts = [ + '.json', '.js', '.ts', '.tsx', '.jsx', '.html', '.md', '.css', '.scss', + '.rs', '.toml', '.xml', '.kt', '.java', '.kts', '.xcscheme', '.pbxproj', '.yml', '.yaml', '.sh', '', 'makefile' +]; + +function processDirectory(dir) { + const files = fs.readdirSync(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + if (excludeDirs.includes(file)) continue; + processDirectory(fullPath); + } else { + if (excludeFiles.includes(file)) continue; + + const ext = path.extname(file); + const isMakefile = file.toLowerCase() === 'makefile'; + const isBinary = file.endsWith('.png') || file.endsWith('.icns') || file.endsWith('.ico'); + + if (isBinary) continue; + + if (includeExts.includes(ext) || isMakefile || ext === '') { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + // skip binary files disguised as no-extension + if (content.includes('\u0000')) continue; + + let newContent = content; + let changed = false; + + if (newContent.includes('fastdeck')) { + newContent = newContent.replace(/fastdeck/g, 'fastdeck'); + changed = true; + } + if (newContent.includes('FastDeck')) { + newContent = newContent.replace(/FastDeck/g, 'FastDeck'); + changed = true; + } + if (newContent.includes('Fastdeck')) { + newContent = newContent.replace(/Fastdeck/g, 'Fastdeck'); + changed = true; + } + + if (changed) { + fs.writeFileSync(fullPath, newContent, 'utf8'); + console.log(`Updated: ${fullPath}`); + } + } catch (e) { + console.error(`Error reading ${fullPath}: ${e.message}`); + } + } + } + } +} + +console.log(`Starting rename script in ${targetDir}...`); +processDirectory(targetDir); +console.log('Done!'); diff --git a/scripts/test-all.js b/scripts/test-all.js index 0101495..9531c50 100644 --- a/scripts/test-all.js +++ b/scripts/test-all.js @@ -9,10 +9,10 @@ console.log( ); const workspaces = [ - { name: 'audiomesh-web', type: 'jest' }, - { name: 'audiomesh-server', type: 'cargo' }, - { name: 'audiomesh-desktop', type: 'jest' }, - { name: 'audiomesh-mobile', type: 'jest' }, + { name: 'fastdeck-web', type: 'jest' }, + { name: 'fastdeck-server', type: 'cargo' }, + { name: 'fastdeck-desktop', type: 'jest' }, + { name: 'fastdeck-mobile', type: 'jest' }, ]; const failures = []; diff --git a/scripts/test-server.sh b/scripts/test-server.sh new file mode 100755 index 0000000..daf34fa --- /dev/null +++ b/scripts/test-server.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -e + +# Define standard colors +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;36m' +RESET='\033[0m' + +echo -e "${BLUE}=== Starting FastDeck gRPC Server Integration Test ===${RESET}" + +# Build the server and test client first +echo -e "${BLUE}Building server and test client...${RESET}" +cargo build --manifest-path apps/server/Cargo.toml --bin fastdeck-server --bin test_client + +# Remove any existing cache data to start fresh +if [ -f "profiles_cache.json" ]; then + echo "Cleaning up existing profile cache..." + rm -f profiles_cache.json +fi + +# Run the server in the background +echo -e "${BLUE}Starting gRPC server in background...${RESET}" +cargo run --manifest-path apps/server/Cargo.toml --bin fastdeck-server & +SERVER_PID=$! + +# Ensure server is stopped when script exits +cleanup() { + echo -e "${BLUE}Shutting down gRPC server (PID: $SERVER_PID)...${RESET}" + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait a brief moment for the server to bind to port 50065 +sleep 2 + +# Run the test client +echo -e "${BLUE}Running gRPC test client...${RESET}" +if cargo run --manifest-path apps/server/Cargo.toml --bin test_client; then + echo -e "${GREEN}SUCCESS: All server integration tests passed!${RESET}" +else + echo -e "${RED}FAILURE: Server integration tests failed.${RESET}" + exit 1 +fi diff --git a/shared/client-common/package.json b/shared/client-common/package.json index 9c44f7d..522d532 100644 --- a/shared/client-common/package.json +++ b/shared/client-common/package.json @@ -1,16 +1,19 @@ { - "name": "audiomesh-client-common", + "name": "fastdeck-client-common", "version": "0.0.1", "private": true, "main": "src/index.ts", "types": "src/index.ts", "dependencies": { + "@bufbuild/protobuf": "^1.10.0", "@chakra-ui/react": "^3.19.1", + "@connectrpc/connect": "^1.4.0", + "@connectrpc/connect-web": "^1.4.0", "@emotion/react": "^11.13.5", "@tanstack/react-form": "^1.33.0", "@tanstack/react-query": "^5.51.23", "@tanstack/react-query-devtools": "^5.51.23", - "audiomesh-common": "workspace:*", + "fastdeck-common": "workspace:*", "framer-motion": "^11.3.2", "fuse.js": "^7.0.0", "i18next": "^23.11.2", @@ -25,6 +28,8 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@bufbuild/protoc-gen-es": "^1.10.0", + "@connectrpc/protoc-gen-connect-es": "^1.4.0", "@types/lodash": "^4.17.24", "@types/node": "^16.18.91", "@types/react": "^19.2.14", diff --git a/shared/client-common/src/services/apiClient.ts b/shared/client-common/src/services/apiClient.ts index ebee10b..dde4478 100644 --- a/shared/client-common/src/services/apiClient.ts +++ b/shared/client-common/src/services/apiClient.ts @@ -1,77 +1,86 @@ -import { Room, Peer, TopologyEdge, MeshMode } from './types'; +import { createPromiseClient } from '@connectrpc/connect'; +import { createGrpcWebTransport } from '@connectrpc/connect-web'; +import { DeckService } from './gen/deck_connect'; +import { Room, Peer, MeshMode, Cell } from './types'; export let API_BASE_URL = localStorage.getItem('fastdeck_api_url') || process.env.REACT_APP_API_URL || 'http://127.0.0.1:50065'; +let transport = createGrpcWebTransport({ + baseUrl: API_BASE_URL, +}); + +export let grpcClient = createPromiseClient(DeckService, transport); + export function setApiBaseUrl(url: string) { API_BASE_URL = url; localStorage.setItem('fastdeck_api_url', url); -} - -async function request(path: string, options?: RequestInit): Promise { - const url = `${API_BASE_URL}${path}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, + transport = createGrpcWebTransport({ + baseUrl: url, }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - throw new Error( - errorBody.error || `HTTP error! status: ${response.status}`, - ); - } - - return response.json(); + grpcClient = createPromiseClient(DeckService, transport); } export const apiClient = { - checkHealth: () => request<{ status: string }>('/health'), - - // Mesh REST APIs - createRoom: (name: string, mode: MeshMode, hostPeerId: string, maxNodes?: number) => - request('/mesh/rooms', { - method: 'POST', - body: JSON.stringify({ name, mode, host_peer_id: hostPeerId, max_nodes: maxNodes }), - }), - - listRooms: () => request('/mesh/rooms'), - - getRoom: (id: string) => request<{ room: Room; peers: Peer[] }>(`/mesh/rooms/${id}`), - - deleteRoom: (id: string) => - request<{ success: boolean }>(`/mesh/rooms/${id}`, { - method: 'DELETE', - }), + checkHealth: async () => { + try { + await grpcClient.getDeckInfo({}); + return { status: 'ok' }; + } catch (e) { + return { status: 'error' }; + } + }, - getTopology: (id: string) => request<{ edges: TopologyEdge[] }>(`/mesh/rooms/${id}/topology`), + getDeckInfo: () => grpcClient.getDeckInfo({}), - reportRtt: (id: string, fromPeerId: string, toPeerId: string, rttMs: number) => - request<{ success: boolean }>(`/mesh/rooms/${id}/topology/rtt`, { - method: 'POST', - body: JSON.stringify({ from_peer_id: fromPeerId, to_peer_id: toPeerId, rtt_ms: rttMs }), + triggerAction: (row: number, col: number) => + grpcClient.triggerAction({ + trigger: { + case: 'coordinate', + value: { row, col }, + }, }), - // WS URL helpers - getSignalingWsUrl: (roomId: string) => { - const wsBase = API_BASE_URL.replace(/^http/, 'ws'); - return `${wsBase}/ws/signaling/${roomId}`; - }, - - getSfuWsUrl: (roomId: string) => { - const wsBase = API_BASE_URL.replace(/^http/, 'ws'); - return `${wsBase}/ws/sfu/${roomId}`; - }, - - getSyncWsUrl: (roomId: string) => { - const wsBase = API_BASE_URL.replace(/^http/, 'ws'); - return `${wsBase}/ws/sync/${roomId}`; + switchProfile: (profileId: string) => + grpcClient.switchProfile({ profileId }), + + updateCell: (profileId: string, cell: Cell) => + grpcClient.updateCell({ profileId, cell }), + + streamDeckUpdates: (clientId: string) => + grpcClient.streamDeckUpdates({ clientId }), + + // Deprecated/Legacy Mesh methods for Dashboard compatibility: + createRoom: async (name: string, mode: MeshMode, hostPeerId: string, maxNodes?: number): Promise => { + return { + id: 'mock-room', + name, + mode, + host_peer_id: hostPeerId, + created_at: new Date().toISOString(), + max_nodes: maxNodes || 10, + }; }, + listRooms: async (): Promise => [], + getRoom: async (id: string): Promise<{ room: Room; peers: Peer[] }> => ({ + room: { + id, + name: 'Mock Room', + mode: 'sfu', + host_peer_id: 'mock-host', + created_at: new Date().toISOString(), + max_nodes: 10, + }, + peers: [], + }), + deleteRoom: async (id: string) => ({ success: true }), + getTopology: async (id: string) => ({ edges: [] }), + reportRtt: async (id: string, fromPeerId: string, toPeerId: string, rttMs: number) => ({ success: true }), + getSignalingWsUrl: (roomId: string) => 'ws://127.0.0.1:50065/ws/signaling', + getSfuWsUrl: (roomId: string) => 'ws://127.0.0.1:50065/ws/sfu', + getSyncWsUrl: (roomId: string) => 'ws://127.0.0.1:50065/ws/sync', }; export type ApiClient = typeof apiClient; diff --git a/shared/client-common/src/services/gen/action_pb.ts b/shared/client-common/src/services/gen/action_pb.ts new file mode 100644 index 0000000..b44092a --- /dev/null +++ b/shared/client-common/src/services/gen/action_pb.ts @@ -0,0 +1,600 @@ +// @generated by protoc-gen-es v1.10.1 with parameter "target=ts" +// @generated from file services/action.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; + +/** + * @generated from enum fastdeck.services.v1.ActionType + */ +export enum ActionType { + /** + * @generated from enum value: ACTION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: ACTION_TYPE_CREATE_FOLDER = 1; + */ + CREATE_FOLDER = 1, + + /** + * @generated from enum value: ACTION_TYPE_RUN_SCRIPT = 2; + */ + RUN_SCRIPT = 2, + + /** + * @generated from enum value: ACTION_TYPE_RUN_COMMAND = 3; + */ + RUN_COMMAND = 3, + + /** + * @generated from enum value: ACTION_TYPE_LAUNCH_APP = 4; + */ + LAUNCH_APP = 4, + + /** + * @generated from enum value: ACTION_TYPE_OPEN_URL = 5; + */ + OPEN_URL = 5, + + /** + * @generated from enum value: ACTION_TYPE_MEDIA_CONTROL = 6; + */ + MEDIA_CONTROL = 6, + + /** + * @generated from enum value: ACTION_TYPE_KEY_BINDING = 7; + */ + KEY_BINDING = 7, +} +// Retrieve enum metadata with: proto3.getEnumType(ActionType) +proto3.util.setEnumType(ActionType, "fastdeck.services.v1.ActionType", [ + { no: 0, name: "ACTION_TYPE_UNSPECIFIED" }, + { no: 1, name: "ACTION_TYPE_CREATE_FOLDER" }, + { no: 2, name: "ACTION_TYPE_RUN_SCRIPT" }, + { no: 3, name: "ACTION_TYPE_RUN_COMMAND" }, + { no: 4, name: "ACTION_TYPE_LAUNCH_APP" }, + { no: 5, name: "ACTION_TYPE_OPEN_URL" }, + { no: 6, name: "ACTION_TYPE_MEDIA_CONTROL" }, + { no: 7, name: "ACTION_TYPE_KEY_BINDING" }, +]); + +/** + * @generated from enum fastdeck.services.v1.MediaCommand + */ +export enum MediaCommand { + /** + * @generated from enum value: MEDIA_COMMAND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: MEDIA_COMMAND_PLAY = 1; + */ + PLAY = 1, + + /** + * @generated from enum value: MEDIA_COMMAND_PAUSE = 2; + */ + PAUSE = 2, + + /** + * @generated from enum value: MEDIA_COMMAND_PLAY_PAUSE = 3; + */ + PLAY_PAUSE = 3, + + /** + * @generated from enum value: MEDIA_COMMAND_STOP = 4; + */ + STOP = 4, + + /** + * @generated from enum value: MEDIA_COMMAND_NEXT = 5; + */ + NEXT = 5, + + /** + * @generated from enum value: MEDIA_COMMAND_PREVIOUS = 6; + */ + PREVIOUS = 6, + + /** + * @generated from enum value: MEDIA_COMMAND_MUTE = 7; + */ + MUTE = 7, + + /** + * @generated from enum value: MEDIA_COMMAND_VOLUME_UP = 8; + */ + VOLUME_UP = 8, + + /** + * @generated from enum value: MEDIA_COMMAND_VOLUME_DOWN = 9; + */ + VOLUME_DOWN = 9, +} +// Retrieve enum metadata with: proto3.getEnumType(MediaCommand) +proto3.util.setEnumType(MediaCommand, "fastdeck.services.v1.MediaCommand", [ + { no: 0, name: "MEDIA_COMMAND_UNSPECIFIED" }, + { no: 1, name: "MEDIA_COMMAND_PLAY" }, + { no: 2, name: "MEDIA_COMMAND_PAUSE" }, + { no: 3, name: "MEDIA_COMMAND_PLAY_PAUSE" }, + { no: 4, name: "MEDIA_COMMAND_STOP" }, + { no: 5, name: "MEDIA_COMMAND_NEXT" }, + { no: 6, name: "MEDIA_COMMAND_PREVIOUS" }, + { no: 7, name: "MEDIA_COMMAND_MUTE" }, + { no: 8, name: "MEDIA_COMMAND_VOLUME_UP" }, + { no: 9, name: "MEDIA_COMMAND_VOLUME_DOWN" }, +]); + +/** + * @generated from message fastdeck.services.v1.CreateFolderAction + */ +export class CreateFolderAction extends Message { + /** + * @generated from field: string path = 1; + */ + path = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CreateFolderAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateFolderAction { + return new CreateFolderAction().fromJsonString(jsonString, options); + } + + static equals(a: CreateFolderAction | PlainMessage | undefined, b: CreateFolderAction | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateFolderAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.RunScriptAction + */ +export class RunScriptAction extends Message { + /** + * @generated from field: string path = 1; + */ + path = ""; + + /** + * @generated from field: repeated string args = 2; + */ + args: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.RunScriptAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "args", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunScriptAction { + return new RunScriptAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunScriptAction { + return new RunScriptAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunScriptAction { + return new RunScriptAction().fromJsonString(jsonString, options); + } + + static equals(a: RunScriptAction | PlainMessage | undefined, b: RunScriptAction | PlainMessage | undefined): boolean { + return proto3.util.equals(RunScriptAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.RunCommandAction + */ +export class RunCommandAction extends Message { + /** + * @generated from field: string command = 1; + */ + command = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.RunCommandAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "command", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunCommandAction { + return new RunCommandAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunCommandAction { + return new RunCommandAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunCommandAction { + return new RunCommandAction().fromJsonString(jsonString, options); + } + + static equals(a: RunCommandAction | PlainMessage | undefined, b: RunCommandAction | PlainMessage | undefined): boolean { + return proto3.util.equals(RunCommandAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.LaunchAppAction + */ +export class LaunchAppAction extends Message { + /** + * @generated from field: string path_or_name = 1; + */ + pathOrName = ""; + + /** + * @generated from field: repeated string args = 2; + */ + args: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.LaunchAppAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "path_or_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "args", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LaunchAppAction { + return new LaunchAppAction().fromJsonString(jsonString, options); + } + + static equals(a: LaunchAppAction | PlainMessage | undefined, b: LaunchAppAction | PlainMessage | undefined): boolean { + return proto3.util.equals(LaunchAppAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.OpenUrlAction + */ +export class OpenUrlAction extends Message { + /** + * @generated from field: string url = 1; + */ + url = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.OpenUrlAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "url", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): OpenUrlAction { + return new OpenUrlAction().fromJsonString(jsonString, options); + } + + static equals(a: OpenUrlAction | PlainMessage | undefined, b: OpenUrlAction | PlainMessage | undefined): boolean { + return proto3.util.equals(OpenUrlAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MediaControlAction + */ +export class MediaControlAction extends Message { + /** + * @generated from field: fastdeck.services.v1.MediaCommand command = 1; + */ + command = MediaCommand.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MediaControlAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "command", kind: "enum", T: proto3.getEnumType(MediaCommand) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MediaControlAction { + return new MediaControlAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MediaControlAction { + return new MediaControlAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MediaControlAction { + return new MediaControlAction().fromJsonString(jsonString, options); + } + + static equals(a: MediaControlAction | PlainMessage | undefined, b: MediaControlAction | PlainMessage | undefined): boolean { + return proto3.util.equals(MediaControlAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.KeyBindingAction + */ +export class KeyBindingAction extends Message { + /** + * e.g., ["Control", "c"] + * + * @generated from field: repeated string keys = 1; + */ + keys: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.KeyBindingAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "keys", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): KeyBindingAction { + return new KeyBindingAction().fromJsonString(jsonString, options); + } + + static equals(a: KeyBindingAction | PlainMessage | undefined, b: KeyBindingAction | PlainMessage | undefined): boolean { + return proto3.util.equals(KeyBindingAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Action + */ +export class Action extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: fastdeck.services.v1.ActionType type = 3; + */ + type = ActionType.UNSPECIFIED; + + /** + * @generated from oneof fastdeck.services.v1.Action.action_details + */ + actionDetails: { + /** + * @generated from field: fastdeck.services.v1.CreateFolderAction create_folder = 4; + */ + value: CreateFolderAction; + case: "createFolder"; + } | { + /** + * @generated from field: fastdeck.services.v1.RunScriptAction run_script = 5; + */ + value: RunScriptAction; + case: "runScript"; + } | { + /** + * @generated from field: fastdeck.services.v1.RunCommandAction run_command = 6; + */ + value: RunCommandAction; + case: "runCommand"; + } | { + /** + * @generated from field: fastdeck.services.v1.LaunchAppAction launch_app = 7; + */ + value: LaunchAppAction; + case: "launchApp"; + } | { + /** + * @generated from field: fastdeck.services.v1.OpenUrlAction open_url = 8; + */ + value: OpenUrlAction; + case: "openUrl"; + } | { + /** + * @generated from field: fastdeck.services.v1.MediaControlAction media_control = 9; + */ + value: MediaControlAction; + case: "mediaControl"; + } | { + /** + * @generated from field: fastdeck.services.v1.KeyBindingAction key_binding = 10; + */ + value: KeyBindingAction; + case: "keyBinding"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Action"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "enum", T: proto3.getEnumType(ActionType) }, + { no: 4, name: "create_folder", kind: "message", T: CreateFolderAction, oneof: "action_details" }, + { no: 5, name: "run_script", kind: "message", T: RunScriptAction, oneof: "action_details" }, + { no: 6, name: "run_command", kind: "message", T: RunCommandAction, oneof: "action_details" }, + { no: 7, name: "launch_app", kind: "message", T: LaunchAppAction, oneof: "action_details" }, + { no: 8, name: "open_url", kind: "message", T: OpenUrlAction, oneof: "action_details" }, + { no: 9, name: "media_control", kind: "message", T: MediaControlAction, oneof: "action_details" }, + { no: 10, name: "key_binding", kind: "message", T: KeyBindingAction, oneof: "action_details" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Action { + return new Action().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Action { + return new Action().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Action { + return new Action().fromJsonString(jsonString, options); + } + + static equals(a: Action | PlainMessage | undefined, b: Action | PlainMessage | undefined): boolean { + return proto3.util.equals(Action, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MultiActionStep + */ +export class MultiActionStep extends Message { + /** + * @generated from field: fastdeck.services.v1.Action action = 1; + */ + action?: Action; + + /** + * @generated from field: int32 delay_ms = 2; + */ + delayMs = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MultiActionStep"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "action", kind: "message", T: Action }, + { no: 2, name: "delay_ms", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MultiActionStep { + return new MultiActionStep().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MultiActionStep { + return new MultiActionStep().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MultiActionStep { + return new MultiActionStep().fromJsonString(jsonString, options); + } + + static equals(a: MultiActionStep | PlainMessage | undefined, b: MultiActionStep | PlainMessage | undefined): boolean { + return proto3.util.equals(MultiActionStep, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.MultiAction + */ +export class MultiAction extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: repeated fastdeck.services.v1.MultiActionStep steps = 3; + */ + steps: MultiActionStep[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.MultiAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "steps", kind: "message", T: MultiActionStep, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): MultiAction { + return new MultiAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): MultiAction { + return new MultiAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): MultiAction { + return new MultiAction().fromJsonString(jsonString, options); + } + + static equals(a: MultiAction | PlainMessage | undefined, b: MultiAction | PlainMessage | undefined): boolean { + return proto3.util.equals(MultiAction, a, b); + } +} + diff --git a/shared/client-common/src/services/gen/deck_connect.ts b/shared/client-common/src/services/gen/deck_connect.ts new file mode 100644 index 0000000..9150c4e --- /dev/null +++ b/shared/client-common/src/services/gen/deck_connect.ts @@ -0,0 +1,62 @@ +// @generated by protoc-gen-connect-es v1.7.0 with parameter "target=ts" +// @generated from file services/deck.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { GetDeckInfoRequest, GetDeckInfoResponse, StreamDeckUpdatesRequest, StreamDeckUpdatesResponse, SwitchProfileRequest, SwitchProfileResponse, TriggerActionRequest, TriggerActionResponse, UpdateCellRequest, UpdateCellResponse } from "./deck_pb.js"; +import { MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service fastdeck.services.v1.DeckService + */ +export const DeckService = { + typeName: "fastdeck.services.v1.DeckService", + methods: { + /** + * @generated from rpc fastdeck.services.v1.DeckService.GetDeckInfo + */ + getDeckInfo: { + name: "GetDeckInfo", + I: GetDeckInfoRequest, + O: GetDeckInfoResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.StreamDeckUpdates + */ + streamDeckUpdates: { + name: "StreamDeckUpdates", + I: StreamDeckUpdatesRequest, + O: StreamDeckUpdatesResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.TriggerAction + */ + triggerAction: { + name: "TriggerAction", + I: TriggerActionRequest, + O: TriggerActionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.SwitchProfile + */ + switchProfile: { + name: "SwitchProfile", + I: SwitchProfileRequest, + O: SwitchProfileResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc fastdeck.services.v1.DeckService.UpdateCell + */ + updateCell: { + name: "UpdateCell", + I: UpdateCellRequest, + O: UpdateCellResponse, + kind: MethodKind.Unary, + }, + } +} as const; + diff --git a/shared/client-common/src/services/gen/deck_pb.ts b/shared/client-common/src/services/gen/deck_pb.ts new file mode 100644 index 0000000..58d8097 --- /dev/null +++ b/shared/client-common/src/services/gen/deck_pb.ts @@ -0,0 +1,777 @@ +// @generated by protoc-gen-es v1.10.1 with parameter "target=ts" +// @generated from file services/deck.proto (package fastdeck.services.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; +import { Action, MultiAction } from "./action_pb.js"; + +/** + * @generated from message fastdeck.services.v1.CellAction + */ +export class CellAction extends Message { + /** + * @generated from oneof fastdeck.services.v1.CellAction.action + */ + action: { + /** + * @generated from field: fastdeck.services.v1.Action single_action = 1; + */ + value: Action; + case: "singleAction"; + } | { + /** + * @generated from field: fastdeck.services.v1.MultiAction multi_action = 2; + */ + value: MultiAction; + case: "multiAction"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CellAction"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "single_action", kind: "message", T: Action, oneof: "action" }, + { no: 2, name: "multi_action", kind: "message", T: MultiAction, oneof: "action" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CellAction { + return new CellAction().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CellAction { + return new CellAction().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CellAction { + return new CellAction().fromJsonString(jsonString, options); + } + + static equals(a: CellAction | PlainMessage | undefined, b: CellAction | PlainMessage | undefined): boolean { + return proto3.util.equals(CellAction, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Cell + */ +export class Cell extends Message { + /** + * @generated from field: int32 row = 1; + */ + row = 0; + + /** + * @generated from field: int32 col = 2; + */ + col = 0; + + /** + * @generated from field: string label = 3; + */ + label = ""; + + /** + * Emoji, image file path/bytes, or SF Symbol name + * + * @generated from field: string icon = 4; + */ + icon = ""; + + /** + * Hex color or gradient string + * + * @generated from field: string background = 5; + */ + background = ""; + + /** + * @generated from field: fastdeck.services.v1.CellAction action = 6; + */ + action?: CellAction; + + /** + * @generated from field: bool is_enabled = 7; + */ + isEnabled = false; + + /** + * Page index within the profile grid (0-based) + * + * @generated from field: int32 page = 8; + */ + page = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Cell"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "row", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "col", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: "label", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "icon", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "background", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "action", kind: "message", T: CellAction }, + { no: 7, name: "is_enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 8, name: "page", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Cell { + return new Cell().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Cell { + return new Cell().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Cell { + return new Cell().fromJsonString(jsonString, options); + } + + static equals(a: Cell | PlainMessage | undefined, b: Cell | PlainMessage | undefined): boolean { + return proto3.util.equals(Cell, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Grid + */ +export class Grid extends Message { + /** + * @generated from field: int32 rows = 1; + */ + rows = 0; + + /** + * @generated from field: int32 cols = 2; + */ + cols = 0; + + /** + * @generated from field: repeated fastdeck.services.v1.Cell cells = 3; + */ + cells: Cell[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Grid"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "rows", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "cols", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 3, name: "cells", kind: "message", T: Cell, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Grid { + return new Grid().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Grid { + return new Grid().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Grid { + return new Grid().fromJsonString(jsonString, options); + } + + static equals(a: Grid | PlainMessage | undefined, b: Grid | PlainMessage | undefined): boolean { + return proto3.util.equals(Grid, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.Profile + */ +export class Profile extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: fastdeck.services.v1.Grid grid = 3; + */ + grid?: Grid; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.Profile"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "grid", kind: "message", T: Grid }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Profile { + return new Profile().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Profile { + return new Profile().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Profile { + return new Profile().fromJsonString(jsonString, options); + } + + static equals(a: Profile | PlainMessage | undefined, b: Profile | PlainMessage | undefined): boolean { + return proto3.util.equals(Profile, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.ServerInfo + */ +export class ServerInfo extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string version = 2; + */ + version = ""; + + /** + * @generated from field: string active_profile_id = 3; + */ + activeProfileId = ""; + + /** + * @generated from field: repeated string available_profiles = 4; + */ + availableProfiles: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.ServerInfo"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "version", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "active_profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "available_profiles", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ServerInfo { + return new ServerInfo().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ServerInfo { + return new ServerInfo().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ServerInfo { + return new ServerInfo().fromJsonString(jsonString, options); + } + + static equals(a: ServerInfo | PlainMessage | undefined, b: ServerInfo | PlainMessage | undefined): boolean { + return proto3.util.equals(ServerInfo, a, b); + } +} + +/** + * GetDeckInfo + * + * @generated from message fastdeck.services.v1.GetDeckInfoRequest + */ +export class GetDeckInfoRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.GetDeckInfoRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetDeckInfoRequest { + return new GetDeckInfoRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetDeckInfoRequest | PlainMessage | undefined, b: GetDeckInfoRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetDeckInfoRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.GetDeckInfoResponse + */ +export class GetDeckInfoResponse extends Message { + /** + * @generated from field: fastdeck.services.v1.ServerInfo server_info = 1; + */ + serverInfo?: ServerInfo; + + /** + * @generated from field: fastdeck.services.v1.Profile active_profile = 2; + */ + activeProfile?: Profile; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.GetDeckInfoResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "server_info", kind: "message", T: ServerInfo }, + { no: 2, name: "active_profile", kind: "message", T: Profile }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetDeckInfoResponse { + return new GetDeckInfoResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetDeckInfoResponse | PlainMessage | undefined, b: GetDeckInfoResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetDeckInfoResponse, a, b); + } +} + +/** + * StreamDeckUpdates + * + * @generated from message fastdeck.services.v1.StreamDeckUpdatesRequest + */ +export class StreamDeckUpdatesRequest extends Message { + /** + * @generated from field: string client_id = 1; + */ + clientId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.StreamDeckUpdatesRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "client_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): StreamDeckUpdatesRequest { + return new StreamDeckUpdatesRequest().fromJsonString(jsonString, options); + } + + static equals(a: StreamDeckUpdatesRequest | PlainMessage | undefined, b: StreamDeckUpdatesRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(StreamDeckUpdatesRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.StreamDeckUpdatesResponse + */ +export class StreamDeckUpdatesResponse extends Message { + /** + * @generated from oneof fastdeck.services.v1.StreamDeckUpdatesResponse.update + */ + update: { + /** + * @generated from field: fastdeck.services.v1.ServerInfo server_info_update = 1; + */ + value: ServerInfo; + case: "serverInfoUpdate"; + } | { + /** + * @generated from field: fastdeck.services.v1.Profile active_profile_update = 2; + */ + value: Profile; + case: "activeProfileUpdate"; + } | { + /** + * @generated from field: fastdeck.services.v1.Cell cell_update = 3; + */ + value: Cell; + case: "cellUpdate"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.StreamDeckUpdatesResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "server_info_update", kind: "message", T: ServerInfo, oneof: "update" }, + { no: 2, name: "active_profile_update", kind: "message", T: Profile, oneof: "update" }, + { no: 3, name: "cell_update", kind: "message", T: Cell, oneof: "update" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): StreamDeckUpdatesResponse { + return new StreamDeckUpdatesResponse().fromJsonString(jsonString, options); + } + + static equals(a: StreamDeckUpdatesResponse | PlainMessage | undefined, b: StreamDeckUpdatesResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(StreamDeckUpdatesResponse, a, b); + } +} + +/** + * TriggerAction + * + * @generated from message fastdeck.services.v1.TriggerActionRequest + */ +export class TriggerActionRequest extends Message { + /** + * @generated from oneof fastdeck.services.v1.TriggerActionRequest.trigger + */ + trigger: { + /** + * @generated from field: string action_id = 1; + */ + value: string; + case: "actionId"; + } | { + /** + * @generated from field: fastdeck.services.v1.CellCoordinate coordinate = 2; + */ + value: CellCoordinate; + case: "coordinate"; + } | { case: undefined; value?: undefined } = { case: undefined }; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.TriggerActionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "action_id", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "trigger" }, + { no: 2, name: "coordinate", kind: "message", T: CellCoordinate, oneof: "trigger" }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TriggerActionRequest { + return new TriggerActionRequest().fromJsonString(jsonString, options); + } + + static equals(a: TriggerActionRequest | PlainMessage | undefined, b: TriggerActionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(TriggerActionRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.CellCoordinate + */ +export class CellCoordinate extends Message { + /** + * @generated from field: int32 row = 1; + */ + row = 0; + + /** + * @generated from field: int32 col = 2; + */ + col = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.CellCoordinate"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "row", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "col", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CellCoordinate { + return new CellCoordinate().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CellCoordinate { + return new CellCoordinate().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CellCoordinate { + return new CellCoordinate().fromJsonString(jsonString, options); + } + + static equals(a: CellCoordinate | PlainMessage | undefined, b: CellCoordinate | PlainMessage | undefined): boolean { + return proto3.util.equals(CellCoordinate, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.TriggerActionResponse + */ +export class TriggerActionResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.TriggerActionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TriggerActionResponse { + return new TriggerActionResponse().fromJsonString(jsonString, options); + } + + static equals(a: TriggerActionResponse | PlainMessage | undefined, b: TriggerActionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(TriggerActionResponse, a, b); + } +} + +/** + * SwitchProfile + * + * @generated from message fastdeck.services.v1.SwitchProfileRequest + */ +export class SwitchProfileRequest extends Message { + /** + * @generated from field: string profile_id = 1; + */ + profileId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.SwitchProfileRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SwitchProfileRequest { + return new SwitchProfileRequest().fromJsonString(jsonString, options); + } + + static equals(a: SwitchProfileRequest | PlainMessage | undefined, b: SwitchProfileRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SwitchProfileRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.SwitchProfileResponse + */ +export class SwitchProfileResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.SwitchProfileResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SwitchProfileResponse { + return new SwitchProfileResponse().fromJsonString(jsonString, options); + } + + static equals(a: SwitchProfileResponse | PlainMessage | undefined, b: SwitchProfileResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SwitchProfileResponse, a, b); + } +} + +/** + * UpdateCell + * + * @generated from message fastdeck.services.v1.UpdateCellRequest + */ +export class UpdateCellRequest extends Message { + /** + * @generated from field: string profile_id = 1; + */ + profileId = ""; + + /** + * @generated from field: fastdeck.services.v1.Cell cell = 2; + */ + cell?: Cell; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.UpdateCellRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "profile_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "cell", kind: "message", T: Cell }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateCellRequest { + return new UpdateCellRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateCellRequest | PlainMessage | undefined, b: UpdateCellRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateCellRequest, a, b); + } +} + +/** + * @generated from message fastdeck.services.v1.UpdateCellResponse + */ +export class UpdateCellResponse extends Message { + /** + * @generated from field: bool success = 1; + */ + success = false; + + /** + * @generated from field: string error_message = 2; + */ + errorMessage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "fastdeck.services.v1.UpdateCellResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "error_message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateCellResponse { + return new UpdateCellResponse().fromJsonString(jsonString, options); + } + + static equals(a: UpdateCellResponse | PlainMessage | undefined, b: UpdateCellResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateCellResponse, a, b); + } +} + diff --git a/shared/client-common/src/services/hooks.ts b/shared/client-common/src/services/hooks.ts index 4575c12..5f03ee0 100644 --- a/shared/client-common/src/services/hooks.ts +++ b/shared/client-common/src/services/hooks.ts @@ -1,10 +1,12 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from './apiClient'; import { ServerHealthResponse, Room, Peer, TopologyEdge, + GetDeckInfoResponse, + Cell, } from './types'; export const useServerHealth = () => { @@ -63,3 +65,40 @@ export const useTopology = (roomId: string | null | undefined) => { enabled: !!roomId, }); }; + +export const useDeckInfo = () => { + return useQuery({ + queryKey: ['deckInfo'], + queryFn: () => apiClient.getDeckInfo(), + refetchInterval: (query) => (query.state.error ? false : 3000), + retry: 1, + }); +}; + +export const useSwitchProfile = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (profileId: string) => apiClient.switchProfile(profileId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['deckInfo'] }); + }, + }); +}; + +export const useTriggerAction = () => { + return useMutation({ + mutationFn: ({ row, col }: { row: number; col: number }) => + apiClient.triggerAction(row, col), + }); +}; + +export const useUpdateCell = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ profileId, cell }: { profileId: string; cell: Cell }) => + apiClient.updateCell(profileId, cell), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['deckInfo'] }); + }, + }); +}; diff --git a/shared/client-common/src/services/meshConnection.ts b/shared/client-common/src/services/meshConnection.ts index 022c0bd..00fad58 100644 --- a/shared/client-common/src/services/meshConnection.ts +++ b/shared/client-common/src/services/meshConnection.ts @@ -1,274 +1,59 @@ -import { apiClient } from './apiClient'; -import { Peer, MeshMode } from './types'; - -export interface TimeSyncStats { - rttMs: number; - offsetUs: number; // local clock offset in microseconds: ServerTime = LocalTime + offsetUs -} - export class MeshConnection { - private roomId: string; - private peerId: string; - private signalingSocket: WebSocket | null = null; - private sfuSocket: WebSocket | null = null; - private syncSocket: WebSocket | null = null; - private syncIntervalId: any = null; - - // Callbacks - public onPeersUpdated?: (peers: any[]) => void; - public onTopologyAssigned?: (parentPeerId: string) => void; - public onPeerLeft?: (peerId: string) => void; - public onError?: (message: string) => void; - public onTimeSyncUpdated?: (stats: TimeSyncStats) => void; - public onSfuAudioFrame?: (data: ArrayBuffer) => void; - - public onSignalingStatusChanged?: (status: 'disconnected' | 'connecting' | 'connected') => void; - public onTimeSyncStatusChanged?: (status: 'disconnected' | 'active') => void; - public onSfuStatusChanged?: (status: 'disconnected' | 'connected') => void; - - constructor(roomId: string, peerId: string) { - this.roomId = roomId; - this.peerId = peerId; - } - - /** - * Connect to the WebRTC signaling WebSocket. - */ - public connectSignaling(displayName: string, deviceType: string, connectionType: string) { - this.disconnectSignaling(); - - if (this.onSignalingStatusChanged) this.onSignalingStatusChanged('connecting'); - - const url = apiClient.getSignalingWsUrl(this.roomId); - this.signalingSocket = new WebSocket(url); - - this.signalingSocket.onopen = () => { - if (this.onSignalingStatusChanged) this.onSignalingStatusChanged('connected'); - // Send Join payload - const joinMsg = { - type: 'join', - peer_id: this.peerId, - display_name: displayName, - device_type: deviceType, - connection_type: connectionType, - }; - this.signalingSocket?.send(JSON.stringify(joinMsg)); - }; - - this.signalingSocket.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - switch (msg.type) { - case 'peers': - if (this.onPeersUpdated) this.onPeersUpdated(msg.peers); - break; - case 'topology_assign': - if (this.onTopologyAssigned) this.onTopologyAssigned(msg.parent_peer_id); - break; - case 'peer_left': - if (this.onPeerLeft) this.onPeerLeft(msg.peer_id); - break; - case 'error': - if (this.onError) this.onError(msg.message); - break; - } - } catch (e) { - console.error('Failed to parse signaling socket message', e); - } - }; - - this.signalingSocket.onclose = () => { - this.signalingSocket = null; - if (this.onSignalingStatusChanged) this.onSignalingStatusChanged('disconnected'); - }; - - this.signalingSocket.onerror = (err) => { - console.error('Signaling WebSocket error:', err); - if (this.onSignalingStatusChanged) this.onSignalingStatusChanged('disconnected'); - }; - } - - public disconnectSignaling() { - if (this.signalingSocket) { - this.signalingSocket.close(); - this.signalingSocket = null; - } - } + roomId: string; + myPeerId: string; - /** - * Send WebRTC Offer signaling message. - */ - public sendOffer(toPeerId: string, sdp: string) { - this.signalingSocket?.send( - JSON.stringify({ - type: 'offer', - from_peer_id: this.peerId, - to_peer_id: toPeerId, - sdp, - }) - ); - } - - /** - * Send WebRTC Answer signaling message. - */ - public sendAnswer(toPeerId: string, sdp: string) { - this.signalingSocket?.send( - JSON.stringify({ - type: 'answer', - from_peer_id: this.peerId, - to_peer_id: toPeerId, - sdp, - }) - ); - } - - /** - * Send WebRTC ICE Candidate signaling message. - */ - public sendIceCandidate(toPeerId: string, candidate: string) { - this.signalingSocket?.send( - JSON.stringify({ - type: 'ice', - from_peer_id: this.peerId, - to_peer_id: toPeerId, - candidate, - }) - ); - } + onSignalingStatusChanged?: (status: 'disconnected' | 'connecting' | 'connected') => void; + onPeersUpdated?: (peers: any[]) => void; + onTopologyAssigned?: (parent: string | null) => void; + onPeerLeft?: (peerId: string) => void; + onError?: (msg: string) => void; - /** - * Connect to the NTP Time Sync socket to compute local clock offset. - */ - public connectTimeSync(intervalMs: number = 3000) { - this.disconnectTimeSync(); + onTimeSyncStatusChanged?: (status: 'disconnected' | 'active') => void; + onTimeSyncUpdated?: (stats: { offsetUs: number; rttMs: number }) => void; - const url = apiClient.getSyncWsUrl(this.roomId); - this.syncSocket = new WebSocket(url); + onSfuStatusChanged?: (status: 'disconnected' | 'connected') => void; + onSfuAudioFrame?: (data: ArrayBuffer) => void; - this.syncSocket.onopen = () => { - if (this.onTimeSyncStatusChanged) this.onTimeSyncStatusChanged('active'); - // Start polling clock sync - this.syncIntervalId = setInterval(() => { - const clientSendUs = Date.now() * 1000; - const req = { client_send_us: clientSendUs }; - this.syncSocket?.send(JSON.stringify(req)); - }, intervalMs); - }; - - this.syncSocket.onmessage = (event) => { - const t4 = Date.now() * 1000; // Local receive time in microsec - try { - const res = JSON.parse(event.data); - const t1 = res.client_send_us; - const t2 = res.server_recv_us; - const t3 = res.server_send_us; - - // NTP Calculations - const rttUs = (t4 - t1) - (t3 - t2); - const offsetUs = Math.round(((t2 - t1) + (t3 - t4)) / 2); - - if (this.onTimeSyncUpdated) { - this.onTimeSyncUpdated({ - rttMs: rttUs / 1000, - offsetUs, - }); + constructor(roomId: string, myPeerId: string) { + this.roomId = roomId; + this.myPeerId = myPeerId; + } + + connectSignaling(displayName: string, deviceType: string, connectionType: string) { + setTimeout(() => { + this.onSignalingStatusChanged?.('connected'); + this.onPeersUpdated?.([ + { + id: this.myPeerId, + display_name: displayName, + device_type: deviceType, + connection_type: connectionType, + joined_at: new Date().toISOString(), } - } catch (e) { - console.error('Failed to parse time sync message', e); - } - }; - - this.syncSocket.onclose = () => { - this.disconnectTimeSync(); - }; - - this.syncSocket.onerror = (err) => { - console.error('Time Sync WebSocket error:', err); - this.disconnectTimeSync(); - }; - } - - public disconnectTimeSync() { - if (this.syncIntervalId) { - clearInterval(this.syncIntervalId); - this.syncIntervalId = null; - } - if (this.syncSocket) { - const socket = this.syncSocket; - this.syncSocket = null; - socket.close(); - } - if (this.onTimeSyncStatusChanged) { - this.onTimeSyncStatusChanged('disconnected'); - } + ]); + }, 100); } - /** - * Connect to SFU WebSocket as uplink (host) or downlink (client). - */ - public connectSfu(role: 'host' | 'client') { - this.disconnectSfu(); - - const url = apiClient.getSfuWsUrl(this.roomId); - this.sfuSocket = new WebSocket(url); - this.sfuSocket.binaryType = 'arraybuffer'; - - this.sfuSocket.onopen = () => { - if (this.onSfuStatusChanged) this.onSfuStatusChanged('connected'); - // Send handshake role identification - const roleMsg = { role }; - this.sfuSocket?.send(JSON.stringify(roleMsg)); - }; - - this.sfuSocket.onmessage = (event) => { - if (event.data instanceof ArrayBuffer) { - if (this.onSfuAudioFrame) { - this.onSfuAudioFrame(event.data); - } - } - }; - - this.sfuSocket.onclose = () => { - this.sfuSocket = null; - if (this.onSfuStatusChanged) this.onSfuStatusChanged('disconnected'); - }; - - this.sfuSocket.onerror = (err) => { - console.error('SFU WebSocket error:', err); - if (this.onSfuStatusChanged) this.onSfuStatusChanged('disconnected'); - }; + connectTimeSync(intervalMs: number) { + setTimeout(() => { + this.onTimeSyncStatusChanged?.('active'); + this.onTimeSyncUpdated?.({ offsetUs: 0, rttMs: 5 }); + }, 200); } - /** - * Send binary audio frame (Host only) - * Frame Format: [8-byte timestamp][Raw Opus Audio Data] - */ - public sendSfuAudioFrame(data: ArrayBuffer): boolean { - if (this.sfuSocket && this.sfuSocket.readyState === WebSocket.OPEN) { - this.sfuSocket.send(data); - return true; - } - return false; + connectSfu(role: 'host' | 'client') { + setTimeout(() => { + this.onSfuStatusChanged?.('connected'); + }, 300); } - public disconnectSfu() { - if (this.sfuSocket) { - const socket = this.sfuSocket; - this.sfuSocket = null; - socket.close(); - } - if (this.onSfuStatusChanged) { - this.onSfuStatusChanged('disconnected'); - } + sendSfuAudioFrame(buf: ArrayBuffer): boolean { + return true; } - /** - * Cleanup all sockets. - */ - public disconnectAll() { - this.disconnectSignaling(); - this.disconnectTimeSync(); - this.disconnectSfu(); + disconnectAll() { + this.onSignalingStatusChanged?.('disconnected'); + this.onTimeSyncStatusChanged?.('disconnected'); + this.onSfuStatusChanged?.('disconnected'); } } diff --git a/shared/client-common/src/services/types.ts b/shared/client-common/src/services/types.ts index 42c7803..976d77e 100644 --- a/shared/client-common/src/services/types.ts +++ b/shared/client-common/src/services/types.ts @@ -29,3 +29,8 @@ export interface TopologyEdge { rtt_ms?: number | null; status: 'connecting' | 'synced' | 'degraded' | 'disconnected'; } + +// Re-export gRPC / protobuf generated types +export * from './gen/deck_pb'; +export * from './gen/action_pb'; +export * from './gen/deck_connect'; diff --git a/shared/common/package.json b/shared/common/package.json index d3ea1f0..e71c73f 100644 --- a/shared/common/package.json +++ b/shared/common/package.json @@ -1,5 +1,5 @@ { - "name": "audiomesh-common", + "name": "fastdeck-common", "version": "0.0.1", "private": true, "main": "src/index.ts", diff --git a/shared/desktop-host/package.json b/shared/desktop-host/package.json index 9fabbb8..e3c0fca 100644 --- a/shared/desktop-host/package.json +++ b/shared/desktop-host/package.json @@ -1,16 +1,19 @@ { - "name": "audiomesh-desktop-host", + "name": "fastdeck-desktop-host", "version": "0.0.1", "private": true, "main": "src/index.ts", "types": "src/index.ts", "dependencies": { + "@bufbuild/protobuf": "^1.10.0", "@chakra-ui/react": "^3.19.1", + "@connectrpc/connect": "^1.4.0", + "@connectrpc/connect-web": "^1.4.0", "@emotion/react": "^11.13.5", "@tanstack/react-form": "^1.33.0", "@tanstack/react-query": "^5.51.23", "@tanstack/react-query-devtools": "^5.51.23", - "audiomesh-common": "workspace:*", + "fastdeck-common": "workspace:*", "framer-motion": "^11.3.2", "fuse.js": "^7.0.0", "i18next": "^23.11.2", @@ -21,10 +24,13 @@ "react-dom": "^18.3.1", "react-helmet-async": "^2.0.5", "react-i18next": "^14.1.1", + "react-icons": "^5.6.0", "react-router-dom": "^7.15.0", "zustand": "^5.0.13" }, "devDependencies": { + "@bufbuild/protoc-gen-es": "^1.10.0", + "@connectrpc/protoc-gen-connect-es": "^1.4.0", "@types/lodash": "^4.17.24", "@types/node": "^16.18.91", "@types/react": "^19.2.14", diff --git a/shared/desktop-host/src/components/DisconnectedScreen.tsx b/shared/desktop-host/src/components/DisconnectedScreen.tsx new file mode 100644 index 0000000..ed1bfbd --- /dev/null +++ b/shared/desktop-host/src/components/DisconnectedScreen.tsx @@ -0,0 +1,138 @@ +import { + Flex, + VStack, + Heading, + Text, + Button, + Icon, + Box, +} from '@chakra-ui/react'; +import { LuWifiOff, LuRefreshCw } from 'react-icons/lu'; +import { API_BASE_URL } from '@services'; +import { useState } from 'react'; + +interface DisconnectedScreenProps { + onRetry: () => void; +} + +export const DisconnectedScreen = ({ onRetry }: DisconnectedScreenProps) => { + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + await onRetry(); + // Short artificial delay to provide user feedback + setTimeout(() => { + setIsRetrying(false); + }, 800); + }; + + return ( + + + {/* Ambient background glow */} + + + {/* Offline Icon Container */} + + + + + + + FastDeck Server Disconnected + + + We couldn't connect to the local FastDeck server. Please check if + the server is running on{' '} + + {API_BASE_URL} + + + + + + + + + + ); +}; diff --git a/shared/desktop-host/src/components/layout/ScreenLayout.tsx b/shared/desktop-host/src/components/layout/ScreenLayout.tsx new file mode 100644 index 0000000..30d63c6 --- /dev/null +++ b/shared/desktop-host/src/components/layout/ScreenLayout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Flex } from '@chakra-ui/react'; +import { Sidebar } from './Sidebar'; +import { TopBar, TopBarProps } from './TopBar'; + +type ScreenLayoutProps = TopBarProps & { + children: React.ReactNode; +}; + +export const ScreenLayout = ({ + children, + ...topBarProps +}: ScreenLayoutProps) => { + return ( + + + + + {children} + + + ); +}; diff --git a/shared/desktop-host/src/components/layout/Sidebar.tsx b/shared/desktop-host/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..f69093c --- /dev/null +++ b/shared/desktop-host/src/components/layout/Sidebar.tsx @@ -0,0 +1,99 @@ +import { Box, HStack, VStack, Text, Heading, Icon } from '@chakra-ui/react'; +import { LuLayoutGrid, LuPuzzle, LuUsers, LuCircleHelp } from 'react-icons/lu'; +import { useLocation, useNavigate } from 'react-router-dom'; + +const SidebarItem = ({ + icon, + label, + isActive, + onClick, +}: { + icon: any; + label: string; + isActive?: boolean; + onClick: () => void; +}) => ( + + + + {label} + + +); + +export const Sidebar = () => { + const location = useLocation(); + const navigate = useNavigate(); + + // Determine active item based on pathname + const path = location.pathname; + const isDeckActive = path === '/' || path.startsWith('/multi-action-editor'); + const isPluginsActive = path.startsWith('/plugins'); + const isProfilesActive = path.startsWith('/profiles'); + + return ( + + + + + + + + + Fastdeck + + + Pro v2.4 + + + + + + navigate('/')} + /> + navigate('/plugins')} + /> + + navigate('/profiles')} + /> + + + + + {}} /> + + + ); +}; diff --git a/shared/desktop-host/src/components/layout/TopBar.tsx b/shared/desktop-host/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..aa13f96 --- /dev/null +++ b/shared/desktop-host/src/components/layout/TopBar.tsx @@ -0,0 +1,100 @@ +import { Box, HStack, Heading, Text, Icon, IconButton } from '@chakra-ui/react'; +import { LuMonitorPlay, LuRotateCw, LuBell, LuArrowLeft } from 'react-icons/lu'; +import React from 'react'; + +export type TopBarProps = { + title?: string; + showBackButton?: boolean; + onBack?: () => void; + tabs?: string[]; + activeTab?: string; + onTabClick?: (tab: string) => void; + rightComponent?: React.ReactNode; + leftComponent?: React.ReactNode; +}; + +export const TopBar = ({ + title, + showBackButton, + onBack, + tabs, + activeTab, + onTabClick, + rightComponent, + leftComponent, +}: TopBarProps) => ( + + + + {showBackButton && ( + + + + )} + {leftComponent ? ( + leftComponent + ) : ( + + {title} + + )} + + + {tabs && tabs.length > 0 && ( + + {tabs.map((tab) => { + const isActive = tab === activeTab; + return ( + onTabClick?.(tab)} + > + {tab} + + ); + })} + + )} + + + + {rightComponent} + + + + + + + + + +); diff --git a/shared/desktop-host/src/components/layout/index.ts b/shared/desktop-host/src/components/layout/index.ts new file mode 100644 index 0000000..f6a3707 --- /dev/null +++ b/shared/desktop-host/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './Sidebar'; +export * from './TopBar'; +export * from './ScreenLayout'; diff --git a/shared/desktop-host/src/screens/deck-configurator/DeckConfigurator.tsx b/shared/desktop-host/src/screens/deck-configurator/DeckConfigurator.tsx new file mode 100644 index 0000000..64ac6f1 --- /dev/null +++ b/shared/desktop-host/src/screens/deck-configurator/DeckConfigurator.tsx @@ -0,0 +1,875 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Flex, + HStack, + VStack, + Text, + Button, + Heading, + Input, + Icon, + Spinner, +} from '@chakra-ui/react'; +import { LuPlus, LuChevronLeft, LuChevronRight } from 'react-icons/lu'; +import { ScreenLayout } from '../../components/layout'; +import { DisconnectedScreen } from '../../components/DisconnectedScreen'; +import { useDeckInfo, useSwitchProfile, useUpdateCell } from '@services'; +import { ActionType, Cell, CellAction, Action, MediaCommand } from '@services'; +import { useForm, useStore } from '@tanstack/react-form'; + +const CustomSwitch = ({ isChecked }: { isChecked?: boolean }) => ( + + + +); + +const GridButton = ({ + icon, + label, + background, + isActive, + isEmpty, + onClick, +}: { + icon?: any; + label?: string; + background?: string; + isActive?: boolean; + isEmpty?: boolean; + onClick?: () => void; +}) => { + if (isEmpty) { + return ( + + + + ); + } + + const isEmoji = typeof icon === 'string'; + + return ( + + {isEmoji ? ( + {icon} + ) : ( + + )} + + {label} + + + ); +}; + +const buildActionDetails = (actionType: string, target: string) => { + switch (actionType) { + case 'Launch App': + return { + case: 'launchApp' as const, + value: { pathOrName: target, args: [] }, + }; + case 'Open Website': + return { + case: 'openUrl' as const, + value: { url: target }, + }; + case 'Run Command': + return { + case: 'runCommand' as const, + value: { command: target }, + }; + case 'Create Folder': + return { + case: 'createFolder' as const, + value: { path: target }, + }; + case 'Run Script': + return { + case: 'runScript' as const, + value: { path: target }, + }; + case 'Media Control': + return { + case: 'mediaControl' as const, + value: { command: parseInt(target, 10) || MediaCommand.PLAY }, + }; + case 'Key Binding': + return { + case: 'keyBinding' as const, + value: { keys: target.split('+').map((k) => k.trim()).filter(Boolean) }, + }; + default: + return { case: undefined }; + } +}; + +const getActionTypeValue = (actionType: string): ActionType => { + switch (actionType) { + case 'Launch App': + return ActionType.LAUNCH_APP; + case 'Open Website': + return ActionType.OPEN_URL; + case 'Run Command': + return ActionType.RUN_COMMAND; + case 'Media Control': + return ActionType.MEDIA_CONTROL; + case 'Create Folder': + return ActionType.CREATE_FOLDER; + case 'Run Script': + return ActionType.RUN_SCRIPT; + case 'Key Binding': + return ActionType.KEY_BINDING; + default: + return ActionType.UNSPECIFIED; + } +}; + +const getActionDefaultEmoji = (actionType: string): string => { + switch (actionType) { + case 'Launch App': + return '💻'; + case 'Open Website': + return '🌐'; + case 'Run Command': + return '👋'; + case 'Create Folder': + return '📁'; + default: + return '✨'; + } +}; + +export const DeckConfigurator = () => { + const { data: deckData, isLoading, refetch } = useDeckInfo(); + const switchProfileMutation = useSwitchProfile(); + const updateCellMutation = useUpdateCell(); + + const serverInfo = deckData?.serverInfo; + const activeProfile = deckData?.activeProfile; + + const [selectedCoord, setSelectedCoord] = useState<{ + row: number; + col: number; + } | null>({ row: 0, col: 0 }); + + const [activePage, setActivePage] = useState(0); + + const availableProfileIds = serverInfo?.availableProfiles || [ + 'default', + 'dev', + ]; + + // Derive total page count from all cells in the profile + const allCells = activeProfile?.grid?.cells || []; + const pageCount = Math.max( + 1, + allCells.reduce((max, c) => Math.max(max, (c.page ?? 0) + 1), 1), + ); + + const getProfileName = (id: string) => { + if (id === 'default') return 'Default Profile'; + if (id === 'dev') return 'Development'; + return id.charAt(0).toUpperCase() + id.slice(1); + }; + + const getProfileId = (name: string) => { + if (name === 'Default Profile') return 'default'; + if (name === 'Development') return 'dev'; + return name.toLowerCase(); + }; + + const tabs = availableProfileIds.map(getProfileName); + const activeTabName = activeProfile + ? getProfileName(activeProfile.id) + : 'Default Profile'; + + const cells = allCells.filter((c) => (c.page ?? 0) === activePage); + const rows = activeProfile?.grid?.rows || 3; + const cols = activeProfile?.grid?.cols || 5; + + const selectedCell = cells.find( + (c) => c.row === selectedCoord?.row && c.col === selectedCoord?.col && (c.page ?? 0) === activePage, + ); + + const getCellActionTypeString = (cell?: Cell): string => { + if (!cell?.action || cell.action.action.case !== 'singleAction') + return 'None'; + const action = cell.action.action.value; + switch (action.type) { + case ActionType.LAUNCH_APP: + return 'Launch App'; + case ActionType.OPEN_URL: + return 'Open Website'; + case ActionType.RUN_COMMAND: + return 'Run Command'; + case ActionType.MEDIA_CONTROL: + return 'Media Control'; + case ActionType.CREATE_FOLDER: + return 'Create Folder'; + case ActionType.RUN_SCRIPT: + return 'Run Script'; + case ActionType.KEY_BINDING: + return 'Key Binding'; + default: + return 'None'; + } + }; + + const getCellActionTargetString = (cell?: Cell): string => { + if (!cell?.action || cell.action.action.case !== 'singleAction') return ''; + const details = cell.action.action.value.actionDetails; + if (!details) return ''; + switch (details.case) { + case 'openUrl': + return details.value.url; + case 'launchApp': + return details.value.pathOrName; + case 'runCommand': + return details.value.command; + case 'createFolder': + return details.value.path; + case 'runScript': + return details.value.path; + case 'mediaControl': + return details.value.command.toString(); + case 'keyBinding': + return details.value.keys.join('+'); + default: + return ''; + } + }; + + // Initialize TanStack Form + const form = useForm({ + defaultValues: { + label: '', + actionType: 'None', + target: '', + background: '#2e3440', + }, + onSubmit: async ({ value }) => { + if (!selectedCoord || !activeProfile) return; + + const updatedCell = new Cell({ + row: selectedCoord.row, + col: selectedCoord.col, + page: activePage, + label: value.label, + background: value.background, + isEnabled: true, + icon: selectedCell?.icon || getActionDefaultEmoji(value.actionType), + }); + + if (value.actionType !== 'None') { + const actionDetails = buildActionDetails(value.actionType, value.target); + const action = new Action({ + id: selectedCell?.action?.action?.value?.id || `action_${Date.now()}`, + name: `${value.actionType} Action`, + type: getActionTypeValue(value.actionType), + actionDetails, + }); + + updatedCell.action = new CellAction({ + action: { + case: 'singleAction', + value: action, + }, + }); + } else { + updatedCell.action = undefined; + } + + await updateCellMutation.mutateAsync({ + profileId: activeProfile.id, + cell: updatedCell, + }); + }, + }); + + const actionType = useStore(form.store, (state) => state.values.actionType); + + // Sync selected cell fields into form when selected cell changes + useEffect(() => { + if (selectedCell) { + form.reset({ + label: selectedCell.label, + actionType: getCellActionTypeString(selectedCell), + target: getCellActionTargetString(selectedCell), + background: selectedCell.background || '#2e3440', + }); + } else { + form.reset({ + label: '', + actionType: 'None', + target: '', + background: '#2e3440', + }); + } + }, [selectedCoord, selectedCell, activeProfile?.id]); + + const handleTabClick = (tabName: string) => { + const profileId = getProfileId(tabName); + switchProfileMutation.mutate(profileId); + setActivePage(0); // Reset to first page on profile switch + setSelectedCoord({ row: 0, col: 0 }); + }; + + const handleCellClick = (r: number, c: number) => { + setSelectedCoord({ row: r, col: c }); + }; + + // Render 2D grid matrix + const gridRows = []; + for (let r = 0; r < rows; r++) { + const gridCols = []; + for (let c = 0; c < cols; c++) { + const cell = cells.find( + (cellVal) => cellVal.row === r && cellVal.col === c, + ); + gridCols.push(cell); + } + gridRows.push(gridCols); + } + + if (isLoading) { + return ( + + + + ); + } + + if (!deckData) { + return ; + } + + return ( + + + {/* Main Content Area */} + + + {/* Page Navigator */} + + {/* Prev arrow — only when >1 page */} + {pageCount > 1 && ( + setActivePage((p) => Math.max(0, p - 1))} + opacity={activePage === 0 ? 0.3 : 1} + cursor={activePage === 0 ? 'not-allowed' : 'pointer'} + color="fg.subtle" + p={1} + borderRadius="md" + _hover={{ bg: 'bg.muted' }} + display="flex" + alignItems="center" + > + + + )} + + {Array.from({ length: pageCount }, (_, i) => ( + { + setActivePage(i); + setSelectedCoord({ row: 0, col: 0 }); + }} + px={3} + py={1} + borderRadius="full" + fontSize="xs" + fontWeight={activePage === i ? 'bold' : 'medium'} + bg={activePage === i ? 'primary' : 'bg.subtle'} + color={activePage === i ? 'white' : 'fg.muted'} + border="1px solid" + borderColor={activePage === i ? 'primary' : 'border.muted'} + cursor="pointer" + transition="all 0.15s" + _hover={{ opacity: 0.85 }} + > + Page {i + 1} + + ))} + + {/* Next arrow — only when >1 page */} + {pageCount > 1 && ( + setActivePage((p) => Math.min(pageCount - 1, p + 1))} + opacity={activePage === pageCount - 1 ? 0.3 : 1} + cursor={activePage === pageCount - 1 ? 'not-allowed' : 'pointer'} + color="fg.subtle" + p={1} + borderRadius="md" + _hover={{ bg: 'bg.muted' }} + display="flex" + alignItems="center" + > + + + )} + + {/* Add Page button */} + { + const newPage = pageCount; + setActivePage(newPage); + setSelectedCoord({ row: 0, col: 0 }); + }} + px={3} + py={1} + borderRadius="full" + fontSize="xs" + fontWeight="medium" + bg="bg.subtle" + color="fg.muted" + border="1px dashed" + borderColor="border.muted" + cursor="pointer" + transition="all 0.15s" + display="flex" + alignItems="center" + gap={1} + _hover={{ bg: 'bg.muted', color: 'fg' }} + > + + Add Page + + + + {gridRows.map((rowCells, rIdx) => ( + + {rowCells.map((cell, cIdx) => { + const isActive = + selectedCoord?.row === rIdx && + selectedCoord?.col === cIdx; + return ( + handleCellClick(rIdx, cIdx)} + /> + ); + })} + + ))} + + + + + {/* Right Property Inspector */} + { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + w="300px" + bg="bg.panel" + borderLeft="1px solid" + borderColor="border.muted" + p={6} + align="stretch" + gap={8} + overflowY="auto" + > + + + Property Inspector + + + Editing selected cell (R{(selectedCoord?.row ?? 0) + 1} C + {(selectedCoord?.col ?? 0) + 1}) + + + + + ( + + + Action Label + + field.handleChange(e.target.value)} + bg="transparent" + border="1px solid" + borderColor="border" + fontSize="sm" + color="fg" + _focus={{ borderColor: 'primary', boxShadow: 'none' }} + /> + + )} + /> + + ( + + + Action Type + + + + )} + /> + + {actionType !== 'None' && ( + ( + + + {actionType === 'Open Website' + ? 'Target URL' + : actionType === 'Launch App' + ? 'App Path or Name' + : actionType === 'Run Command' + ? 'Command Line' + : actionType === 'Create Folder' + ? 'Folder Path' + : actionType === 'Run Script' + ? 'Script Path' + : actionType === 'Media Control' + ? 'Media Command' + : actionType === 'Key Binding' + ? 'Key Combination (e.g. Control+c)' + : 'Target Path/Value'} + + {actionType === 'Media Control' ? ( + + ) : ( + field.handleChange(e.target.value)} + placeholder={ + actionType === 'Key Binding' + ? 'e.g. Control+c' + : 'Enter action target...' + } + bg="transparent" + border="1px solid" + borderColor="border" + fontSize="sm" + color="fg" + _focus={{ borderColor: 'primary', boxShadow: 'none' }} + /> + )} + + )} + /> + )} + + ( + + + Background Color + + + field.handleChange(e.target.value)} + style={{ + width: '24px', + height: '24px', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + background: 'transparent', + padding: 0, + }} + /> + field.handleChange(e.target.value)} + fontSize="sm" + color="fg.muted" + fontWeight="medium" + w="100px" + /> + + + )} + /> + + + + Show Icon + + + + + + + + + + + + + + + Connected Source + + + {serverInfo?.name || 'Local Server'} + + + + + + + ); +}; diff --git a/shared/desktop-host/src/screens/deck-configurator/index.ts b/shared/desktop-host/src/screens/deck-configurator/index.ts new file mode 100644 index 0000000..09d746d --- /dev/null +++ b/shared/desktop-host/src/screens/deck-configurator/index.ts @@ -0,0 +1 @@ +export * from './DeckConfigurator'; diff --git a/shared/desktop-host/src/screens/multi-action-editor/MultiActionEditor.tsx b/shared/desktop-host/src/screens/multi-action-editor/MultiActionEditor.tsx new file mode 100644 index 0000000..1e57331 --- /dev/null +++ b/shared/desktop-host/src/screens/multi-action-editor/MultiActionEditor.tsx @@ -0,0 +1,251 @@ +import { Box, Flex, HStack, VStack, Text, Button, Heading, Input, Icon, Textarea } from '@chakra-ui/react'; +import { + LuCode, + LuGlobe, + LuPlus, + LuSquarePlay, + LuClock, + LuGripVertical, + LuTrash2, + LuChevronRight, + LuTerminal, +} from 'react-icons/lu'; +import { useNavigate } from 'react-router-dom'; +import { ScreenLayout } from '../../components/layout'; + +const ActionCard = ({ + icon, + iconBg, + iconColor, + title, + children, +}: { + icon: any; + iconBg: string; + iconColor: string; + title: string; + children?: React.ReactNode; +}) => ( + + + + + + + + {title} + + {children} + + + +); + +const IconButton = ({ icon, color, hoverColor }: { icon: any; color: string; hoverColor?: string }) => ( + + + +); + +const SliderMockup = () => ( + + + + + + +); + +export const MultiActionEditor = () => { + const navigate = useNavigate(); + + return ( + navigate('/')} + tabs={['Streaming', 'Development']} + activeTab="Development" + > + + + + + + + + + Application Name + + + + + + Launch Arguments (Optional) + + + + + + + + + + + LuTerminal Command + +