From cad78b81888a911115f9fcee3edf6d2e918894b0 Mon Sep 17 00:00:00 2001 From: alexfoxtm <228959921+alexfoxtm@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:54:23 -0400 Subject: [PATCH] Add iPad companion over LAN and Tailscale Working MVP snapshot for hardbeat920/monocode#95: Mac host + iPad thin client. LAN-first pairing, native iPad WebSocket, and embedded Tailscale on desktop and iOS. Generated Tauri output under src-tauri/gen is not included. This is a preview of the running prototype, based on 0.1.34, not a request to merge as-is. --- .gitignore | 3 + Cargo.lock | 1854 +++++++++++++++++++++++- index.html | 23 +- package-lock.json | 66 +- package.json | 2 + src-tauri/.gitignore | 4 +- src-tauri/Cargo.toml | 13 +- src-tauri/Info.ios.plist | 25 + src-tauri/build.rs | 86 ++ src-tauri/capabilities/default.json | 4 +- src-tauri/capabilities/desktop.json | 8 + src-tauri/src/companion_ws.rs | 440 ++++++ src-tauri/src/lib.rs | 63 +- src-tauri/src/menu.rs | 6 +- src-tauri/src/remote.rs | 1586 ++++++++++++++++++++ src-tauri/src/remote_dispatch.rs | 546 +++++++ src-tauri/src/remote_server.rs | 663 +++++++++ src-tauri/src/session_store.rs | 111 +- src-tauri/src/tailnet_embed.rs | 322 ++++ src-tauri/src/tsnet_mobile.rs | 299 ++++ src-tauri/src/window.rs | 6 +- src-tauri/tauri.conf.json | 9 +- src-tauri/tsnet/clangwrap-ios.sh | 9 + src-tauri/tsnet/go.mod | 51 + src-tauri/tsnet/go.sum | 236 +++ src-tauri/tsnet/tsnet.go | 268 ++++ src/App.tsx | 82 +- src/chrome/Composer.tsx | 15 +- src/chrome/GitChangesPanel.tsx | 2 +- src/chrome/MenuBar.tsx | 20 +- src/chrome/ProjectRail.tsx | 12 +- src/chrome/RemoteProjectPicker.tsx | 149 ++ src/chrome/SettingsRail.tsx | 10 +- src/chrome/Sidebar.tsx | 8 +- src/chrome/SidebarUpdate.tsx | 3 + src/chrome/TitleBar.tsx | 4 +- src/chrome/settingsControls.tsx | 109 ++ src/index.css | 40 + src/lib/appLifecycle.ts | 5 +- src/lib/appearance.ts | 10 +- src/lib/attachments.ts | 3 +- src/lib/checkpoint.ts | 3 +- src/lib/dockBadge.ts | 3 +- src/lib/fs.ts | 5 +- src/lib/githubTasks.ts | 3 +- src/lib/harness/child.ts | 5 +- src/lib/harness/cursorStore.ts | 3 +- src/lib/inboxMedia.ts | 3 +- src/lib/linear.ts | 3 +- src/lib/notes.ts | 3 +- src/lib/platform.ts | 28 + src/lib/projectLogos.ts | 6 +- src/lib/pty.ts | 5 +- src/lib/rateLimitsFetch.ts | 3 +- src/lib/search.ts | 3 +- src/lib/sessionStore.ts | 59 +- src/lib/sessionStoreChanged.test.ts | 42 + src/lib/settings.test.ts | 24 + src/lib/settings.ts | 36 +- src/lib/tailscaleLogin.ts | 31 + src/lib/terminalClose.ts | 2 +- src/lib/transport/dialog.ts | 61 + src/lib/transport/index.ts | 372 +++++ src/lib/transport/link.test.ts | 186 +++ src/lib/transport/local.ts | 29 + src/lib/transport/native.test.ts | 27 + src/lib/transport/native.ts | 135 ++ src/lib/transport/protocol.test.ts | 193 +++ src/lib/transport/protocol.ts | 282 ++++ src/lib/transport/remote.test.ts | 422 ++++++ src/lib/transport/remote.ts | 569 ++++++++ src/lib/transport/types.ts | 28 + src/lib/updater.ts | 14 +- src/lib/windowTransferBootstrap.ts | 3 +- src/main.tsx | 118 +- src/surfaces/CompanionPage.tsx | 887 ++++++++++++ src/surfaces/CompanionPairing.tsx | 325 +++++ src/surfaces/EmptySession.tsx | 3 +- src/surfaces/InboxView.tsx | 4 +- src/surfaces/NotesView.tsx | 4 +- src/surfaces/QrScanner.tsx | 160 ++ src/surfaces/SearchView.tsx | 4 +- src/surfaces/SettingsView.tsx | 336 ++--- src/surfaces/TailnetStatusCard.test.ts | 21 + src/surfaces/TailnetStatusCard.tsx | 382 +++++ 85 files changed, 11637 insertions(+), 373 deletions(-) create mode 100644 src-tauri/Info.ios.plist create mode 100644 src-tauri/capabilities/desktop.json create mode 100644 src-tauri/src/companion_ws.rs create mode 100644 src-tauri/src/remote.rs create mode 100644 src-tauri/src/remote_dispatch.rs create mode 100644 src-tauri/src/remote_server.rs create mode 100644 src-tauri/src/tailnet_embed.rs create mode 100644 src-tauri/src/tsnet_mobile.rs create mode 100755 src-tauri/tsnet/clangwrap-ios.sh create mode 100644 src-tauri/tsnet/go.mod create mode 100644 src-tauri/tsnet/go.sum create mode 100644 src-tauri/tsnet/tsnet.go create mode 100644 src/chrome/RemoteProjectPicker.tsx create mode 100644 src/chrome/settingsControls.tsx create mode 100644 src/lib/sessionStoreChanged.test.ts create mode 100644 src/lib/tailscaleLogin.ts create mode 100644 src/lib/transport/dialog.ts create mode 100644 src/lib/transport/index.ts create mode 100644 src/lib/transport/link.test.ts create mode 100644 src/lib/transport/local.ts create mode 100644 src/lib/transport/native.test.ts create mode 100644 src/lib/transport/native.ts create mode 100644 src/lib/transport/protocol.test.ts create mode 100644 src/lib/transport/protocol.ts create mode 100644 src/lib/transport/remote.test.ts create mode 100644 src/lib/transport/remote.ts create mode 100644 src/lib/transport/types.ts create mode 100644 src/surfaces/CompanionPage.tsx create mode 100644 src/surfaces/CompanionPairing.tsx create mode 100644 src/surfaces/QrScanner.tsx create mode 100644 src/surfaces/TailnetStatusCard.test.ts create mode 100644 src/surfaces/TailnetStatusCard.tsx diff --git a/.gitignore b/.gitignore index 5cde0cc3..ad498826 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ dist-ssr/ build/ target/ gen/schemas/ +src-tauri/tsnet/*.a +src-tauri/tsnet/*.h +src-tauri/gen/apple/Externals/**/libmonocode_tsnet.a coverage/ .vite/ .turbo/ diff --git a/Cargo.lock b/Cargo.lock index 5ec962cb..1dd41293 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -32,6 +42,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -56,6 +72,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -222,6 +244,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -264,6 +309,36 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitrs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad65ba2eda683a8486f72c13f0e1f5afbaa99ccd08029e6a72b1cf46ef0f0309" +dependencies = [ + "bitrs-macro", + "zerocopy", +] + +[[package]] +name = "bitrs-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896dc69748b78a3bc4b815084e15cb51efdedc22a6d0ef5a443f6bd5e92af89a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -273,6 +348,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -295,6 +379,22 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bounded-integer" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102dbef1187b1893e6dfe05a774e79fd52265f49f214f6879c8ff49f52c8188b" + [[package]] name = "brotli" version = "8.0.4" @@ -426,6 +526,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -462,6 +564,47 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -474,6 +617,26 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -493,6 +656,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -552,6 +721,30 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -583,9 +776,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto_box" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" +dependencies = [ + "aead", + "crypto_secretbox", + "curve25519-dalek 4.1.3", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -625,6 +857,47 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -659,6 +932,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -670,6 +963,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + [[package]] name = "defmt" version = "1.1.1" @@ -748,8 +1050,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -834,6 +1148,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -885,6 +1205,24 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-eq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388" + +[[package]] +name = "dyn-hash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "embed-resource" version = "3.0.11" @@ -959,6 +1297,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etherparse" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b119b9796ff800751a220394b8b3613f26dd30c48f254f6837e64c464872d1c7" +dependencies = [ + "arrayvec", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -996,6 +1343,9 @@ name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] [[package]] name = "fdeflate" @@ -1006,6 +1356,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1042,6 +1404,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1090,6 +1464,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1097,6 +1492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1164,6 +1560,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1281,6 +1678,17 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", ] [[package]] @@ -1313,8 +1721,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1465,17 +1876,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -1501,6 +1961,46 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b05da5b9e5d4720bfb691eebb2b9d42da3570745da71eac8a1f5bb7e59aab88" +dependencies = [ + "hmac", + "sha1 0.10.7", +] + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "html5ever" version = "0.38.0" @@ -1556,6 +2056,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1566,6 +2075,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1789,11 +2299,23 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] [[package]] name = "is-docker" @@ -1814,6 +2336,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1849,7 +2380,7 @@ version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ - "defmt", + "defmt 1.1.1", "jiff-core", "jiff-static", "jiff-tzdb-platform", @@ -1866,7 +2397,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ - "defmt", + "defmt 1.1.1", ] [[package]] @@ -1970,6 +2501,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -2003,6 +2544,46 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kameo" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea00bfc3709c5b95be7c9a93289d0c3ff42cf9a3b77d532387242992705bca7" +dependencies = [ + "downcast-rs", + "dyn-clone", + "futures", + "kameo_macros", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "kameo_actors" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535f5db40b46b28085ddf4ba0391818e1b388548bf454e6d27a3b5cc805961fa" +dependencies = [ + "futures", + "glob", + "kameo", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "kameo_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7566055976eb86ee8e8fbafa0fbdad985c5d7c3f4eed04fc11bb495f71e3856" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2014,6 +2595,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2110,6 +2697,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2121,6 +2714,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.3" @@ -2175,6 +2774,8 @@ version = "0.1.34" dependencies = [ "base64 0.22.1", "block2", + "futures-util", + "http", "libc", "objc2", "objc2-app-kit", @@ -2182,8 +2783,10 @@ dependencies = [ "objc2-user-notifications", "raw-window-handle", "rusqlite", + "rustls", "serde", "serde_json", + "tailscale", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2191,6 +2794,8 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tauri-plugin-window-state", + "tokio", + "tokio-tungstenite", "ureq", ] @@ -2239,18 +2844,111 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags 2.13.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.20", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2521,6 +3219,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.1" @@ -2622,12 +3326,60 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + [[package]] name = "phf" version = "0.13.1" @@ -2757,6 +3509,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2788,16 +3551,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "precomputed-hash" -version = "0.1.1" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "precis-core" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "9c2e7b31f132e0c6f8682cfb7bf4a5340dbe925b7986618d0826a56dfe0c8e56" +dependencies = [ + "precis-tools", + "ucd-parse", + "unicode-normalization", +] + +[[package]] +name = "precis-profiles" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e2768890a47af73a032af9f0cedbddce3c9d06cf8de201d5b8f2436ded7674" +dependencies = [ + "lazy_static", + "precis-core", + "precis-tools", + "unicode-normalization", +] + +[[package]] +name = "precis-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc1eb2d5887ac7bfd2c0b745764db89edb84b856e4214e204ef48ef96d10c4a" +dependencies = [ + "lazy_static", + "regex", + "ucd-parse", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", "toml_edit 0.19.15", @@ -2873,6 +3679,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted-string-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc75379cdb451d001f1cb667a9f74e8b355e9df84cc5193513cbe62b96fc5e9" +dependencies = [ + "pest", + "pest_derive", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -2885,6 +3701,61 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2954,6 +3825,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -3037,6 +3914,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtnetlink" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc19f84f710fa2f337617f9bc0400260a94224bde7bae28fd8879f3771ca5784" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rusqlite" version = "0.40.2" @@ -3084,6 +3979,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -3147,6 +4043,7 @@ version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3158,6 +4055,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3444,6 +4350,28 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3451,8 +4379,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3505,12 +4433,42 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallbox" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca054fd9f8c2ebe8557a2433f307e038c0716124efd045daa0388afa5172189" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + [[package]] name = "socket2" version = "0.6.5" @@ -3569,12 +4527,27 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -3605,6 +4578,30 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "stun-rs" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb921f10397d5669e1af6455e9e2d367bf1f9cebcd6b1dd1dc50e19f6a9ac2ac" +dependencies = [ + "base64 0.22.1", + "bounded-integer", + "byteorder", + "crc", + "enumflags2", + "fallible-iterator", + "hmac-sha1", + "hmac-sha256", + "hostname-validator", + "lazy_static", + "md5", + "paste", + "precis-core", + "precis-profiles", + "quoted-string-parser", + "rand 0.9.5", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3687,6 +4684,25 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tailscale" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27b4582e1de061fffac7733c893cae4ab559478462562edf13d72d83b38aece" +dependencies = [ + "rand 0.10.2", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "ts_control", + "ts_keys", + "ts_netstack_smoltcp", + "ts_runtime", + "url", +] + [[package]] name = "tao" version = "0.35.3" @@ -3721,7 +4737,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3804,7 +4820,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3945,7 +4961,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.20", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4029,7 +5045,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4054,7 +5070,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4235,9 +5251,22 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", + "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4248,6 +5277,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4257,7 +5310,10 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", + "libc", "pin-project-lite", + "slab", "tokio", ] @@ -4486,65 +5542,637 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "typeid" -version = "1.0.3" +name = "ts_array256" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +checksum = "f68610cca60e50dff05afb3ef4d7d183a2ac50fd2aea5c416b1946d20f0a91eb" +dependencies = [ + "heapless", + "smallvec", + "static_assertions", + "ts_bitset", +] [[package]] -name = "typenum" -version = "1.20.1" +name = "ts_bart" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "3f9db073258ae92ea14070f5e606e4b44a81d5f6452348cae19b96935a986aac" +dependencies = [ + "cfg-if", + "heapless", + "ipnet", + "static_assertions", + "ts_array256", + "ts_bitset", +] [[package]] -name = "uds_windows" -version = "1.2.1" +name = "ts_bart_packetfilter" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +checksum = "d66e70021116d557eff0aba73ed204db118ea653ceb5e857a42bd47d60b1aa33" dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", + "hashbrown 0.17.1", + "smallvec", + "ts_array256", + "ts_bart", + "ts_bitset", + "ts_dynbitset", + "ts_packetfilter", ] [[package]] -name = "unic-char-property" -version = "0.9.0" +name = "ts_bitset" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +checksum = "6674d0576b769f5d6333d3121c2a03d935f478740f3a87d81e10604952e8a4d0" dependencies = [ - "unic-char-range", + "cfg-if", + "static_assertions", ] [[package]] -name = "unic-char-range" -version = "0.9.0" +name = "ts_capabilityversion" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" +checksum = "30cdfc7fadb22af93860d0aad875843142ad945a829647c59ac8d3f8dd1cd307" +dependencies = [ + "serde", +] [[package]] -name = "unic-common" -version = "0.9.0" +name = "ts_control" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" +checksum = "b1b6a3b0fb7ced8c5db65b3cdc8adcf5e63c35f10073e67c9d399ee6f05b1293" +dependencies = [ + "bytes", + "chrono", + "futures-util", + "gethostname", + "ipnet", + "lazy_static", + "pin-project-lite", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "ts_bitset", + "ts_capabilityversion", + "ts_control_noise", + "ts_control_serde", + "ts_derp", + "ts_dynbitset", + "ts_http_util", + "ts_keys", + "ts_packet", + "ts_packetfilter", + "ts_packetfilter_state", + "ts_tls_util", + "url", + "zerocopy", +] [[package]] -name = "unic-ucd-ident" -version = "0.9.0" +name = "ts_control_noise" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +checksum = "46071c79d7454133038a317767409d03851ed945aa6367ecea62c6bebf8329cc" dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", + "base64 0.22.1", + "bytes", + "chacha20poly1305", + "futures-util", + "pin-project-lite", + "static_assertions", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "ts_capabilityversion", + "ts_hexdump", + "ts_keys", + "ts_noise", + "zerocopy", ] [[package]] -name = "unic-ucd-version" -version = "0.9.0" +name = "ts_control_serde" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +checksum = "d5f55b206a0cc45d19e3988bbd0404847a2faabb2a738e1eabbb5f1b86f429a2" +dependencies = [ + "base64 0.22.1", + "chrono", + "ipnet", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "ts_capabilityversion", + "ts_keys", + "ts_nodecapability", + "ts_packetfilter_serde", + "url", +] + +[[package]] +name = "ts_dataplane" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c0f5fe22405153f26b8756dc3b8fc7c5215bee8c7581fb2385e2742b9fcfc33" +dependencies = [ + "bitrs", + "bytes", + "etherparse", + "tokio", + "tracing", + "ts_bart", + "ts_disco_protocol", + "ts_overlay_router", + "ts_packet", + "ts_packetfilter", + "ts_time", + "ts_transport", + "ts_tunnel", + "ts_underlay_router", +] + +[[package]] +name = "ts_derp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589539f8c80460eeb3ecaaa7364388f92f80c70b7bb9c4be935f4e7f42602c6f" +dependencies = [ + "bytes", + "crypto_box", + "futures", + "hex", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "ts_hexdump", + "ts_http_util", + "ts_keys", + "ts_packet", + "ts_tls_util", + "ts_transport", + "url", + "yoke", + "zerocopy", +] + +[[package]] +name = "ts_disco_protocol" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e955f5bcbf02122508fb739b5eb8ef71f4e0a68addb5a2ea4335291c1753e42" +dependencies = [ + "aead", + "crypto_box", + "num-derive", + "num-traits", + "thiserror 2.0.20", + "ts_hexdump", + "ts_keys", + "zerocopy", +] + +[[package]] +name = "ts_dynbitset" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "432baa58f36d2952f6b7e68eca0dfc73bf79f5094e5a4810a89405659bff59f0" +dependencies = [ + "smallvec", + "ts_bitset", +] + +[[package]] +name = "ts_hexdump" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd23f471c3e5650af8cb867296cc032f0a44bffe082ef0e720e31a0879dd1b9" +dependencies = [ + "heapless", +] + +[[package]] +name = "ts_http_util" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f8f3e6af0e3b8d4936007e504c9393cdc2aa294d6dbb4f7b5df65aaf9a7b0b9" +dependencies = [ + "bytes", + "futures", + "http", + "http-body-util", + "httparse", + "hyper", + "hyper-util", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "ts_tls_util", + "url", +] + +[[package]] +name = "ts_keys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f9107b36c5d9d41cb959ae2ad929a899be50d78739a0b6ed22ea7c5e39849" +dependencies = [ + "crypto_box", + "serde", + "thiserror 2.0.20", + "x25519-dalek", + "zerocopy", + "zeroize", +] + +[[package]] +name = "ts_netcheck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9914f88d06d8fe1f57a440e6c83a8425d8d1ef8faa5e760745894a33a083425b" +dependencies = [ + "bytes", + "dashmap", + "stun-rs", + "thiserror 2.0.20", + "tokio", + "tracing", + "ts_control", + "ts_derp", + "ts_http_util", + "url", +] + +[[package]] +name = "ts_netmon" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "716d439ad4af89a4c5b8368e08cc52045d5cccc6784f347a629228ef6a8b958f" +dependencies = [ + "cfg-if", + "flume", + "futures-util", + "ipnet", + "nix 0.31.3", + "pin-project-lite", + "rtnetlink", + "smallvec", + "socket2", + "tokio", + "tokio-stream", + "tracing", + "windows 0.62.2", +] + +[[package]] +name = "ts_netstack_smoltcp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b484bf448c728ae2c3d7f5b4e1b0b1a8c88a458659ca43dc4778e58efe86d3d" +dependencies = [ + "bytes", + "futures-util", + "tokio", + "tracing", + "ts_netstack_smoltcp_core", + "ts_netstack_smoltcp_socket", +] + +[[package]] +name = "ts_netstack_smoltcp_core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eccc25ea3f18e5ed247529afa6794ec4203e4dd0783dfa54f580fbc87587ab4" +dependencies = [ + "bytes", + "flume", + "heapless", + "smoltcp", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "ts_netstack_smoltcp_socket" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afaa4deb85447ca431bbd6dd7443ec4526fb3c8c6a92461bac5d22cc5b840161" +dependencies = [ + "bytes", + "tokio", + "tracing", + "ts_netstack_smoltcp_core", +] + +[[package]] +name = "ts_nodecapability" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "564be200f8f6dd1e8d6acb865a54430c3b76dff958de7ada55f5414d542c3313" +dependencies = [ + "cfg-if", + "serde", + "serde_json", +] + +[[package]] +name = "ts_noise" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112643c4c919c3786f52af85fb3300a42b9699270c9ecd6e52aff620ebb37c9a" +dependencies = [ + "aead", + "blake2", + "chacha20poly1305", + "hkdf", + "itertools", + "ts_keys", + "x25519-dalek", + "zerocopy", + "zeroize", +] + +[[package]] +name = "ts_overlay_router" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bddee3a4d2072b2c21b2b85762c4f917c96b4ead404e60a86c93dee5ad3bb1e" +dependencies = [ + "itertools", + "tracing", + "ts_bart", + "ts_packet", + "ts_transport", +] + +[[package]] +name = "ts_packet" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765444bb1b698fbb16df5aceb036ee5859d67425ecc70f9fcc1b15ac6e612e81" +dependencies = [ + "bytes", + "crypto_box", + "stable_deref_trait", + "ts_hexdump", + "yoke", +] + +[[package]] +name = "ts_packetfilter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eae69f0eb1ab1fee555669cb637a54e5086e90ec89456481d64ffeadc24634" +dependencies = [ + "hashbrown 0.17.1", + "ipnet", + "static_assertions", + "tracing", +] + +[[package]] +name = "ts_packetfilter_serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db575d1b453206bd6a391eb2c69cecb259826f9801a62657554b33f390bb91e" +dependencies = [ + "ipnet", + "nom", + "serde", + "serde_json", + "ts_nodecapability", + "ts_peercapability", +] + +[[package]] +name = "ts_packetfilter_state" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b694bbbcca9ada2d7293aeee51460644cf9e85ecdba8f6db6cad110d05b7ff82" +dependencies = [ + "ts_packetfilter", + "ts_packetfilter_serde", +] + +[[package]] +name = "ts_peercapability" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a10acc4f4b94891294c38cdde93f99f36639984d4890c7d2dd27969015c834d" +dependencies = [ + "serde", + "url", +] + +[[package]] +name = "ts_runtime" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f3e61236a08716aaf822e18fd4e2f1309f96ef59a36e7e69b6ba3883423977" +dependencies = [ + "bytes", + "futures", + "futures-util", + "ipnet", + "itertools", + "kameo", + "kameo_actors", + "rand 0.10.2", + "smallvec", + "smol_str", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "ts_bart", + "ts_bart_packetfilter", + "ts_control", + "ts_dataplane", + "ts_derp", + "ts_disco_protocol", + "ts_hexdump", + "ts_keys", + "ts_netcheck", + "ts_netmon", + "ts_netstack_smoltcp", + "ts_overlay_router", + "ts_packet", + "ts_packetfilter", + "ts_packetfilter_state", + "ts_transport", + "ts_tunnel", + "url", + "yoke", + "zerocopy", +] + +[[package]] +name = "ts_time" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390585318ae3a89c6e9147dc88efba0ec598c5b160d71973b7caadad3861df08" + +[[package]] +name = "ts_tls_util" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed822ccf5117feb72d06aee1277e7efbeeb4f9b74f14fa1e2e2e4c53bcbd5f41" +dependencies = [ + "tokio", + "tokio-rustls", + "tracing", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "ts_transport" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af54d6d3737550d01cae33cbe2fd407ed522992fad0eec7c218f07e2aeb0925" +dependencies = [ + "dyn-eq", + "dyn-hash", + "smallbox", + "ts_hexdump", + "ts_keys", + "ts_packet", +] + +[[package]] +name = "ts_tunnel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b499248cd70449a8d2a34b65aee776b2097bdf16234003b669f6c76245e1e" +dependencies = [ + "aead", + "blake2", + "chacha20poly1305", + "itertools", + "rand 0.10.2", + "tracing", + "ts_keys", + "ts_noise", + "ts_packet", + "ts_time", + "zerocopy", +] + +[[package]] +name = "ts_underlay_router" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0456aa35525eb9b6020189f8f338a31a3f368b36044ef2547adac8b211c19295" +dependencies = [ + "ts_packet", + "ts_transport", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.2", + "sha1 0.11.0", + "thiserror 2.0.20", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-parse" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ff81122fcbf4df4c1660b15f7e3336058e7aec14437c9f85c6b31a0f279b9" +dependencies = [ + "regex-lite", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" dependencies = [ "unic-common", ] @@ -4555,12 +6183,31 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4867,7 +6514,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4891,7 +6538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4947,11 +6594,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -4963,6 +6622,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4997,7 +6665,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5044,6 +6723,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5182,6 +6871,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5413,7 +7111,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5440,6 +7138,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "getrandom 0.4.3", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" @@ -5543,6 +7253,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5569,6 +7299,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/index.html b/index.html index b8182e8e..eb96c70e 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,15 @@ - + + + MonoCode diff --git a/package-lock.json b/package-lock.json index a231f3a8..109994ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,10 +32,12 @@ "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "cuelume": "^0.2.2", + "jsqr": "^1.4.0", "prettier": "^3.9.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-material-icon-theme": "^1.2.0", + "react-qr-code": "^2.2.0", "rehype-harden": "^1.1.8", "streamdown": "^2.5.0" }, @@ -4174,7 +4176,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -4203,6 +4204,12 @@ "node": ">=6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -4516,6 +4523,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5474,6 +5493,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -5637,6 +5665,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -5647,6 +5686,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -5668,6 +5713,12 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-material-icon-theme": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/react-material-icon-theme/-/react-material-icon-theme-1.2.0.tgz", @@ -5690,6 +5741,19 @@ } } }, + "node_modules/react-qr-code": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz", + "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qrcode-generator": "^2.0.4" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index b1af00e7..7e420700 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "cuelume": "^0.2.2", + "jsqr": "^1.4.0", "prettier": "^3.9.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-material-icon-theme": "^1.2.0", + "react-qr-code": "^2.2.0", "rehype-harden": "^1.1.8", "streamdown": "^2.5.0" }, diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd681..3745b938 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -3,5 +3,5 @@ /target/ # Generated by Tauri -# will have schema files for capabilities auto-completion -/gen/schemas +# /gen is regenerated by `tauri ios init` / Xcode; do not commit it. +/gen/ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 05a00aa7..be44a052 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,10 +25,13 @@ tauri-plugin-dialog = "2" rusqlite = { version = "0.40.2", features = ["bundled"], default-features = false } ureq = { version = "2.12.1", default-features = false, features = ["tls", "gzip"] } tauri-plugin-process = "2" +http = "1" +tokio-tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } +tokio = { version = "1.53.1", features = ["rt", "net", "sync", "time", "io-util", "macros"] } +futures-util = "0.3.34" [target.'cfg(unix)'.dependencies] libc = "0.2" - [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" objc2 = "0.6" @@ -39,6 +42,14 @@ raw-window-handle = "0.6" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" +# Embedded tailnet node (tailscale-rs, experimental). Desktop-only: the crate +# does not support iOS/Android, and iOS tailnet access requires the Tailscale +# app (packet-tunnel entitlement) regardless. +tailscale = "0.5" +# Explicit rustls provider for the embedded node: the tree enables multiple +# providers, so rustls cannot auto-pick one and the node panics on its first +# TLS connection. install_default() in tailnet_embed pins aws-lc-rs. +rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "aws-lc-rs"] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-window-state = "2.4.1" diff --git a/src-tauri/Info.ios.plist b/src-tauri/Info.ios.plist new file mode 100644 index 00000000..34bd7eac --- /dev/null +++ b/src-tauri/Info.ios.plist @@ -0,0 +1,25 @@ + + + + + + NSLocalNetworkUsageDescription + MonoCode connects to your paired desktop on the local network to run your coding sessions. + + NSCameraUsageDescription + MonoCode scans the pairing code on your Mac to connect. Video is processed on-device only. + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index a971b1e9..40191696 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,6 +1,92 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + fn main() { // generate_context! embeds icons; cargo ignores them unless we watch here. println!("cargo:rerun-if-changed=icons"); println!("cargo:rerun-if-changed=macos/Assets.car"); + println!("cargo:rerun-if-changed=tsnet/tsnet.go"); + println!("cargo:rerun-if-changed=tsnet/go.mod"); + println!("cargo:rerun-if-changed=tsnet/clangwrap-ios.sh"); + + let target = env::var("TARGET").unwrap_or_default(); + if target == "aarch64-apple-ios" { + build_tsnet_ios(); + } + tauri_build::build() } + +fn find_go() -> PathBuf { + if let Ok(path) = env::var("GO") { + return PathBuf::from(path); + } + for candidate in ["/opt/homebrew/bin/go", "/usr/local/go/bin/go", "/usr/local/bin/go"] + { + let path = PathBuf::from(candidate); + if path.exists() { + return path; + } + } + PathBuf::from("go") +} + +fn build_tsnet_ios() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let tsnet_dir = manifest_dir.join("tsnet"); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let archive = out_dir.join("libmonocode_tsnet.a"); + let wrap = tsnet_dir.join("clangwrap-ios.sh"); + + let go = find_go(); + let path = format!( + "/opt/homebrew/bin:/usr/local/go/bin:/usr/bin:{}", + env::var("PATH").unwrap_or_default() + ); + let status = Command::new(&go) + .current_dir(&tsnet_dir) + .env("PATH", &path) + .env("CGO_ENABLED", "1") + .env("GOOS", "ios") + .env("GOARCH", "arm64") + .env("CC", &wrap) + .env("CGO_CFLAGS", "-fPIC") + .args([ + "build", + "-tags", + "ios", + "-buildmode=c-archive", + "-ldflags=-w -s", + "-o", + ]) + .arg(&archive) + .status() + .expect("go is required to build the iPad tailnet node"); + if !status.success() { + panic!("go build -buildmode=c-archive failed for src-tauri/tsnet"); + } + + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=static=monocode_tsnet"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=Network"); + println!("cargo:rustc-link-lib=resolv"); + + // Xcode links libapp.a only. Copy the Go archive next to it and + // force-load via OTHER_LDFLAGS so the Go runtime constructor is kept. + let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".into()); + let externals = manifest_dir + .join("gen/apple/Externals/arm64") + .join(&profile); + let _ = std::fs::create_dir_all(&externals); + let dest = externals.join("libmonocode_tsnet.a"); + if let Err(error) = std::fs::copy(&archive, &dest) { + println!( + "cargo:warning=could not copy {} to {}: {error}", + archive.display(), + dest.display() + ); + } +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ab7b30ca..94102608 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,8 +16,6 @@ "core:window:allow-hide", "opener:default", "dialog:default", - "updater:default", - "process:default", - "window-state:default" + "process:default" ] } diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json new file mode 100644 index 00000000..5bfb4424 --- /dev/null +++ b/src-tauri/capabilities/desktop.json @@ -0,0 +1,8 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "desktop", + "description": "Desktop-only capabilities (updater, window-state). The iOS companion updates via the App Store and has no window chrome to restore.", + "platforms": ["macOS", "windows", "linux"], + "windows": ["*"], + "permissions": ["updater:default", "window-state:default"] +} diff --git a/src-tauri/src/companion_ws.rs b/src-tauri/src/companion_ws.rs new file mode 100644 index 00000000..35536257 --- /dev/null +++ b/src-tauri/src/companion_ws.rs @@ -0,0 +1,440 @@ +// Native WebSocket client for the companion (iPad / thin client). +// +// WKWebView loads the UI over https://tauri.localhost, so a JS `WebSocket` +// to ws:// is mixed content and is dropped with no useful error. +// Pairing then sits on "Connecting…" forever even though the Mac listener +// is fine. Dialing from Rust uses a real TCP socket and avoids that gate. +// +// The Tauri command itself must not await the socket: on iOS a spawned +// reader that re-enters AppHandle state can stall the command, so JS never +// sees onopen and reconnects forever. Handshake + read/write live in one +// spawned task; JS is notified with `companion-ws` events. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; + +const EVENT: &str = "companion-ws"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(12); +/// Slightly longer than the Go tsnet Dial timeout (8s) so the CGO error +/// surfaces instead of a leaked blocking task. +#[cfg(target_os = "ios")] +const TSNET_DIAL_TIMEOUT: Duration = Duration::from_secs(9); + +#[derive(Serialize, Clone)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum ClientEvent { + Open { + id: String, + }, + Message { + id: String, + data: String, + }, + Close { + id: String, + code: u16, + reason: String, + }, + Error { + id: String, + message: String, + }, +} + +enum Outgoing { + Text(String), + Close, +} + +struct Conn { + tx: UnboundedSender, +} + +type Table = HashMap; + +pub struct CompanionWs { + inner: Arc>, +} + +impl CompanionWs { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn table(&self) -> Arc> { + Arc::clone(&self.inner) + } +} + +fn lock(table: &Mutex) -> std::sync::MutexGuard<'_, Table> { + table.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn valid_id(id: &str) -> bool { + (8..=64).contains(&id.len()) && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// Tailnet CGNAT (100.64/10), Tailscale IPv6 (fd7a:115c:a1e0::/48), or MagicDNS. +#[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))] +pub fn is_tailnet_host(host: &str) -> bool { + let host = host.trim().trim_matches(['[', ']']); + if host.ends_with(".ts.net") || host.eq_ignore_ascii_case("ts.net") { + return true; + } + match host.parse::() { + Ok(std::net::IpAddr::V4(ip)) => { + let o = ip.octets(); + o[0] == 100 && (64..=127).contains(&o[1]) + } + Ok(std::net::IpAddr::V6(ip)) => { + let s = ip.segments(); + s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0 + } + Err(_) => false, + } +} + +/// Host/port for a `ws://` URL. `wss://` is rejected: LAN and the embedded +/// tailnet listener are both cleartext TCP (WireGuard covers the tailnet). +pub fn parse_ws_target(url: &str) -> Result<(String, u16), String> { + let uri: http::Uri = url + .parse() + .map_err(|error| format!("bad companion url: {error}"))?; + match uri.scheme_str() { + Some("ws") => {} + Some("wss") => { + return Err( + "wss:// is not used for LAN or tailnet TCP pairing — scan the LAN code or use the 6-digit code." + .into(), + ); + } + _ => return Err("companion url must be ws://".into()), + } + let host = uri + .host() + .ok_or_else(|| "companion url is missing a host".to_string())? + .to_string(); + let port = uri.port_u16().unwrap_or(80); + Ok((host, port)) +} + +fn take_conn(table: &Mutex
, id: &str) -> Option { + lock(table).remove(id) +} + +fn emit(app: &AppHandle, event: ClientEvent) { + let _ = app.emit(EVENT, event); +} + +/// Starts a connection. Resolves as soon as the task is spawned; `open` / +/// `error` / `close` arrive on `companion-ws`. +#[tauri::command] +pub fn companion_ws_open( + app: AppHandle, + state: State<'_, CompanionWs>, + id: String, + url: String, +) -> Result<(), String> { + if !valid_id(&id) { + return Err("bad companion socket id".into()); + } + let _ = parse_ws_target(&url)?; + let table = state.table(); + if let Some(previous) = take_conn(&table, &id) { + let _ = previous.tx.send(Outgoing::Close); + } + tauri::async_runtime::spawn(async move { + if let Err(error) = run_socket(app.clone(), table, id.clone(), url).await { + emit( + &app, + ClientEvent::Error { + id: id.clone(), + message: error.clone(), + }, + ); + emit( + &app, + ClientEvent::Close { + id, + code: 1006, + reason: error, + }, + ); + } + }); + Ok(()) +} + +enum CompanionIo { + Tcp(TcpStream), + #[cfg(target_os = "ios")] + Tsnet(tokio::net::UnixStream), +} + +impl AsyncRead for CompanionIo { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + CompanionIo::Tcp(stream) => std::pin::Pin::new(stream).poll_read(cx, buf), + #[cfg(target_os = "ios")] + CompanionIo::Tsnet(stream) => std::pin::Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for CompanionIo { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.get_mut() { + CompanionIo::Tcp(stream) => std::pin::Pin::new(stream).poll_write(cx, buf), + #[cfg(target_os = "ios")] + CompanionIo::Tsnet(stream) => std::pin::Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + CompanionIo::Tcp(stream) => std::pin::Pin::new(stream).poll_flush(cx), + #[cfg(target_os = "ios")] + CompanionIo::Tsnet(stream) => std::pin::Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + CompanionIo::Tcp(stream) => std::pin::Pin::new(stream).poll_shutdown(cx), + #[cfg(target_os = "ios")] + CompanionIo::Tsnet(stream) => std::pin::Pin::new(stream).poll_shutdown(cx), + } + } +} + +async fn connect_companion(host: &str, port: u16) -> Result { + #[cfg(target_os = "ios")] + if is_tailnet_host(host) { + let addr = format!("{host}:{port}"); + eprintln!("companion: tailnet dial {addr}"); + match tokio::time::timeout( + TSNET_DIAL_TIMEOUT, + tauri::async_runtime::spawn_blocking(move || crate::tsnet_mobile::dial_blocking(&addr)), + ) + .await + { + Ok(Ok(Ok(stream))) => { + eprintln!("companion: tailnet dial ok {host}:{port}"); + return Ok(CompanionIo::Tsnet(stream)); + } + Ok(Ok(Err(tsnet_err))) => { + eprintln!("companion: tailnet dial failed {host}:{port}: {tsnet_err}"); + // Short OS TCP try: a system Tailscale VPN still works. + // Do not wait the full handshake timeout here — 100.x is + // unroutable on iOS without that VPN, and pairing then + // sits on "Connecting…" instead of falling back to LAN. + match tokio::time::timeout(Duration::from_secs(2), TcpStream::connect((host, port))) + .await + { + Ok(Ok(stream)) => { + let _ = stream.set_nodelay(true); + return Ok(CompanionIo::Tcp(stream)); + } + _ => return Err(tsnet_err), + } + } + Ok(Err(error)) => return Err(error.to_string()), + Err(_) => { + eprintln!("companion: tailnet dial timed out {host}:{port}"); + return Err(format!( + "timed out connecting over tailnet to {host}:{port}" + )); + } + } + } + + let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port))) + .await + .map_err(|_| format!("timed out connecting to {host}:{port}"))? + .map_err(|error| format!("could not reach {host}:{port}: {error}"))?; + let _ = stream.set_nodelay(true); + Ok(CompanionIo::Tcp(stream)) +} + +async fn run_socket( + app: AppHandle, + table: Arc>, + id: String, + url: String, +) -> Result<(), String> { + let (host, port) = parse_ws_target(&url)?; + let stream = connect_companion(&host, port).await?; + let request = url + .into_client_request() + .map_err(|error| format!("bad companion url: {error}"))?; + let (ws, _response) = tokio::time::timeout( + CONNECT_TIMEOUT, + tokio_tungstenite::client_async(request, stream), + ) + .await + .map_err(|_| format!("timed out completing websocket handshake with {host}:{port}"))? + .map_err(|error| format!("websocket handshake failed: {error}"))?; + + let (mut sink, mut incoming) = ws.split(); + let (tx, mut mailbox) = unbounded_channel::(); + lock(&table).insert(id.clone(), Conn { tx }); + emit(&app, ClientEvent::Open { id: id.clone() }); + + loop { + tokio::select! { + frame = mailbox.recv() => { + match frame { + Some(Outgoing::Text(text)) => { + if sink.send(Message::Text(text.into())).await.is_err() { + break; + } + } + Some(Outgoing::Close) | None => { + let _ = sink.close().await; + break; + } + } + } + message = incoming.next() => { + match message { + Some(Ok(Message::Text(text))) => { + emit( + &app, + ClientEvent::Message { + id: id.clone(), + data: text.to_string(), + }, + ); + } + Some(Ok(Message::Close(frame))) => { + let (code, reason) = match frame { + Some(frame) => (u16::from(frame.code), frame.reason.to_string()), + None => (1000, String::new()), + }; + emit( + &app, + ClientEvent::Close { + id: id.clone(), + code, + reason, + }, + ); + break; + } + Some(Err(error)) => { + let message = error.to_string(); + emit( + &app, + ClientEvent::Error { + id: id.clone(), + message: message.clone(), + }, + ); + emit( + &app, + ClientEvent::Close { + id: id.clone(), + code: 1006, + reason: message, + }, + ); + break; + } + Some(Ok(_)) => {} + None => break, + } + } + } + } + let _ = take_conn(&table, &id); + Ok(()) +} + +#[tauri::command] +pub fn companion_ws_send( + state: State<'_, CompanionWs>, + id: String, + data: String, +) -> Result<(), String> { + let table = state.table(); + let live = lock(&table); + let conn = live + .get(&id) + .ok_or_else(|| "companion socket is not open".to_string())?; + conn.tx + .send(Outgoing::Text(data)) + .map_err(|_| "companion socket is not open".to_string()) +} + +#[tauri::command] +pub fn companion_ws_close(state: State<'_, CompanionWs>, id: String) { + if let Some(conn) = take_conn(&state.table(), &id) { + let _ = conn.tx.send(Outgoing::Close); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ws_target_reads_host_and_port() { + let (host, port) = + parse_ws_target("ws://192.168.4.191:17233/v1/connect?token=abc&v=1").unwrap(); + assert_eq!(host, "192.168.4.191"); + assert_eq!(port, 17233); + } + + #[test] + fn parse_ws_target_rejects_http_and_wss() { + assert!(parse_ws_target("http://192.168.4.191:17233/").is_err()); + assert!(parse_ws_target("wss://mac.tail.ts.net:443/v1/connect").is_err()); + assert!(parse_ws_target("file:///tmp").is_err()); + } + + #[test] + fn tailnet_hosts() { + assert!(is_tailnet_host("100.119.157.61")); + assert!(is_tailnet_host("100.64.0.1")); + assert!(is_tailnet_host("mac.tail9a5.ts.net")); + assert!(is_tailnet_host("[fd7a:115c:a1e0::1]")); + assert!(!is_tailnet_host("192.168.4.191")); + assert!(!is_tailnet_host("10.0.0.1")); + assert!(!is_tailnet_host("127.0.0.1")); + assert!(!is_tailnet_host("example.com")); + } + + #[test] + fn socket_ids_are_bounded() { + assert!(valid_id("abcd-efgh-ijkl")); + assert!(!valid_id("short")); + assert!(!valid_id(&"a".repeat(65))); + assert!(!valid_id("has space!!")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c66bbdd..4ce29b0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ use tauri::Manager; mod checkpoint; +mod companion_ws; mod cursor_store; mod fs; mod harness; @@ -13,9 +14,16 @@ mod notes; mod project_logo; mod pty; mod rate_limits; +mod remote; +mod remote_dispatch; +mod remote_server; mod search; mod session_store; mod skills; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod tailnet_embed; +#[cfg(target_os = "ios")] +mod tsnet_mobile; mod window; mod window_transfer; @@ -129,19 +137,33 @@ fn open_new_window(app: tauri::AppHandle) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let app = tauri::Builder::default() + // Updater + window-state are desktop-only crates (window-state is an + // empty crate on mobile; updater isn't even a dependency there), so they + // stay out of the mobile plugin set. The companion updates via the App + // Store and has no window chrome to restore. + let builder = tauri::Builder::default() .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_dialog::init()); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let builder = builder + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_window_state::Builder::default().build()); + let builder = builder .manage(harness::HarnessHost::new()) .manage(pty::PtyHost::new()) + .manage(remote::RemoteState::new()) + .manage(companion_ws::CompanionWs::new()) .manage(window_transfer::WindowTransferState::new()) .setup(|app| { harness::reap_orphaned_harness_processes(); session_store::init(app.handle())?; checkpoint::init(app.handle())?; + remote::autostart(app.handle()); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + crate::tailnet_embed::autostart(app.handle()); + #[cfg(target_os = "ios")] + crate::tsnet_mobile::autostart(app.handle()); menu::install(app.handle())?; #[cfg(target_os = "macos")] { @@ -150,7 +172,7 @@ pub fn run() { macos::install(&window); } } - #[cfg(not(target_os = "macos"))] + #[cfg(all(not(target_os = "macos"), desktop))] { if let Some(window) = app.get_webview_window("main") { let _ = window.set_decorations(false); @@ -158,10 +180,14 @@ pub fn run() { } } Ok(()) - }) - .on_menu_event(|app, event| { - menu::dispatch(app, event.id().as_ref()); - }) + }); + // Native menus do not exist on mobile; the companion reaches every + // action through the touch chrome instead. + #[cfg(desktop)] + let builder = builder.on_menu_event(|app, event| { + menu::dispatch(app, event.id().as_ref()); + }); + let app = builder .invoke_handler(tauri::generate_handler![ default_cwd, home_dir, @@ -243,6 +269,25 @@ pub fn run() { harness::harness_sse_close, harness::harness_exec, rate_limits::fetch_claude_usage, + remote::remote_status, + remote::remote_enable, + remote::remote_set_route, + remote::remote_set_system_tailscale, + remote::remote_disable, + remote::remote_pairing, + remote::remote_pairing_code, + remote::remote_tailnet, + remote::remote_serve_on, + remote::remote_serve_off, + remote::remote_serve_status, + remote::remote_embed_status, + remote::remote_embed_start, + remote::remote_embed_stop, + remote::remote_embed_logout, + remote::remote_peers, + companion_ws::companion_ws_open, + companion_ws::companion_ws_send, + companion_ws::companion_ws_close, pty::pty_spawn, pty::pty_write, pty::pty_resize, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index cedb9db7..6a127d5b 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,8 +1,10 @@ #[cfg(target_os = "macos")] use tauri::menu::{AboutMetadata, Menu, MenuItemBuilder, SubmenuBuilder}; +use tauri::AppHandle; +#[cfg(desktop)] +use tauri::Emitter; #[cfg(target_os = "macos")] use tauri::Wry; -use tauri::{AppHandle, Emitter}; pub fn install(app: &AppHandle) -> tauri::Result<()> { #[cfg(target_os = "macos")] @@ -11,6 +13,8 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// Desktop-only: mobile has no menu events, so nothing dispatches here. +#[cfg(desktop)] pub fn dispatch(app: &AppHandle, id: &str) { match id { "new_window" => { diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs new file mode 100644 index 00000000..0e43f0f0 --- /dev/null +++ b/src-tauri/src/remote.rs @@ -0,0 +1,1586 @@ +// Companion link (thin iPad client) — host-side pairing, token store, +// Tailscale tailnet status, and `tailscale serve` lifecycle. +// +// MERGE NOTE (upstream-friendly): this module is self-contained and only +// *calls* existing commands — it changes none of them. Desktop behavior is +// untouched until the user enables the companion link in Settings. +// +// LAN and Tailscale can both be live at once: the TCP listener is always +// 0.0.0.0, and the iPad keeps both hosts so it can switch without re-pairing. +// Tailscale path: each app embeds a userspace node and signs in with Google. +// The system Tailscale app is optional on the Mac (`systemTailscale`). + +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, Manager, State}; + +/// Default host port. Mirrors COMPANION_PORT_DEFAULT in protocol.ts. +pub const COMPANION_PORT_DEFAULT: u16 = 17233; +/// Protocol version. Mirrors COMPANION_PROTO_VERSION in protocol.ts. +pub const COMPANION_PROTO_VERSION: u32 = 1; +/// WebSocket path. Mirrors COMPANION_WS_PATH in protocol.ts. +pub const COMPANION_WS_PATH: &str = "/v1/connect"; + +const TOKEN_DIR: &str = "companion"; +const TOKEN_FILE: &str = "token"; +const TOKEN_BYTES: usize = 32; + +/// Commands the companion link may forward. Mirrors the inverse of +/// LOCAL_ONLY_COMMANDS in protocol.ts: window chrome stays host-local, +/// everything below is safe to serve remotely. The WS server enforces this. +pub const REMOTE_COMMAND_ALLOWLIST: &[&str] = &[ + // Agent harnesses (spawning stays on the host; the iPad never runs CLIs). + "harness_resolve_cursor", + "harness_resolve_codex", + "harness_resolve_opencode", + "harness_resolve_claude", + "harness_resolve_pi", + "harness_resolve_omp", + "harness_resolve_fx", + "harness_resolve_grok", + "harness_free_port", + "harness_spawn", + "harness_write", + "harness_kill", + "harness_kill_all", + "harness_http", + "harness_sse_open", + "harness_sse_close", + "harness_exec", + // Terminals. + "pty_spawn", + "pty_write", + "pty_resize", + "pty_status", + "pty_kill", + "pty_kill_all", + // Sessions / workspace. + "session_upsert", + "session_list_by_project", + "session_search", + "session_get", + "session_delete", + "session_set_archived", + "session_set_pinned", + "session_set_in_flight", + "session_list_in_flight", + "session_take_in_flight", + "workspace_set_snapshot", + "workspace_get_snapshot", + // Filesystem / git. + "list_dir", + "list_project_files", + "git_diff_stats", + "git_diff_index", + "git_diff_files", + "git_file_diff", + "git_history", + "git_commit_files", + "git_commit_file_diff", + "git_stage_file", + "git_stage_contents", + "git_unstage_file", + "git_discard_file", + "git_discard_all", + "git_stage_all", + "git_unstage_all", + "git_commit", + "git_staged_context", + "git_push", + "git_pull", + "git_sync", + "git_range_context", + "git_pr_status", + "git_pr_create", + "git_github_repo", + "git_github_work_items", + "git_github_work_item_details", + "git_github_work_item_thread", + "git_github_work_item_comment", + "git_github_pr_diff", + "git_branches", + "git_checkout", + "git_create_branch", + "git_stash", + "create_path", + "rename_path", + "delete_path", + "copy_path", + "move_path", + "reveal_path", + "clone_repo", + "read_file_preview", + "stat_files", + "inspect_paths", + "read_file_base64", + "read_binary_file", + "write_attachment", + "read_text_file", + "write_text_file", + // Search / skills / misc host data. + "search_project", + "list_skills", + "cursor_tool_calls", + "fetch_claude_usage", + "fetch_inbox_media", + "linear_status", + "linear_set_token", + "linear_list_teams", + "linear_list_issues", + "linear_issue_details", + "linear_issue_thread", + "linear_issue_comment", + "notes_list", + "notes_get", + "notes_upsert", + "notes_delete", + "session_checkpoint_ensure", + "session_checkpoint_prepare", + "session_checkpoint_capture", + "session_checkpoint_status", + "session_checkpoint_file_diff", + "session_checkpoint_undo", + "session_checkpoint_keep", + "save_project_logo", + "remove_project_logo", + "default_cwd", + "home_dir", + "remote_peers", + // Read-only node state (the iPad pairing screen benefits later too). + // Start/stop stay host-only. remote_status lets a paired iPad learn + // the other live route (LAN vs Tailscale) without re-pairing. + // Embed start/stop/status stay on each device (iPad tsnet is local). + "remote_status", + "stage_window_transfer", + "take_window_transfer", +]; + +/// Future WS server gate: unknown/new commands default to denied until they +/// are reviewed into the list above. +pub fn is_remote_command(command: &str) -> bool { + REMOTE_COMMAND_ALLOWLIST.contains(&command) +} + +/// One companion route. LAN and Tailscale can both be on; the TCP listener +/// is always `0.0.0.0` so LAN keeps working while the embedded node is up. +#[derive(Serialize, serde::Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RemoteMode { + #[default] + Tailscale, + Lan, +} + +struct RemoteConfig { + enabled: bool, + lan: bool, + tailscale: bool, + /// Also run `tailscale serve` through the system Tailscale app. Off by + /// default — the embedded node is the Google-login path. + system_tailscale: bool, + port: u16, + token: Option, + server: Option, + forwarding_registered: bool, + embed_task: Option>, + embed_snapshot: EmbedSnapshot, + pair_code: Option, +} + +/// Short-lived 6-digit claim code for easy manual pairing. The code itself +/// never grants session access: it is single-use and only exchanges for the +/// real pairing token over the same TLS/trusted link. Brute force is +/// pointless — a handful of wrong guesses burns the code. +struct PairingCode { + code: String, + expires_at: std::time::SystemTime, + attempts: u8, +} + +const PAIR_CODE_TTL_SECS: u64 = 600; +const PAIR_CODE_MAX_ATTEMPTS: u8 = 10; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PairingCodeView { + /// Displayed grouped as "123 456". + pub code: String, + /// Seconds until expiry (capped, informational). + pub expires_in: u64, +} + +/// A running accept loop. Aborted on disable / port change / re-enable. +/// Uses the Tauri runtime handle (not tokio's) so it can be spawned from +/// setup(), which runs outside the async runtime on the main thread. +struct ServerHandle { + port: u16, + task: tauri::async_runtime::JoinHandle<()>, +} + +pub struct RemoteState { + inner: Mutex, + shared: std::sync::Arc, +} + +impl RemoteState { + pub fn new() -> Self { + Self { + inner: Mutex::new(RemoteConfig { + enabled: false, + lan: false, + tailscale: false, + system_tailscale: false, + port: COMPANION_PORT_DEFAULT, + token: None, + server: None, + forwarding_registered: false, + embed_task: None, + embed_snapshot: EmbedSnapshot::default(), + pair_code: None, + }), + shared: std::sync::Arc::new(crate::remote_server::ServerShared::new()), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, RemoteConfig> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Companion listener port (shared by the TCP and embedded listeners). + /// Desktop-only today (mobile shells never serve); the method stays + /// un-gated so a future mobile use needs no refactor. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn companion_port(&self) -> u16 { + self.lock().port + } + + /// Pairing token, generating and persisting it on first use. + pub fn pairing_token(&self, app: &AppHandle) -> Result { + let mut config = self.lock(); + match &config.token { + Some(token) => Ok(token.clone()), + None => { + let token = load_or_create_token(app)?; + config.token = Some(token.clone()); + Ok(token) + } + } + } + + pub fn companion_shared(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.shared) + } + + /// Hosts the iPad should remember for this pairing. Only currently + /// enabled routes are advertised, so a LAN-only link never plants a + /// stale tailnet name as the fallback. + pub fn advertised_hosts(&self) -> AdvertisedHosts { + let (lan, tailscale, system, embed_ip) = { + let config = self.lock(); + ( + config.lan, + config.tailscale, + config.system_tailscale, + config.embed_snapshot.tailnet_ip.clone(), + ) + }; + AdvertisedHosts { + lan_ip: if lan { lan_ip() } else { None }, + tailnet_host: if tailscale { + embed_ip.filter(|ip| !ip.is_empty()).or_else(|| { + // MagicDNS from the system client is only useful when + // that client is also serving the companion port. + if system { + tailnet_hostname() + } else { + None + } + }) + } else { + None + }, + } + } + + /// Swap the embedded-node task, returning the previous one to abort. + pub fn embed_replace_task( + &self, + task: tauri::async_runtime::JoinHandle<()>, + ) -> Option> { + self.lock().embed_task.replace(task) + } + + pub fn embed_take_task(&self) -> Option> { + self.lock().embed_task.take() + } + + pub fn embed_snapshot(&self) -> EmbedSnapshot { + self.lock().embed_snapshot.clone() + } + + pub fn embed_update_snapshot(&self, snapshot: EmbedSnapshot) { + self.lock().embed_snapshot = snapshot; + } +} + +/// Status of the embedded tailnet node. Same shape on Mac (tailscale-rs) +/// and iPad (userspace tsnet) so the Companion page can share one UI. +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct EmbedSnapshot { + pub running: bool, + pub authorized: bool, + pub tailnet_ip: Option, + /// Browser URL for interactive (Google SSO) authorization. + pub login_url: Option, + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tailnet_name: Option, + /// Tailscale node hostname (this Mac or this iPad). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RemoteStatus { + pub enabled: bool, + pub lan: bool, + pub tailscale: bool, + /// Preferred label when a caller still thinks in one mode: Tailscale if + /// that path is on, otherwise LAN. Both flags can be true at once. + pub mode: RemoteMode, + pub port: u16, + /// Protocol version the host speaks. + pub version: u32, + pub lan_ip: Option, + pub tailnet_host: Option, + /// True when the host also forwards through the system Tailscale app. + #[serde(default)] + pub system_tailscale: bool, +} + +#[derive(Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct AdvertisedHosts { + pub lan_ip: Option, + pub tailnet_host: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RemotePairing { + pub port: u16, + pub token: String, + /// Best-effort LAN IP for the QR payload (Tailscale users type their + /// tailnet hostname instead). Null when it cannot be determined. + pub lan_ip: Option, + /// Best-effort MagicDNS name (`machine.tailnet.ts.net`) when the Tailscale + /// CLI is installed and logged in. Prefill for the `tailscale serve` path: + /// `tailscale serve --bg --https=443 http://localhost:`. + pub tailnet_host: Option, + pub version: u32, +} + +#[tauri::command] +pub fn remote_status(state: State) -> RemoteStatus { + let hosts = state.advertised_hosts(); + let config = state.lock(); + RemoteStatus { + enabled: config.enabled, + lan: config.lan, + tailscale: config.tailscale, + mode: if config.tailscale { + RemoteMode::Tailscale + } else { + RemoteMode::Lan + }, + port: config.port, + version: COMPANION_PROTO_VERSION, + lan_ip: hosts.lan_ip, + tailnet_host: hosts.tailnet_host, + system_tailscale: config.system_tailscale, + } +} + +/// Turn a route on (or, with no mode, both). Does not disable the other +/// route. Persist the pairing token, bind the listener, start Tailscale +/// when that path is live. +#[tauri::command] +pub async fn remote_enable( + app: AppHandle, + state: State<'_, RemoteState>, + port: Option, + mode: Option, +) -> Result { + if let Some(port) = port { + if port == 0 { + return Err("Port must be non-zero".into()); + } + } + // Resolve token + port without holding the lock across awaits. + let (port, token, restart, lan, tailscale) = { + let mut config = state.lock(); + if let Some(port) = port { + config.port = port; + } + match mode { + Some(RemoteMode::Lan) => config.lan = true, + Some(RemoteMode::Tailscale) => config.tailscale = true, + None if !config.lan && !config.tailscale => { + config.lan = true; + config.tailscale = true; + } + None => {} + } + let token = match &config.token { + Some(token) => token.clone(), + None => { + let token = load_or_create_token(&app)?; + config.token = Some(token.clone()); + token + } + }; + config.enabled = config.lan || config.tailscale; + let restart = config.server.as_ref().map(|s| s.port) != Some(config.port); + (config.port, token, restart, config.lan, config.tailscale) + }; + write_enabled(&app, Some((port, lan, tailscale)))?; + + if restart { + start_server(&app, &state, port, &token).await?; + } + + apply_routes(&app, &state, port, tailscale).await; + + Ok(RemotePairing { + port, + token, + lan_ip: lan_ip(), + tailnet_host: tailnet_hostname(), + version: COMPANION_PROTO_VERSION, + }) +} + +/// Turn one route on or off without dropping the other. Disabling the last +/// live route tears the whole link down. +#[tauri::command] +pub async fn remote_set_route( + app: AppHandle, + state: State<'_, RemoteState>, + route: RemoteMode, + enabled: bool, +) -> Result { + if enabled { + return remote_enable(app, state, None, Some(route)).await; + } + let (port, token, lan, tailscale, empty) = { + let mut config = state.lock(); + match route { + RemoteMode::Lan => config.lan = false, + RemoteMode::Tailscale => config.tailscale = false, + } + config.enabled = config.lan || config.tailscale; + let token = config.token.clone().unwrap_or_default(); + ( + config.port, + token, + config.lan, + config.tailscale, + !config.enabled, + ) + }; + if empty { + remote_disable(app, state).await?; + return Ok(RemotePairing { + port, + token, + lan_ip: lan_ip(), + tailnet_host: tailnet_hostname(), + version: COMPANION_PROTO_VERSION, + }); + } + write_enabled(&app, Some((port, lan, tailscale)))?; + apply_routes(&app, &state, port, tailscale).await; + Ok(RemotePairing { + port, + token, + lan_ip: lan_ip(), + tailnet_host: tailnet_hostname(), + version: COMPANION_PROTO_VERSION, + }) +} + +async fn apply_routes(app: &AppHandle, state: &RemoteState, port: u16, tailscale: bool) { + let system = state.lock().system_tailscale; + if tailscale { + start_embedded_best_effort(app).await; + if system { + serve_best_effort(port, true).await; + } + } else { + stop_embedded(app, state).await; + if system { + serve_best_effort(port, false).await; + } + } +} + +/// Bind with retries on AddrInUse: a previous instance's socket can still +/// be draining when the app restarts quickly. +async fn bind_with_retry(port: u16) -> Result { + let mut attempt = 0; + loop { + match tokio::net::TcpListener::bind(("0.0.0.0", port)).await { + Ok(listener) => return Ok(listener), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse && attempt < 8 => { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + Err(error) => { + return Err(format!("Failed to listen on port {port}: {error}")); + } + } + } +} + +/// Bind the listener and start the accept loop, replacing any previous one. +/// Shared by `remote_enable` and boot-time autostart. +async fn start_server( + app: &AppHandle, + state: &RemoteState, + port: u16, + token: &str, +) -> Result<(), String> { + // 0.0.0.0 so direct-LAN companions and `tailscale serve` (which dials + // localhost) both reach it. Every frame still needs the token. + // Retried: a previous instance's socket can still be draining when the + // app restarts quickly (or two windows race at boot). + let listener = bind_with_retry(port).await?; + let task = tauri::async_runtime::spawn(crate::remote_server::run( + app.clone(), + listener, + token.to_string(), + state.companion_shared(), + )); + let mut config = state.lock(); + if let Some(previous) = config.server.take() { + previous.task.abort(); + } + if !config.forwarding_registered { + config.forwarding_registered = true; + crate::remote_server::register_event_forwarding(app, &state.companion_shared()); + } + config.server = Some(ServerHandle { port, task }); + Ok(()) +} + +/// Best-effort embedded node start for link enable. Saved keys/auth carry +/// over, so after the first Google login this is silent. Never fails. +async fn start_embedded_best_effort(app: &AppHandle) { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = app; + return; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if let Err(error) = crate::tailnet_embed::start(app.clone(), None, Vec::new()).await { + eprintln!("companion: embedded node did not start: {error}"); + } + } +} + +/// Best-effort embedded node stop: abort the task, reset the snapshot, and +/// clear the boot flag so a stopped link stays stopped. Never fails. +async fn stop_embedded(app: &AppHandle, state: &RemoteState) { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (app, state); + return; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if let Some(task) = state.embed_take_task() { + task.abort(); + } + crate::tailnet_embed::set_wanted(app, false); + state.embed_update_snapshot(EmbedSnapshot::default()); + } +} + +/// Best-effort `tailscale serve` management for link enable/disable. +/// No CLI, or a CLI that refuses — both fine, LAN pairing is unaffected. +async fn serve_best_effort(port: u16, on: bool) { + let args: Vec = if on { + vec![ + "serve".into(), + "--bg".into(), + format!("--tcp={port}"), + format!("tcp://localhost:{port}"), + ] + } else { + vec!["serve".into(), format!("--tcp={port}"), "off".into()] + }; + let owned = args.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + tailscale_cli(&refs, STD_CLI_TIMEOUT) + }) + .await; + match result { + Ok(Some(output)) if output.status.success() => {} + Ok(Some(output)) => eprintln!("companion: tailscale serve: {}", serve_error(&output)), + Ok(None) => {} + Err(error) => eprintln!("companion: tailscale serve: {error}"), + } +} + +const ENABLED_FILE: &str = "enabled"; +fn enabled_path(app: &AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(data_dir.join(TOKEN_DIR).join(ENABLED_FILE)) +} + +/// Stored as `port:routes` (`17233:lan,tailscale`). A bare port, or the +/// legacy `17233:tailscale` / `17233:lan` tokens, still load. +fn parse_route_flags(raw: Option<&str>) -> (bool, bool) { + let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { + return (false, true); + }; + if raw.eq_ignore_ascii_case("both") { + return (true, true); + } + let lan = raw.split(',').any(|part| part.trim().eq_ignore_ascii_case("lan")); + let tailscale = raw + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case("tailscale")); + if !lan && !tailscale { + return (false, true); + } + (lan, tailscale) +} + +fn format_route_flags(lan: bool, tailscale: bool) -> String { + match (lan, tailscale) { + (true, true) => "lan,tailscale".into(), + (true, false) => "lan".into(), + (false, true) => "tailscale".into(), + (false, false) => String::new(), + } +} + +fn read_enabled(app: &AppHandle) -> Option<(u16, bool, bool)> { + let raw = std::fs::read_to_string(enabled_path(app).ok()?).ok()?; + let (port_raw, routes_raw) = match raw.trim().split_once(':') { + Some((port, routes)) => (port, Some(routes)), + None => (raw.trim(), None), + }; + let port: u16 = port_raw.parse().ok().filter(|port| *port != 0)?; + let (lan, tailscale) = parse_route_flags(routes_raw); + if !lan && !tailscale { + return None; + } + Some((port, lan, tailscale)) +} + +fn write_enabled( + app: &AppHandle, + enabled: Option<(u16, bool, bool)>, +) -> Result<(), String> { + let path = enabled_path(app)?; + match enabled { + Some((port, lan, tailscale)) => { + let routes = format_route_flags(lan, tailscale); + std::fs::write(&path, format!("{port}:{routes}")).map_err(|e| e.to_string()) + } + None => { + let _ = std::fs::remove_file(&path); + Ok(()) + } + } +} + +const SYSTEM_FILE: &str = "system"; + +fn system_path(app: &AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(data_dir.join(TOKEN_DIR).join(SYSTEM_FILE)) +} + +fn read_system(app: &AppHandle) -> bool { + let Ok(path) = system_path(app) else { + return false; + }; + let Ok(raw) = std::fs::read_to_string(path) else { + return false; + }; + let trimmed = raw.trim(); + trimmed == "1" || trimmed.eq_ignore_ascii_case("true") +} + +fn write_system(app: &AppHandle, on: bool) -> Result<(), String> { + let path = system_path(app)?; + if on { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, "1\n").map_err(|e| e.to_string()) + } else { + let _ = std::fs::remove_file(&path); + Ok(()) + } +} + +/// Opt-in: also expose the companion port through the system Tailscale app +/// (`tailscale serve`). The embedded Google-login node stays the default. +#[tauri::command] +pub async fn remote_set_system_tailscale( + app: AppHandle, + state: State<'_, RemoteState>, + enabled: bool, +) -> Result { + { + let mut config = state.lock(); + config.system_tailscale = enabled; + } + write_system(&app, enabled)?; + let (port, tailscale) = { + let config = state.lock(); + (config.port, config.tailscale) + }; + if tailscale { + serve_best_effort(port, enabled).await; + } + Ok(remote_status(state)) +} + +/// Boot-time autostart: if the link was enabled when the app last ran, +/// bring the listener back in the same mode without opening Settings. +pub fn autostart(app: &AppHandle) { + let system = read_system(app); + { + let state: State<'_, RemoteState> = app.state(); + state.lock().system_tailscale = system; + } + let Some((port, lan, tailscale)) = read_enabled(app) else { + return; + }; + let Ok(token) = load_or_create_token(app) else { + return; + }; + let state: State<'_, RemoteState> = app.state(); + { + let mut config = state.lock(); + config.enabled = true; + config.port = port; + config.lan = lan; + config.tailscale = tailscale; + config.system_tailscale = read_system(&app); + config.token = Some(token.clone()); + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state: State<'_, RemoteState> = app.state(); + if let Err(error) = start_server(&app, &state, port, &token).await { + eprintln!("companion autostart failed: {error}"); + return; + } + apply_routes(&app, &state, port, tailscale).await; + }); +} + +/// Connected companion count, for Settings ("1 iPad connected") and +/// headless verification. +#[tauri::command] +pub fn remote_peers(state: State) -> PeerCount { + PeerCount { + connected: state.companion_shared().peer_count(), + } +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PeerCount { + pub connected: usize, +} + +#[tauri::command] +pub async fn remote_disable(app: AppHandle, state: State<'_, RemoteState>) -> Result<(), String> { + let (port, system) = { + let config = state.lock(); + (config.port, config.system_tailscale) + }; + stop_embedded(&app, &state).await; + { + let mut config = state.lock(); + config.enabled = false; + config.lan = false; + config.tailscale = false; + // Disabling burns the manual code too — a stale code must never + // outlive the link it was generated for. + config.pair_code = None; + if let Some(server) = config.server.take() { + server.task.abort(); + } + } + write_enabled(&app, None)?; + if system { + serve_best_effort(port, false).await; + } + Ok(()) +} + +/// Pairing payload for the Settings QR screen. Does not flip `enabled` by +/// itself; the link only serves once `remote_enable` has run. +#[tauri::command] +pub fn remote_pairing(app: AppHandle, state: State) -> Result { + let mut config = state.lock(); + let token = match &config.token { + Some(token) => token.clone(), + None => { + let token = load_or_create_token(&app)?; + config.token = Some(token.clone()); + token + } + }; + Ok(RemotePairing { + port: config.port, + token, + lan_ip: lan_ip(), + tailnet_host: tailnet_hostname(), + version: COMPANION_PROTO_VERSION, + }) +} + +/// Mint (or re-mint) the 6-digit manual pairing code. Shown big on the +/// host; typed on the iPad instead of the full token. Single-use, expiring, +/// attempt-limited — see PairingCode. +#[tauri::command] +pub fn remote_pairing_code(state: State) -> Result { + if !state.lock().enabled { + return Err("Enable the companion link first.".into()); + } + let code = mint_pair_digits()?; + let view = PairingCodeView { + code: format!("{} {}", &code[..3], &code[3..]), + expires_in: PAIR_CODE_TTL_SECS, + }; + state.lock().pair_code = Some(PairingCode { + code, + expires_at: std::time::SystemTime::now() + + std::time::Duration::from_secs(PAIR_CODE_TTL_SECS), + attempts: 0, + }); + Ok(view) +} + +fn mint_pair_digits() -> Result { + let mut bytes = [0u8; 6]; + read_secure_random(&mut bytes).map_err(|_| "No random source".to_string())?; + Ok(bytes.iter().map(|b| (b % 10).to_string()).collect()) +} + +fn normalize_pair_code(raw: &str) -> String { + raw.chars().filter(|c| c.is_ascii_digit()).collect() +} + +/// Verify a claimed code. Single-use: success and abuse both burn it. +pub(crate) fn verify_pair_code(state: &RemoteState, claimed: &str) -> Result<(), String> { + let mut config = state.lock(); + let Some(entry) = config.pair_code.take() else { + return Err("No pairing code is active — generate one on the host.".into()); + }; + if entry.expires_at < std::time::SystemTime::now() { + return Err("That code expired — generate a fresh one on the host.".into()); + } + if normalize_pair_code(claimed) == entry.code { + return Ok(()); + } + let attempts = entry.attempts + 1; + if attempts >= PAIR_CODE_MAX_ATTEMPTS { + return Err("Too many wrong guesses — code burned. Generate a fresh one.".into()); + } + config.pair_code = Some(PairingCode { attempts, ..entry }); + Err("Wrong code — check the host screen and retry.".into()) +} + +fn token_path(app: &AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(data_dir.join(TOKEN_DIR).join(TOKEN_FILE)) +} + +fn load_or_create_token(app: &AppHandle) -> Result { + let path = token_path(app)?; + if let Ok(raw) = std::fs::read_to_string(&path) { + let token = raw.trim().to_string(); + if is_pairing_token(&token) { + return Ok(token); + } + } + let token = generate_token(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, format!("{token}\n")).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + Ok(token) +} + +fn generate_token() -> String { + let mut bytes = [0u8; TOKEN_BYTES]; + if read_secure_random(&mut bytes).is_err() { + // Fallback only (non-unix): still unique per host+time, but the unix + // path above is the real token source on every supported host OS. + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + .wrapping_add(std::process::id() as u128); + let mut state = seed | 1; + for chunk in bytes.chunks_mut(8) { + state ^= state >> 12; + state ^= state << 25; + state ^= state >> 27; + let rand = state.wrapping_mul(0x2545F4914F6CDD1D); + chunk.copy_from_slice(&rand.to_le_bytes()[..chunk.len()]); + } + } + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(unix)] +fn read_secure_random(buf: &mut [u8]) -> std::io::Result<()> { + use std::io::Read; + std::fs::File::open("/dev/urandom")?.read_exact(buf) +} + +#[cfg(not(unix))] +fn read_secure_random(_buf: &mut [u8]) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "no secure random source", + )) +} + +fn is_pairing_token(value: &str) -> bool { + // 32 bytes base64url-no-pad render as 43 chars. + value.len() == 43 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Best-effort MagicDNS name for the Tailscale pairing path. Only reported +/// while the tailnet is actually running — a stale name is worse than none. +fn tailnet_hostname() -> Option { + let status = tailnet_status(); + if !status.running { + return None; + } + status.dns_name +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TailnetStatus { + pub installed: bool, + /// BackendState == "Running": SSO complete, tailnet reachable. + pub running: bool, + /// SSO login, e.g. `user@company.dev` (Google Workspace login lands here). + pub login_name: Option, + pub display_name: Option, + /// Tailnet name as reported by the control plane. + pub tailnet_name: Option, + /// This machine's MagicDNS name without trailing dot. + pub dns_name: Option, + pub magic_dns: bool, +} + +/// Tailnet login + identity for the Settings pairing screen. Never fails: +/// a logged-out or missing client reports as such. +#[tauri::command] +pub fn remote_tailnet() -> TailnetStatus { + tailnet_status() +} + +fn tailnet_status() -> TailnetStatus { + let down = TailnetStatus { + installed: false, + running: false, + login_name: None, + display_name: None, + tailnet_name: None, + dns_name: None, + magic_dns: false, + }; + let output = match tailscale_cli(&["status", "--json"], STD_CLI_TIMEOUT) { + Some(output) if output.status.success() => output, + Some(_) => { + return TailnetStatus { + installed: true, + ..down + } + } + None => return down, + }; + let json: serde_json::Value = match serde_json::from_slice(&output.stdout) { + Ok(json) => json, + Err(_) => { + return TailnetStatus { + installed: true, + ..down + } + } + }; + parse_tailnet_status(&json) +} + +#[cfg(test)] +pub(crate) fn parse_tailnet_status_for_test(json: &serde_json::Value) -> TailnetStatus { + parse_tailnet_status(json) +} + +fn parse_tailnet_status(json: &serde_json::Value) -> TailnetStatus { + let running = json + .get("BackendState") + .and_then(|v| v.as_str()) + .is_some_and(|state| state == "Running"); + let dns_name = json + .get("Self") + .and_then(|s| s.get("DNSName")) + .and_then(|v| v.as_str()) + .map(|name| name.trim_end_matches('.').to_string()) + .filter(|name| !name.is_empty()); + let user_id = json + .get("Self") + .and_then(|s| s.get("UserID")) + .and_then(|v| v.as_u64()) + .map(|id| id.to_string()); + let user = user_id.as_deref().and_then(|id| json.get("User")?.get(id)); + TailnetStatus { + installed: true, + running, + login_name: user + .and_then(|u| u.get("LoginName")) + .and_then(|v| v.as_str()) + .filter(|name| !name.is_empty() && *name != "tagged-devices") + .map(str::to_string), + display_name: user + .and_then(|u| u.get("DisplayName")) + .and_then(|v| v.as_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string), + tailnet_name: json + .get("CurrentTailnet") + .and_then(|t| t.get("Name")) + .and_then(|v| v.as_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string), + dns_name, + magic_dns: json + .get("CurrentTailnet") + .and_then(|t| t.get("MagicDNSEnabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + } +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ServeStatus { + pub active: bool, +} + +/// Expose the companion listener on the tailnet via +/// `tailscale serve --tcp`. Raw-TCP mode (not `--https`) on purpose: the +/// tailnet is already WireGuard-encrypted end to end, and TCP forwarding +/// needs no provisioned certificates. The pairing token still gates every +/// frame. +#[tauri::command] +pub async fn remote_serve_on(state: State<'_, RemoteState>) -> Result { + let port = state.lock().port; + let tcp = format!("--tcp={port}"); + let target = format!("tcp://localhost:{port}"); + let tcp_ref = tcp.clone(); + let target_ref = target.clone(); + let output = tauri::async_runtime::spawn_blocking(move || { + tailscale_cli(&["serve", "--bg", &tcp_ref, &target_ref], STD_CLI_TIMEOUT) + }) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Tailscale CLI not found. Install it and log in, then retry.".to_string())?; + if !output.status.success() { + return Err(serve_error(&output)); + } + Ok(ServeStatus { active: true }) +} + +/// Remove the tailnet forwarding again. `--bg` persistence means it would +/// otherwise survive restarts. +#[tauri::command] +pub async fn remote_serve_off(state: State<'_, RemoteState>) -> Result { + let port = state.lock().port; + let tcp = format!("--tcp={port}"); + let output = tauri::async_runtime::spawn_blocking(move || { + tailscale_cli(&["serve", &tcp, "off"], STD_CLI_TIMEOUT) + }) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Tailscale CLI not found.".to_string())?; + if !output.status.success() { + return Err(serve_error(&output)); + } + Ok(ServeStatus { active: false }) +} + +/// Whether our TCP forwarder is currently served. Parsed defensively: serve +/// status JSON shapes drift between client versions, so this looks for our +/// target address anywhere in the payload instead of a fixed schema. +#[tauri::command] +pub async fn remote_serve_status(state: State<'_, RemoteState>) -> Result { + let port = state.lock().port; + let output = tauri::async_runtime::spawn_blocking(move || { + tailscale_cli(&["serve", "status", "--json"], STD_CLI_TIMEOUT) + }) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Tailscale CLI not found.".to_string())?; + if !output.status.success() { + return Err(serve_error(&output)); + } + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; + Ok(ServeStatus { + active: serve_target_present(&json, port), + }) +} + +/// Embedded tailnet node status. Mac uses tailscale-rs; iPad uses userspace +/// tsnet. Android has no node yet. +#[tauri::command] +pub async fn remote_embed_status( + app: AppHandle, + state: State<'_, RemoteState>, +) -> Result { + #[cfg(target_os = "android")] + { + let _ = (&app, &state); + return Ok(EmbedSnapshot::default()); + } + #[cfg(not(target_os = "android"))] + { + let _ = app; + Ok(state.embed_snapshot()) + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(any(target_os = "android", target_os = "ios"), allow(dead_code))] +pub struct EmbedStartInput { + #[serde(default)] + pub auth_key: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Start (or restart) the embedded node. Saved key, fresh key, or +/// interactive Google SSO via the login URL the snapshot returns. +#[tauri::command] +pub async fn remote_embed_start( + app: AppHandle, + state: State<'_, RemoteState>, + input: Option, +) -> Result { + let input = input.unwrap_or(EmbedStartInput { + auth_key: None, + tags: Vec::new(), + }); + #[cfg(target_os = "ios")] + { + let _ = &state; + crate::tsnet_mobile::start(app, input.auth_key).await + } + #[cfg(target_os = "android")] + { + let _ = (&app, &state, &input); + Err("The embedded tailnet node is not available on Android.".into()) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let _ = state; + crate::tailnet_embed::start(app, input.auth_key, input.tags).await + } +} + +/// Stop the embedded node. Saved keys survive; starting again rejoins. +#[tauri::command] +pub async fn remote_embed_stop( + app: AppHandle, + state: State<'_, RemoteState>, +) -> Result { + #[cfg(target_os = "ios")] + { + let _ = &state; + crate::tsnet_mobile::stop(app).await + } + #[cfg(target_os = "android")] + { + let _ = (&app, &state); + Err("The embedded tailnet node is not available on Android.".into()) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let _ = state; + crate::tailnet_embed::stop(app).await + } +} + +/// Wipe this device's tailnet keys and restart so Google sign-in can pick +/// a different account. Pairing with the Mac is unchanged. +#[tauri::command] +pub async fn remote_embed_logout( + app: AppHandle, + state: State<'_, RemoteState>, +) -> Result { + #[cfg(target_os = "ios")] + { + let _ = &state; + crate::tsnet_mobile::logout(app).await + } + #[cfg(target_os = "android")] + { + let _ = (&app, &state); + Err("The embedded tailnet node is not available on Android.".into()) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let _ = state; + crate::tailnet_embed::logout(app).await + } +} + +/// DNS-safe Tailscale hostname for this device (Mac computer name / iPad name). +pub fn node_hostname() -> String { + #[cfg(unix)] + { + let mut buf = vec![0u8; 256]; + let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) }; + if rc == 0 { + if let Some(end) = buf.iter().position(|&b| b == 0) { + buf.truncate(end); + } + if let Ok(raw) = String::from_utf8(buf) { + let trimmed = raw.trim_end_matches(".local"); + let sanitized = sanitize_ts_hostname(trimmed); + // iOS gethostname is often "localhost", which Tailscale + // then shows as the device name. Use the platform default. + if !sanitized.is_empty() && sanitized != "localhost" { + return sanitized; + } + } + } + } + default_node_hostname().to_string() +} + +pub fn default_node_hostname() -> &'static str { + #[cfg(target_os = "ios")] + { + "monocode-ipad" + } + #[cfg(not(target_os = "ios"))] + { + "monocode" + } +} + +pub fn sanitize_ts_hostname(raw: &str) -> String { + let mut out = String::new(); + for ch in raw.chars() { + let next = if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else if ch == '-' || ch == '_' || ch == '.' || ch == ' ' { + '-' + } else { + continue; + }; + if next == '-' && (out.is_empty() || out.ends_with('-')) { + continue; + } + out.push(next); + if out.len() >= 63 { + break; + } + } + while out.ends_with('-') { + out.pop(); + } + out +} + +/// Build an embed snapshot from `tailscale status --json` / tsnet status JSON. +#[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))] +pub(crate) fn embed_from_status_json(json: &serde_json::Value) -> EmbedSnapshot { + let tailnet = parse_tailnet_status(json); + let login_url = json + .get("AuthURL") + .and_then(|v| v.as_str()) + .filter(|url| !url.is_empty()) + .map(str::to_string); + let tailnet_ip = json + .get("Self") + .and_then(|s| s.get("TailscaleIPs")) + .and_then(|v| v.as_array()) + .and_then(|ips| { + ips.iter() + .filter_map(|v| v.as_str()) + .find(|ip| ip.contains('.')) + .map(str::to_string) + }); + let hostname = json + .get("Self") + .and_then(|s| s.get("HostName")) + .and_then(|v| v.as_str()) + .map(sanitize_ts_hostname) + .filter(|name| !name.is_empty()) + .or_else(|| Some(node_hostname())); + EmbedSnapshot { + running: true, + authorized: tailnet.running, + tailnet_ip, + login_url: if tailnet.running { None } else { login_url }, + error: None, + login_name: tailnet.login_name, + display_name: tailnet.display_name, + tailnet_name: tailnet.tailnet_name, + hostname, + } +} + +fn serve_target_present(json: &serde_json::Value, port: u16) -> bool { + let needle_local = format!("localhost:{port}"); + let needle_loop = format!("127.0.0.1:{port}"); + let mut stack = vec![json]; + while let Some(value) = stack.pop() { + match value { + serde_json::Value::String(text) => { + if text.contains(&needle_local) || text.contains(&needle_loop) { + return true; + } + } + serde_json::Value::Array(items) => stack.extend(items), + serde_json::Value::Object(map) => stack.extend(map.values()), + _ => {} + } + } + false +} + +fn serve_error(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return "tailscale serve failed".into(); + } + // CLI errors can be multi-line; keep the last line (the actionable one). + stderr + .lines() + .last() + .unwrap_or(&stderr) + .chars() + .take(300) + .collect() +} + +const STD_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run the Tailscale CLI off-thread with a timeout. None = not installed or +/// hung; Some(output) may still be a non-zero exit (logged out, etc.). +fn tailscale_cli(args: &[&str], timeout: std::time::Duration) -> Option { + let owned: Vec = args.iter().map(|arg| (*arg).to_string()).collect(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let output = std::process::Command::new("tailscale") + .args(&owned) + .output(); + let _ = tx.send(output); + }); + rx.recv_timeout(timeout).ok()?.ok() +} + +/// Best-effort LAN IP without new dependencies: "connecting" a UDP socket +/// sends nothing but reveals the interface route to the LAN. +fn lan_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + // TEST-NET-1; no packet ever leaves — connect() only picks a route. + socket.connect("192.0.2.1:80").ok()?; + let addr = socket.local_addr().ok()?; + let ip = addr.ip().to_string(); + if ip == "0.0.0.0" { + return None; + } + Some(ip) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn google_workspace_status() -> serde_json::Value { + // Shape of `tailscale status --json` on a Google-SSO tailnet. + json!({ + "BackendState": "Running", + "Self": { + "DNSName": "macbook.tail9a5.ts.net.", + "UserID": 5, + }, + "User": { + "5": { "ID": 5, "LoginName": "user@company.dev", "DisplayName": "User" }, + "999": { "ID": 999, "LoginName": "tagged-devices", "DisplayName": "Tagged Devices" }, + }, + "CurrentTailnet": { + "Name": "company", + "MagicDNSSuffix": "tail9a5.ts.net", + "MagicDNSEnabled": true, + }, + }) + } + + #[test] + fn tailnet_reports_google_login_and_dns() { + let status = parse_tailnet_status_for_test(&google_workspace_status()); + assert!(status.running); + assert_eq!(status.login_name.as_deref(), Some("user@company.dev")); + assert_eq!(status.display_name.as_deref(), Some("User")); + assert_eq!(status.tailnet_name.as_deref(), Some("company")); + assert_eq!(status.dns_name.as_deref(), Some("macbook.tail9a5.ts.net")); + assert!(status.magic_dns); + } + + #[test] + fn hostname_sanitizes_machine_names() { + assert_eq!(sanitize_ts_hostname("MJ iPad"), "mj-ipad"); + assert_eq!(sanitize_ts_hostname("Aleksandrs-Mac-Studio.local"), "aleksandrs-mac-studio-local"); + assert_eq!(sanitize_ts_hostname("---Wow---"), "wow"); + assert!(sanitize_ts_hostname("***").is_empty()); + } + + #[test] + fn embed_snapshot_reads_auth_url_and_google_login() { + let json = json!({ + "AuthURL": "https://login.tailscale.com/a/example", + "BackendState": "NeedsLogin", + "Self": { "DNSName": "", "UserID": 0, "TailscaleIPs": [] }, + }); + let snap = embed_from_status_json(&json); + assert!(snap.running); + assert!(!snap.authorized); + assert_eq!( + snap.login_url.as_deref(), + Some("https://login.tailscale.com/a/example") + ); + } + + #[test] + fn embed_snapshot_reads_identity_when_running() { + let json = google_workspace_status(); + let snap = embed_from_status_json(&json); + assert!(snap.authorized); + assert_eq!(snap.login_name.as_deref(), Some("user@company.dev")); + assert_eq!(snap.login_url, None); + } + + #[test] + fn tailnet_logged_out_is_not_running() { + let status = parse_tailnet_status_for_test(&json!({ + "BackendState": "NoState", + "Self": { "DNSName": "", "UserID": 0 }, + })); + assert!(!status.running); + assert_eq!(status.login_name, None); + assert_eq!(status.dns_name, None); + } + + #[test] + fn serve_target_found_anywhere_in_status() { + assert!(serve_target_present( + &json!({ "TCP": { "17233": { "TCPForward": "tcp://localhost:17233" } } }), + 17233 + )); + assert!(serve_target_present( + &json!({ "TCP": { "17233": { "TCPForward": "tcp://127.0.0.1:17233" } } }), + 17233 + )); + assert!(!serve_target_present( + &json!({ "TCP": { "443": { "TCPForward": "tcp://localhost:443" } } }), + 17233 + )); + assert!(!serve_target_present(&json!({}), 17233)); + } + + #[test] + fn route_flags_read_legacy_and_combined() { + assert_eq!(parse_route_flags(None), (false, true)); + assert_eq!(parse_route_flags(Some("")), (false, true)); + assert_eq!(parse_route_flags(Some("lan")), (true, false)); + assert_eq!(parse_route_flags(Some("tailscale")), (false, true)); + assert_eq!(parse_route_flags(Some("lan,tailscale")), (true, true)); + assert_eq!(parse_route_flags(Some("both")), (true, true)); + assert_eq!(format_route_flags(true, true), "lan,tailscale"); + assert_eq!(format_route_flags(true, false), "lan"); + assert_eq!(format_route_flags(false, true), "tailscale"); + assert_eq!(format_route_flags(false, false), ""); + } + + fn live_pair_state(code: &str) -> RemoteState { + let state = RemoteState::new(); + state.lock().pair_code = Some(PairingCode { + code: code.to_string(), + expires_at: std::time::SystemTime::now() + std::time::Duration::from_secs(600), + attempts: 0, + }); + state + } + + #[test] + fn pair_code_accepts_with_spacing_and_burns_on_use() { + let state = live_pair_state("123456"); + assert!(verify_pair_code(&state, "123 456").is_ok()); + // Single-use: the very next claim finds nothing. + assert!(verify_pair_code(&state, "123456").is_err()); + } + + #[test] + fn pair_code_burns_after_too_many_guesses() { + let state = live_pair_state("123456"); + for _ in 0..PAIR_CODE_MAX_ATTEMPTS - 1 { + assert!(verify_pair_code(&state, "000000").is_err()); + } + // Last allowed guess burns it; even the right code fails after. + assert!(verify_pair_code(&state, "000000").is_err()); + assert!(verify_pair_code(&state, "123456").is_err()); + } + + #[test] + fn pair_code_rejects_expired() { + let state = RemoteState::new(); + state.lock().pair_code = Some(PairingCode { + code: "123456".to_string(), + expires_at: std::time::SystemTime::now() - std::time::Duration::from_secs(1), + attempts: 0, + }); + assert!(verify_pair_code(&state, "123456").is_err()); + } +} diff --git a/src-tauri/src/remote_dispatch.rs b/src-tauri/src/remote_dispatch.rs new file mode 100644 index 00000000..c7873a1f --- /dev/null +++ b/src-tauri/src/remote_dispatch.rs @@ -0,0 +1,546 @@ +// Remote command dispatch for the companion link. +// +// MERGE NOTE (upstream-friendly): this file only *calls* existing commands — +// it changes none of them. When upstream adds a command, the companion keeps +// working; exposing the new command remotely is a 3-line arm plus an +// allowlist entry in remote.rs. +// +// Safety overrides (documented deviations from local behavior): +// - `session_take_in_flight` is served as a non-destructive *list*: the take +// consumes the host's quit-restore snapshot, and a companion must never +// steal host restore state. +// - `workspace_set_snapshot` is absorbed (Ok, no write): the companion layout +// is ephemeral and must not clobber the desktop's restore snapshot. +// - `reveal_path` is absorbed (Ok, no-op): there is no Finder to reveal on +// an iPad. +// - `stage/take_window_transfer` are host-window plumbing and are not in the +// allowlist at all; the TS caller already treats rejection as "no transfer". + +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Manager}; + +fn required(args: &Value, name: &str) -> Result { + let raw = args.get(name).cloned().unwrap_or(Value::Null); + serde_json::from_value(raw).map_err(|e| format!("{name}: {e}")) +} + +fn optional(args: &Value, name: &str) -> Result, String> { + match args.get(name) { + None | Some(Value::Null) => Ok(None), + Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| format!("{name}: {e}")), + } +} + +fn ok(value: T) -> Result { + serde_json::to_value(value).map_err(|e| e.to_string()) +} + +/// Convert a binary Tauri `Response` into the `{ __bytes }` envelope the +/// companion transport decodes back to an ArrayBuffer (see protocol.ts). +fn binary_envelope(response: tauri::ipc::Response) -> Result { + use tauri::ipc::IpcResponse; + match response.body().map_err(|e| e.to_string())? { + tauri::ipc::InvokeResponseBody::Raw(bytes) => { + use base64::Engine; + Ok(serde_json::json!({ + "__bytes": base64::engine::general_purpose::STANDARD.encode(bytes), + })) + } + tauri::ipc::InvokeResponseBody::Json(_) => { + Err("companion: unexpected JSON binary body".into()) + } + } +} + +pub async fn dispatch_command( + app: &AppHandle, + command: &str, + args: Value, +) -> Result { + if !crate::remote::is_remote_command(command) { + return Err(format!( + "companion: command is not available remotely ({command})" + )); + } + let args_obj = if args.is_null() { + &Value::Object(Default::default()) + } else { + &args + }; + match command { + // -- host identity ------------------------------------------------- + "default_cwd" => ok(crate::default_cwd()), + "home_dir" => ok(crate::home_dir()), + "remote_peers" => ok(crate::remote::remote_peers(app.state())), + "remote_status" => ok(crate::remote::remote_status(app.state())), + + // -- agent harnesses (spawned on the host; iPad never runs CLIs) --- + "harness_resolve_cursor" => ok(crate::harness::harness_resolve_cursor()?), + "harness_resolve_codex" => ok(crate::harness::harness_resolve_codex()?), + "harness_resolve_opencode" => ok(crate::harness::harness_resolve_opencode()?), + "harness_resolve_claude" => ok(crate::harness::harness_resolve_claude()?), + "harness_resolve_pi" => ok(crate::harness::harness_resolve_pi()?), + "harness_resolve_omp" => ok(crate::harness::harness_resolve_omp()?), + "harness_resolve_fx" => ok(crate::harness::harness_resolve_fx()?), + "harness_resolve_grok" => ok(crate::harness::harness_resolve_grok()?), + "harness_free_port" => ok(crate::harness::harness_free_port()?), + "harness_spawn" => ok(crate::harness::harness_spawn( + app.clone(), + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "command")?, + required(args_obj, "args")?, + required(args_obj, "cwd")?, + )?), + "harness_write" => ok(crate::harness::harness_write( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "line")?, + )?), + "harness_kill" => ok(crate::harness::harness_kill( + app.state(), + required(args_obj, "sessionId")?, + )?), + "harness_kill_all" => ok(crate::harness::harness_kill_all(app.state())?), + "harness_http" => { + let response = crate::harness::harness_http( + required(args_obj, "url")?, + required(args_obj, "method")?, + optional(args_obj, "headers")?, + optional(args_obj, "body")?, + optional(args_obj, "timeoutMs")?, + ) + .await?; + ok(response) + } + "harness_sse_open" => ok(crate::harness::harness_sse_open( + app.clone(), + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "url")?, + optional(args_obj, "headers")?, + )?), + "harness_sse_close" => ok(crate::harness::harness_sse_close( + app.state(), + required(args_obj, "sessionId")?, + )?), + "harness_exec" => ok(crate::harness::harness_exec( + required(args_obj, "command")?, + required(args_obj, "args")?, + optional(args_obj, "cwd")?, + ) + .await?), + + // -- terminals ------------------------------------------------------ + "pty_spawn" => ok(crate::pty::pty_spawn( + app.clone(), + app.state(), + required(args_obj, "id")?, + required(args_obj, "cwd")?, + required(args_obj, "cols")?, + required(args_obj, "rows")?, + )?), + "pty_write" => ok(crate::pty::pty_write( + app.state(), + required(args_obj, "id")?, + required(args_obj, "data")?, + )?), + "pty_resize" => ok(crate::pty::pty_resize( + app.state(), + required(args_obj, "id")?, + required(args_obj, "cols")?, + required(args_obj, "rows")?, + )?), + "pty_status" => ok(crate::pty::pty_status( + app.state(), + required(args_obj, "id")?, + )?), + "pty_kill" => ok(crate::pty::pty_kill( + app.state(), + required(args_obj, "id")?, + )?), + "pty_kill_all" => ok(crate::pty::pty_kill_all(app.state())?), + + // -- sessions / workspace ------------------------------------------- + "session_upsert" => ok(crate::session_store::session_upsert( + app.clone(), + app.state(), + required(args_obj, "session")?, + )?), + "session_list_by_project" => ok(crate::session_store::session_list_by_project( + app.state(), + required(args_obj, "cwd")?, + )?), + "session_search" => ok(crate::session_store::session_search( + app.state(), + required(args_obj, "options")?, + )?), + "session_get" => ok(crate::session_store::session_get( + app.state(), + required(args_obj, "sessionId")?, + )?), + "session_delete" => ok(crate::session_store::session_delete( + app.clone(), + app.state(), + required(args_obj, "sessionId")?, + )?), + "session_set_archived" => ok(crate::session_store::session_set_archived( + app.clone(), + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "archived")?, + )?), + "session_set_pinned" => ok(crate::session_store::session_set_pinned( + app.clone(), + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "pinned")?, + )?), + "session_set_in_flight" => ok(crate::session_store::session_set_in_flight( + app.state(), + required(args_obj, "sessions")?, + )?), + "session_list_in_flight" => ok(crate::session_store::session_list_in_flight(app.state())?), + // Non-destructive on purpose: the take consumes host restore state. + "session_take_in_flight" => ok(crate::session_store::session_list_in_flight(app.state())?), + // Absorbed on purpose: companion layout is ephemeral; see header. + "workspace_set_snapshot" => Ok(Value::Null), + "workspace_get_snapshot" => ok(crate::session_store::workspace_get_snapshot(app.state())?), + + // -- filesystem / git ----------------------------------------------- + "list_dir" => ok(crate::fs::list_dir(required(args_obj, "path")?)?), + "list_project_files" => { + ok(crate::fs::list_project_files(required(args_obj, "cwd")?).await?) + } + "git_diff_stats" => ok(crate::fs::git_diff_stats(required(args_obj, "cwd")?).await?), + "git_diff_index" => ok(crate::fs::git_diff_index(required(args_obj, "cwd")?).await?), + "git_diff_files" => ok(crate::fs::git_diff_files(required(args_obj, "cwd")?).await?), + "git_file_diff" => ok(crate::fs::git_file_diff( + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + ) + .await?), + "git_history" => ok(crate::fs::git_history( + required(args_obj, "cwd")?, + optional(args_obj, "limit")?, + ) + .await?), + "git_commit_files" => ok(crate::fs::git_commit_files( + required(args_obj, "cwd")?, + required(args_obj, "sha")?, + ) + .await?), + "git_commit_file_diff" => ok(crate::fs::git_commit_file_diff( + required(args_obj, "cwd")?, + required(args_obj, "sha")?, + required(args_obj, "relative")?, + ) + .await?), + "git_stage_file" => ok(crate::fs::git_stage_file( + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + ) + .await?), + "git_stage_contents" => ok(crate::fs::git_stage_contents( + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + required(args_obj, "contents")?, + ) + .await?), + "git_unstage_file" => ok(crate::fs::git_unstage_file( + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + ) + .await?), + "git_discard_file" => ok(crate::fs::git_discard_file( + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + ) + .await?), + "git_discard_all" => ok(crate::fs::git_discard_all(required(args_obj, "cwd")?).await?), + "git_stage_all" => ok(crate::fs::git_stage_all(required(args_obj, "cwd")?).await?), + "git_unstage_all" => ok(crate::fs::git_unstage_all(required(args_obj, "cwd")?).await?), + "git_staged_context" => { + ok(crate::fs::git_staged_context(required(args_obj, "cwd")?).await?) + } + "git_commit" => ok(crate::fs::git_commit( + required(args_obj, "cwd")?, + required(args_obj, "message")?, + ) + .await?), + "git_push" => ok(crate::fs::git_push(required(args_obj, "cwd")?).await?), + "git_pull" => ok(crate::fs::git_pull(required(args_obj, "cwd")?).await?), + "git_sync" => ok(crate::fs::git_sync(required(args_obj, "cwd")?).await?), + "git_range_context" => ok(crate::fs::git_range_context(required(args_obj, "cwd")?).await?), + "git_pr_status" => ok(crate::fs::git_pr_status(required(args_obj, "cwd")?).await?), + "git_pr_create" => ok(crate::fs::git_pr_create( + required(args_obj, "cwd")?, + required(args_obj, "title")?, + required(args_obj, "body")?, + required(args_obj, "base")?, + required(args_obj, "head")?, + ) + .await?), + "git_github_repo" => ok(crate::fs::git_github_repo(required(args_obj, "cwd")?).await?), + "git_github_work_items" => ok(crate::fs::git_github_work_items( + required(args_obj, "cwd")?, + required(args_obj, "kind")?, + required(args_obj, "assignedToMe")?, + required(args_obj, "state")?, + required(args_obj, "search")?, + optional(args_obj, "limit")?, + ) + .await?), + "git_github_work_item_details" => ok(crate::fs::git_github_work_item_details( + required(args_obj, "cwd")?, + required(args_obj, "kind")?, + required(args_obj, "number")?, + ) + .await?), + "git_github_work_item_thread" => ok(crate::fs::git_github_work_item_thread( + required(args_obj, "cwd")?, + required(args_obj, "kind")?, + required(args_obj, "number")?, + ) + .await?), + "git_github_work_item_comment" => ok(crate::fs::git_github_work_item_comment( + required(args_obj, "cwd")?, + required(args_obj, "kind")?, + required(args_obj, "number")?, + required(args_obj, "body")?, + required(args_obj, "inReplyTo")?, + ) + .await?), + "git_github_pr_diff" => ok(crate::fs::git_github_pr_diff( + required(args_obj, "cwd")?, + required(args_obj, "number")?, + ) + .await?), + "git_branches" => ok(crate::fs::git_branches(required(args_obj, "cwd")?).await?), + "git_checkout" => ok(crate::fs::git_checkout( + required(args_obj, "cwd")?, + required(args_obj, "name")?, + optional(args_obj, "remote")?, + ) + .await?), + "git_create_branch" => ok(crate::fs::git_create_branch( + required(args_obj, "cwd")?, + required(args_obj, "name")?, + ) + .await?), + "git_stash" => ok(crate::fs::git_stash( + required(args_obj, "cwd")?, + optional(args_obj, "message")?, + ) + .await?), + "create_path" => ok(crate::fs::create_path( + required(args_obj, "parent")?, + required(args_obj, "name")?, + required(args_obj, "isDir")?, + )?), + "rename_path" => ok(crate::fs::rename_path( + required(args_obj, "path")?, + required(args_obj, "name")?, + ) + .await?), + "delete_path" => ok(crate::fs::delete_path(required(args_obj, "path")?).await?), + "copy_path" => ok(crate::fs::copy_path( + required(args_obj, "from")?, + required(args_obj, "destParent")?, + ) + .await?), + "move_path" => ok(crate::fs::move_path( + required(args_obj, "from")?, + required(args_obj, "destParent")?, + ) + .await?), + // No-op on purpose: nothing to reveal on a companion screen. + "reveal_path" => Ok(Value::Null), + "clone_repo" => ok(crate::fs::clone_repo( + required(args_obj, "url")?, + required(args_obj, "parent")?, + ) + .await?), + "read_file_preview" => ok(crate::fs::read_file_preview( + required(args_obj, "path")?, + required(args_obj, "maxLines")?, + optional(args_obj, "startLine")?, + )?), + "stat_files" => ok(crate::fs::stat_files(required(args_obj, "paths")?)?), + "inspect_paths" => ok(crate::fs::inspect_paths(required(args_obj, "paths")?)), + "read_file_base64" => ok(crate::fs::read_file_base64(required(args_obj, "path")?).await?), + "read_binary_file" => { + let response = crate::fs::read_binary_file(required(args_obj, "path")?).await?; + binary_envelope(response) + } + "write_attachment" => ok(crate::fs::write_attachment( + required(args_obj, "name")?, + required(args_obj, "data")?, + ) + .await?), + "read_text_file" => ok(crate::fs::read_text_file(required(args_obj, "path")?).await?), + "write_text_file" => ok(crate::fs::write_text_file( + required(args_obj, "path")?, + required(args_obj, "content")?, + ) + .await?), + + // -- search / skills / misc host data -------------------------------- + "search_project" => { + ok(crate::search::search_project(required(args_obj, "options")?).await?) + } + "list_skills" => ok(crate::skills::list_skills(required(args_obj, "cwd")?)?), + "cursor_tool_calls" => ok(crate::cursor_store::cursor_tool_calls( + required(args_obj, "sessionId")?, + required(args_obj, "toolCallIds")?, + ) + .await?), + "fetch_claude_usage" => ok(crate::rate_limits::fetch_claude_usage().await?), + "fetch_inbox_media" => { + let response = + crate::inbox_media::fetch_inbox_media(required(args_obj, "url")?).await?; + binary_envelope(response) + } + "linear_status" => ok(crate::linear::linear_status(app.clone())?), + "linear_set_token" => { + ok(crate::linear::linear_set_token(app.clone(), required(args_obj, "token")?).await?) + } + "linear_list_teams" => ok(crate::linear::linear_list_teams(app.clone()).await?), + "linear_list_issues" => ok(crate::linear::linear_list_issues( + app.clone(), + required(args_obj, "assignedToMe")?, + required(args_obj, "state")?, + required(args_obj, "teamIds")?, + optional(args_obj, "limit")?, + ) + .await?), + "linear_issue_details" => { + ok(crate::linear::linear_issue_details(app.clone(), required(args_obj, "id")?).await?) + } + "linear_issue_thread" => { + ok(crate::linear::linear_issue_thread(app.clone(), required(args_obj, "id")?).await?) + } + "linear_issue_comment" => ok(crate::linear::linear_issue_comment( + app.clone(), + required(args_obj, "id")?, + required(args_obj, "body")?, + required(args_obj, "parentId")?, + ) + .await?), + "notes_list" => ok(crate::notes::notes_list(app.state())?), + "notes_get" => ok(crate::notes::notes_get( + app.state(), + required(args_obj, "id")?, + )?), + "notes_upsert" => ok(crate::notes::notes_upsert( + app.state(), + required(args_obj, "note")?, + )?), + "notes_delete" => ok(crate::notes::notes_delete( + app.state(), + required(args_obj, "id")?, + )?), + "session_checkpoint_ensure" => ok(crate::checkpoint::session_checkpoint_ensure( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + ) + .await?), + "session_checkpoint_prepare" => ok(crate::checkpoint::session_checkpoint_prepare( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + required(args_obj, "paths")?, + ) + .await?), + "session_checkpoint_capture" => ok(crate::checkpoint::session_checkpoint_capture( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + required(args_obj, "paths")?, + ) + .await?), + "session_checkpoint_status" => ok(crate::checkpoint::session_checkpoint_status( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + ) + .await?), + "session_checkpoint_file_diff" => ok(crate::checkpoint::session_checkpoint_file_diff( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + required(args_obj, "relative")?, + ) + .await?), + "session_checkpoint_undo" => ok(crate::checkpoint::session_checkpoint_undo( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + optional(args_obj, "relative")?, + ) + .await?), + "session_checkpoint_keep" => ok(crate::checkpoint::session_checkpoint_keep( + app.state(), + required(args_obj, "sessionId")?, + required(args_obj, "cwd")?, + optional(args_obj, "relative")?, + ) + .await?), + "save_project_logo" => ok(crate::project_logo::save_project_logo( + app.clone(), + required(args_obj, "project")?, + required(args_obj, "sourcePath")?, + ) + .await?), + "remove_project_logo" => ok(crate::project_logo::remove_project_logo( + app.clone(), + required(args_obj, "project")?, + ) + .await?), + + _ => Err(format!( + "companion: command is not available remotely ({command})" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn required_reads_typed_args() { + let args = json!({ "path": "/tmp", "limit": 7 }); + assert_eq!(required::(&args, "path").unwrap(), "/tmp"); + assert_eq!(required::(&args, "limit").unwrap(), 7); + } + + #[test] + fn required_rejects_missing_and_mistyped_args() { + let args = json!({ "limit": "seven" }); + assert!(required::(&args, "path").is_err()); + assert!(required::(&args, "limit").is_err()); + } + + #[test] + fn optional_treats_missing_and_null_as_none() { + let args = json!({ "present": "x", "nil": null }); + assert_eq!( + optional::(&args, "present").unwrap(), + Some("x".to_string()) + ); + assert_eq!(optional::(&args, "nil").unwrap(), None); + assert_eq!(optional::(&args, "absent").unwrap(), None); + assert_eq!(optional::(&args, "absent").unwrap(), None); + } + + #[test] + fn optional_rejects_mistyped_args() { + let args = json!({ "limit": [1, 2] }); + assert!(optional::(&args, "limit").is_err()); + } +} diff --git a/src-tauri/src/remote_server.rs b/src-tauri/src/remote_server.rs new file mode 100644 index 00000000..183c301d --- /dev/null +++ b/src-tauri/src/remote_server.rs @@ -0,0 +1,663 @@ +// Companion WebSocket server (host side). +// +// MERGE NOTE: new file; calls into existing modules without changing them. +// Serves the protocol documented in src/lib/transport/protocol.ts: +// +// ws://:/v1/connect?token= +// client -> host { id, type: "invoke", command, args? } +// host -> client { id, type: "result", ok, payload?/error? } +// host -> client { type: "event", event, payload } +// +// Auth is a bearer pairing token compared in constant time. Transport +// security comes from the link, not this server: +// - direct LAN: token-gated ws:// on a trusted network; +// - Tailscale: `tailscale serve --bg --https=443 http://localhost:` +// terminates outer TLS (auto cert) and proxies the WebSocket upgrade; +// the companion then dials wss://..ts.net:443. +// WireGuard already encrypts tailnet traffic, so the token is the only +// credential in both modes (same as Tailscale's own serve model). + +use std::collections::HashMap; +use std::io::Cursor; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use http::{Request, Response}; +use tauri::{AppHandle, Listener, Manager}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::TcpListener; +use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; +use tokio_tungstenite::tungstenite::Message; + +const HEALTH_BODY: &str = "MONOCODE-COMPANION"; +const NOT_FOUND_RESPONSE: &str = + "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nnot found"; + +fn health_response() -> Vec { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{HEALTH_BODY}", + HEALTH_BODY.len() + ) + .into_bytes() +} + +/// Backend events rebroadcast to companions. Window/menu chrome is host-local +/// and is deliberately not forwarded. +const FORWARDED_EVENTS: &[&str] = &[ + "harness-stdout", + "harness-stderr", + "harness-exit", + "harness-sse", + "harness-sse-end", + "pty-data", + "pty-exit", + "session-store-changed", +]; + +/// Connected companions. Shared between the accept loop (registers peers) +/// and the global Tauri event forwarders (broadcasts to peers). +pub struct ServerShared { + peers: Mutex>>, + next_id: AtomicU64, +} + +impl ServerShared { + pub fn new() -> Self { + Self { + peers: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + } + } + + fn add(&self, sender: UnboundedSender) -> u64 { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + self.peers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id, sender); + id + } + + fn remove(&self, id: u64) { + self.peers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + } + + pub fn peer_count(&self) -> usize { + self.peers.lock().unwrap_or_else(|e| e.into_inner()).len() + } + + fn broadcast(&self, frame: String) { + let peers = self.peers.lock().unwrap_or_else(|e| e.into_inner()); + for sender in peers.values() { + // A full peer eventually drops its receiver; dead senders are + // reaped on disconnect, so delivery here is best-effort. + let _ = sender.send(frame.clone()); + } + } +} + +/// Register global forwarders once per process. They broadcast to whatever +/// peers are connected (none when the link is disabled: a no-op). +pub fn register_event_forwarding(app: &AppHandle, shared: &Arc) { + for name in FORWARDED_EVENTS { + let event = (*name).to_string(); + let peers = Arc::clone(shared); + app.listen(event.clone(), move |tauri_event| { + // `payload()` is the raw JSON string ("" when the emitter sent none). + let raw = tauri_event.payload(); + let payload: serde_json::Value = + serde_json::from_str(raw).unwrap_or(serde_json::Value::Null); + let frame = serde_json::json!({ + "type": "event", + "event": event, + "payload": payload, + }); + peers.broadcast(frame.to_string()); + }); + } +} + +pub async fn run(app: AppHandle, listener: TcpListener, token: String, shared: Arc) { + loop { + let (stream, addr) = match listener.accept().await { + Ok(pair) => pair, + Err(_) => continue, + }; + eprintln!("companion: connection from {addr}"); + let app = app.clone(); + let token = token.clone(); + let shared = Arc::clone(&shared); + tokio::spawn(async move { + route_connection(app, stream, token, shared, addr).await; + }); + } +} + +/// Bytes already read from `inner` (the HTTP request head) plus the rest +/// of the socket, so a WebSocket handshake can consume the same request. +struct PrefixedStream { + prefix: Cursor>, + inner: S, +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let pos = self.prefix.position() as usize; + let len = self.prefix.get_ref().len(); + if pos < len { + let rest = &self.prefix.get_ref()[pos..]; + let n = rest.len().min(buf.remaining()); + buf.put_slice(&rest[..n]); + self.prefix.set_position((pos + n) as u64); + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +async fn read_http_head(stream: &mut S) -> std::io::Result> { + let mut buf = Vec::with_capacity(512); + let mut tmp = [0u8; 512]; + loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + if buf.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if buf.len() > 16_384 { + break; + } + } + Ok(buf) +} + +fn is_websocket_upgrade(head: &[u8]) -> bool { + std::str::from_utf8(head) + .unwrap_or("") + .to_ascii_lowercase() + .lines() + .any(|line| line.starts_with("upgrade:") && line.contains("websocket")) +} + +#[derive(Debug, PartialEq, Eq)] +enum HeadKind { + Health, + Other, +} + +fn classify_http_head(head: &[u8]) -> HeadKind { + let text = std::str::from_utf8(head).unwrap_or(""); + let line = text.split(['\r', '\n']).next().unwrap_or(""); + let path = line.split_whitespace().nth(1).unwrap_or(""); + if line.starts_with("GET ") && (path == "/" || path == "/health") { + HeadKind::Health + } else { + HeadKind::Other + } +} + +async fn route_connection( + app: AppHandle, + mut stream: S, + token: String, + shared: Arc, + addr: impl std::fmt::Display, +) where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let head = match tokio::time::timeout(Duration::from_secs(3), read_http_head(&mut stream)).await + { + Ok(Ok(head)) if !head.is_empty() => head, + Ok(Ok(head)) => { + eprintln!( + "companion: empty request from {addr} ({} bytes)", + head.len() + ); + return; + } + Ok(Err(error)) => { + eprintln!("companion: read failed from {addr}: {error}"); + return; + } + Err(_) => { + eprintln!("companion: timed out waiting for request from {addr}"); + return; + } + }; + let first = std::str::from_utf8(&head) + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); + eprintln!("companion: request from {addr}: {first}"); + if is_websocket_upgrade(&head) { + let replay = PrefixedStream { + prefix: Cursor::new(head), + inner: stream, + }; + eprintln!("companion: websocket upgrade from {addr}"); + serve_connection(app, replay, token, shared).await; + eprintln!("companion: websocket closed from {addr}"); + return; + } + if classify_http_head(&head) == HeadKind::Health { + let _ = stream.write_all(&health_response()).await; + let _ = stream.shutdown().await; + return; + } + eprintln!("companion: rejected non-websocket from {addr}"); + let _ = stream.write_all(NOT_FOUND_RESPONSE.as_bytes()).await; + let _ = stream.shutdown().await; +} + +type ErrorResponse = Response>; + +fn reject(status: u16, message: &str) -> ErrorResponse { + Response::builder() + .status(status) + .body(Some(message.to_string())) + .unwrap_or_else(|_| Response::new(None)) +} + +fn query_param(query: Option<&str>, key: &str) -> Option { + let query = query?; + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + if parts.next() == Some(key) { + return parts.next().map(str::to_string); + } + } + None +} + +fn tokens_match(presented: &str, expected: &str) -> bool { + if presented.len() != expected.len() || presented.is_empty() { + return false; + } + presented + .bytes() + .zip(expected.bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +/// Pure upgrade gate so the handshake policy is unit-testable without a +/// socket: right path + (bearer pairing token OR pair mode). Pair mode +/// (`?pair=1`, no token) accepts the socket but leaves it unpaired: it may +/// only invoke `pair_claim` until the 6-digit code checks out. +#[derive(Debug, PartialEq, Eq)] +enum Upgrade { + Paired, + Pairing, +} + +fn check_upgrade( + path: &str, + query: Option<&str>, + expected_token: &str, +) -> Result { + if path != super::remote::COMPANION_WS_PATH { + return Err((404, "not found")); + } + let ok = query_param(query, "token") + .map(|presented| tokens_match(&presented, expected_token)) + .unwrap_or(false); + if ok { + return Ok(Upgrade::Paired); + } + if query_param(query, "pair").as_deref() == Some("1") { + return Ok(Upgrade::Pairing); + } + Err((401, "bad pairing token")) +} + +// The handshake callback's Err type is fixed by tungstenite (an HTTP +// response); boxing it would only complicate the accept path. +#[allow(clippy::result_large_err)] +pub async fn serve_connection( + app: AppHandle, + stream: S, + token: String, + shared: Arc, +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + // The handshake callback cannot return extra state (tungstenite fixes + // its signature), so the pair/token decision travels out via shared + // state the FnOnce closure moves in. + let decided = Arc::new(std::sync::Mutex::new(None)); + let decide_in = Arc::clone(&decided); + let ws = match tokio_tungstenite::accept_hdr_async( + stream, + move |request: &Request<()>, response: Response<()>| { + let uri = request.uri(); + match check_upgrade(uri.path(), uri.query(), &token) { + Ok(mode) => { + *decide_in.lock().unwrap_or_else(|e| e.into_inner()) = Some(mode); + Ok(response) + } + Err((status, message)) => Err(reject(status, message)), + } + }, + ) + .await + { + Ok(ws) => ws, + Err(error) => { + eprintln!("companion: handshake failed: {error}"); + return; + } + }; + let pairing = matches!( + *decided.lock().unwrap_or_else(|e| e.into_inner()), + Some(Upgrade::Pairing) + ); + let paired = Arc::new(std::sync::atomic::AtomicBool::new(!pairing)); + + let (mut sink, mut incoming) = ws.split(); + let (outgoing, mut mailbox) = unbounded_channel::(); + let peer_id = shared.add(outgoing.clone()); + + let writer = tokio::spawn(async move { + while let Some(frame) = mailbox.recv().await { + if sink.send(Message::Text(frame.into())).await.is_err() { + break; + } + } + let _ = sink.close().await; + }); + + let mut claim_failures = 0u8; + while let Some(message) = incoming.next().await { + match message { + Ok(Message::Text(text)) => { + if handle_request(&app, &text, &outgoing, &paired).await { + claim_failures = 0; + } else { + // Rejected on an unpaired socket: only pair_claim gets + // answers, and only a few wrong guesses before we hang up. + claim_failures += 1; + if claim_failures >= 6 { + break; + } + } + } + Ok(Message::Binary(_)) => { + // Protocol is JSON text only; ignore binary frames. + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + + shared.remove(peer_id); + // `outgoing` (our clone) drops here, closing the mailbox so the writer + // task exits even if the sink close above raced it. + drop(outgoing); + writer.abort(); +} + +/// Handle one frame. Returns true when the frame was answered (or intentionally +/// absorbed on a paired socket); false when an unpaired socket sent anything +/// but a claim — the caller counts those toward hanging up. +async fn handle_request( + app: &AppHandle, + text: &str, + outgoing: &UnboundedSender, + paired: &std::sync::atomic::AtomicBool, +) -> bool { + let request: serde_json::Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(_) => return true, + }; + let id = request.get("id").and_then(|v| v.as_u64()).unwrap_or(0); + let command = request + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // Malformed frames are ignored; the client's per-request timeout fires. + if id == 0 || command.is_empty() { + return true; + } + let args = request + .get("args") + .cloned() + .unwrap_or(serde_json::Value::Null); + if command == "pair_claim" && !paired.load(std::sync::atomic::Ordering::SeqCst) { + return handle_claim(app, id, &args, outgoing, paired).await; + } + if !paired.load(std::sync::atomic::Ordering::SeqCst) { + // Unpaired sockets get exactly one command. Anything else is a probe. + return false; + } + let frame = match super::remote_dispatch::dispatch_command(app, command, args).await { + Ok(payload) => serde_json::json!({ + "id": id, + "type": "result", + "ok": true, + "payload": payload, + }), + Err(error) => serde_json::json!({ + "id": id, + "type": "result", + "ok": false, + "error": error, + }), + }; + let _ = outgoing.send(frame.to_string()); + true +} + +/// Claim a pairing code on an unpaired socket. Success upgrades the socket +/// to fully paired and hands over the real token; the iPad then reconnects +/// (or continues) as a normal token-authenticated client. +async fn handle_claim( + app: &AppHandle, + id: u64, + args: &serde_json::Value, + outgoing: &UnboundedSender, + paired: &std::sync::atomic::AtomicBool, +) -> bool { + let code = args.get("code").and_then(|v| v.as_str()).unwrap_or(""); + let state: tauri::State<'_, crate::remote::RemoteState> = app.state(); + let frame = match crate::remote::verify_pair_code(&state, code) { + Ok(()) => match state.pairing_token(app) { + Ok(token) => { + paired.store(true, std::sync::atomic::Ordering::SeqCst); + let hosts = state.advertised_hosts(); + let mut payload = serde_json::json!({ "token": token }); + if let Some(obj) = payload.as_object_mut() { + if let Some(lan_ip) = hosts.lan_ip { + obj.insert("lanIp".into(), serde_json::Value::String(lan_ip)); + } + if let Some(tailnet_host) = hosts.tailnet_host { + obj.insert( + "tailnetHost".into(), + serde_json::Value::String(tailnet_host), + ); + } + } + serde_json::json!({ + "id": id, + "type": "result", + "ok": true, + "payload": payload, + }) + } + Err(error) => serde_json::json!({ + "id": id, + "type": "result", + "ok": false, + "error": error, + }), + }, + Err(error) => serde_json::json!({ + "id": id, + "type": "result", + "ok": false, + "error": error, + }), + }; + let claimed = frame.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let _ = outgoing.send(frame.to_string()); + claimed +} + +/// Re-exported for tests that only need frame helpers. +#[allow(dead_code)] +pub fn parse_query_token(query: Option<&str>) -> Option { + query_param(query, "token") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_compare_is_exact() { + assert!(tokens_match("abc123", "abc123")); + assert!(!tokens_match("abc123", "abc124")); + assert!(!tokens_match("abc123", "abc12")); + assert!(!tokens_match("", "")); + } + + #[test] + fn query_token_parses() { + assert_eq!( + parse_query_token(Some("token=abc&v=1")), + Some("abc".to_string()) + ); + assert_eq!(parse_query_token(Some("v=1")), None); + assert_eq!(parse_query_token(None), None); + } + + #[test] + fn upgrade_gate() { + let token = "0123456789abcdef0123456789abcdef0123456789abc"; + assert!(check_upgrade( + "/v1/connect", + Some("token=0123456789abcdef0123456789abcdef0123456789abc&v=1"), + token + ) + .is_ok()); + // Pair mode upgrades without a token but starts unpaired. + assert_eq!( + check_upgrade("/v1/connect", Some("pair=1"), token), + Ok(Upgrade::Pairing) + ); + // Wrong token, missing token, empty token. + assert_eq!( + check_upgrade("/v1/connect", Some("token=wrong"), token), + Err((401, "bad pairing token")) + ); + assert_eq!( + check_upgrade("/v1/connect", Some("v=1"), token), + Err((401, "bad pairing token")) + ); + assert_eq!( + check_upgrade("/v1/connect", None, token), + Err((401, "bad pairing token")) + ); + // Wrong path (e.g. a tailscale-serve health probe on /). + assert_eq!( + check_upgrade( + "/", + Some("token=0123456789abcdef0123456789abcdef0123456789abc"), + token + ), + Err((404, "not found")) + ); + } + + #[test] + fn safari_get_slash_is_health() { + let head = b"GET / HTTP/1.1\r\nHost: 192.168.4.191:17233\r\n\r\n"; + assert_eq!(classify_http_head(head), HeadKind::Health); + assert!(!is_websocket_upgrade(head)); + let health = b"GET /health HTTP/1.1\r\nHost: 192.168.4.191:17233\r\n\r\n"; + assert_eq!(classify_http_head(health), HeadKind::Health); + let ws = b"GET /v1/connect HTTP/1.1\r\nHost: 192.168.4.191:17233\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"; + assert!(is_websocket_upgrade(ws)); + assert_eq!(classify_http_head(ws), HeadKind::Other); + } + + #[tokio::test(flavor = "current_thread")] + async fn tungstenite_client_handshakes_through_prefixed_head() { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let head = read_http_head(&mut stream).await.unwrap(); + assert!( + is_websocket_upgrade(&head), + "head={}", + String::from_utf8_lossy(&head) + ); + let replay = PrefixedStream { + prefix: Cursor::new(head), + inner: stream, + }; + tokio_tungstenite::accept_async(replay).await.unwrap(); + }); + let url = format!("ws://{addr}/v1/connect?pair=1"); + let tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); + let request = url.into_client_request().unwrap(); + let (_ws, _response) = tokio_tungstenite::client_async(request, tcp).await.unwrap(); + server.await.unwrap(); + } + + #[test] + fn forwarded_events_cover_the_streaming_bridges() { + // The TS bridges in harness/child.ts and pty.ts subscribe to these; + // dropping one here silently breaks companion realtime sync. + for name in [ + "harness-stdout", + "harness-stderr", + "harness-exit", + "harness-sse", + "harness-sse-end", + "pty-data", + "pty-exit", + "session-store-changed", + ] { + assert!( + FORWARDED_EVENTS.contains(&name), + "missing forwarded event: {name}" + ); + } + } +} diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index e999bf21..09a08236 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -5,7 +5,50 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, State}; + +const SESSION_STORE_CHANGED: &str = "session-store-changed"; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SessionStoreChanged { + Upserted { + summary: Box, + }, + Deleted { + #[serde(rename = "sessionId")] + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + }, + Archived { + #[serde(rename = "sessionId")] + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + archived: bool, + }, + Pinned { + #[serde(rename = "sessionId")] + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + pinned: bool, + }, +} + +fn emit_store_changed(app: &AppHandle, payload: SessionStoreChanged) { + let _ = app.emit(SESSION_STORE_CHANGED, payload); +} + +fn session_cwd(conn: &Connection, session_id: &str) -> Option { + conn.query_row( + "SELECT cwd FROM sessions WHERE id = ?1", + params![session_id], + |row| row.get(0), + ) + .ok() +} const MIGRATION_V1: &str = r#" CREATE TABLE IF NOT EXISTS sessions ( @@ -145,6 +188,7 @@ pub struct SessionRecord { #[tauri::command(async)] pub fn session_upsert( + app: AppHandle, store: State<'_, SessionStore>, session: SessionUpsert, ) -> Result { @@ -164,8 +208,17 @@ pub fn session_upsert( return Err("blocks must be an array".into()); } - let conn = store.conn.lock().map_err(|_| "Session store is locked")?; - upsert_session(&conn, &session).map_err(|e| e.to_string()) + let summary = { + let conn = store.conn.lock().map_err(|_| "Session store is locked")?; + upsert_session(&conn, &session).map_err(|e| e.to_string())? + }; + emit_store_changed( + &app, + SessionStoreChanged::Upserted { + summary: Box::new(summary.clone()), + }, + ); + Ok(summary) } #[tauri::command(async)] @@ -238,32 +291,70 @@ pub fn session_search( } #[tauri::command(async)] -pub fn session_delete(store: State<'_, SessionStore>, session_id: String) -> Result<(), String> { +pub fn session_delete( + app: AppHandle, + store: State<'_, SessionStore>, + session_id: String, +) -> Result<(), String> { validate_id(&session_id, "session")?; - let conn = store.conn.lock().map_err(|_| "Session store is locked")?; - delete_session(&conn, &session_id).map_err(|e| e.to_string()) + let cwd = { + let conn = store.conn.lock().map_err(|_| "Session store is locked")?; + let cwd = session_cwd(&conn, &session_id); + delete_session(&conn, &session_id).map_err(|e| e.to_string())?; + cwd + }; + emit_store_changed(&app, SessionStoreChanged::Deleted { session_id, cwd }); + Ok(()) } #[tauri::command(async)] pub fn session_set_archived( + app: AppHandle, store: State<'_, SessionStore>, session_id: String, archived: bool, ) -> Result<(), String> { validate_id(&session_id, "session")?; - let conn = store.conn.lock().map_err(|_| "Session store is locked")?; - set_archived(&conn, &session_id, archived).map_err(|e| e.to_string()) + let cwd = { + let conn = store.conn.lock().map_err(|_| "Session store is locked")?; + let cwd = session_cwd(&conn, &session_id); + set_archived(&conn, &session_id, archived).map_err(|e| e.to_string())?; + cwd + }; + emit_store_changed( + &app, + SessionStoreChanged::Archived { + session_id, + cwd, + archived, + }, + ); + Ok(()) } #[tauri::command(async)] pub fn session_set_pinned( + app: AppHandle, store: State<'_, SessionStore>, session_id: String, pinned: bool, ) -> Result<(), String> { validate_id(&session_id, "session")?; - let conn = store.conn.lock().map_err(|_| "Session store is locked")?; - set_pinned(&conn, &session_id, pinned).map_err(|e| e.to_string()) + let cwd = { + let conn = store.conn.lock().map_err(|_| "Session store is locked")?; + let cwd = session_cwd(&conn, &session_id); + set_pinned(&conn, &session_id, pinned).map_err(|e| e.to_string())?; + cwd + }; + emit_store_changed( + &app, + SessionStoreChanged::Pinned { + session_id, + cwd, + pinned, + }, + ); + Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/tailnet_embed.rs b/src-tauri/src/tailnet_embed.rs new file mode 100644 index 00000000..28d4fb54 --- /dev/null +++ b/src-tauri/src/tailnet_embed.rs @@ -0,0 +1,322 @@ +// Embedded tailnet node (tailscale-rs) — desktop-only, EXPERIMENTAL. +// +// MERGE NOTE: desktop-gated module (`mod` is cfg'd out on mobile), calls +// existing code without changing it. Depends on the `tailscale` crate, which +// is itself desktop-only in Cargo.toml. +// +// What this is: MonoCode joins the official tailnet as its own "monocode" +// device and serves the companion WebSocket protocol directly on its tailnet +// IP — no Tailscale CLI, daemon, or `tailscale serve` needed on the Mac. +// Google SSO happens through the interactive login URL the node returns +// (open it in a browser, log in with Google), or via a pasted auth key. +// +// Hard limits (from Tailscale's own docs — not our choice): +// - tailscale-rs is pre-1.0, unaudited, DERP-only: treat this transport as +// experimental. Frame auth (pairing token) still applies, but there is no +// TLS on this path yet. System `tailscale serve` is opt-in; this node +// is the default Google-login path. +// - iOS is unsupported by this crate. The iPad uses userspace tsnet +// (`tsnet_mobile.rs`) with the same Google SSO flow, no Tailscale app. +// - No MagicDNS in the crate: the iPad pairs with the node's tailnet IP +// (stable per node, shown in Settings). + +use tauri::{AppHandle, Manager}; + +use crate::remote::{EmbedSnapshot, RemoteState}; + +const CONFIG_FILE: &str = "tailnet.json"; +const KEY_FILE: &str = "tailnet_keys.json"; +const CLIENT_NAME: &str = "monocode"; + +/// Required by the crate until its third-party audit lands. Set +/// programmatically so the desktop app, not the user's shell, owns it. +const EXPERIMENT_ENV: &str = "TS_RS_EXPERIMENT"; +const EXPERIMENT_VALUE: &str = "this_is_unstable_software"; + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct EmbedConfig { + #[serde(default)] + auth_key: Option, + /// Set on start, cleared on stop: boot restores a wanted node. + #[serde(default)] + wanted: bool, + /// Advertised ACL tags, e.g. ["tag:monocode"]. Empty = untagged node. + #[serde(default)] + tags: Vec, +} + +/// Tag syntax enforced client-side so control-plane rejections (and the +/// admin approval they trigger) never come as a surprise. +pub fn validate_tag(tag: &str) -> Result<(), String> { + let trimmed = tag.trim(); + if !trimmed.starts_with("tag:") { + return Err(format!("Tag must look like tag:name, got {trimmed:?}")); + } + let name = &trimmed["tag:".len()..]; + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(format!( + "Tag names use lowercase letters, digits, and dashes: {trimmed:?}" + )); + } + Ok(()) +} + +fn config_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("companion"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join(CONFIG_FILE)) +} + +fn key_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("companion"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join(KEY_FILE)) +} + +fn load_config(app: &AppHandle) -> EmbedConfig { + let Ok(path) = config_path(app) else { + return EmbedConfig::default(); + }; + let Ok(raw) = std::fs::read_to_string(&path) else { + return EmbedConfig::default(); + }; + serde_json::from_str(&raw).unwrap_or_default() +} +fn save_config(app: &AppHandle, config: &EmbedConfig) -> Result<(), String> { + let path = config_path(app)?; + let raw = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?; + std::fs::write(&path, raw).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// Flip the boot-restore flag without touching keys or tags. Used by link +/// disable so a stopped link stays stopped across restarts. +pub(crate) fn set_wanted(app: &AppHandle, wanted: bool) { + let mut saved = load_config(app); + saved.wanted = wanted; + let _ = save_config(app, &saved); +} + +fn secret_path(path: &std::path::Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Start (or restart) the embedded node. `None` keeps the saved auth key. +/// Always joins the official tailnet (login.tailscale.com). +pub async fn start( + app: AppHandle, + auth_key: Option, + tags: Vec, +) -> Result { + let mut saved = load_config(&app); + if auth_key.is_some() { + saved.auth_key = auth_key.filter(|key| !key.trim().is_empty()); + } + let mut seen = std::collections::HashSet::new(); + let mut clean_tags = Vec::new(); + for tag in &tags { + validate_tag(tag)?; + let normalized = tag.trim().to_string(); + if seen.insert(normalized.clone()) { + clean_tags.push(normalized); + } + } + saved.tags = clean_tags; + saved.wanted = true; + save_config(&app, &saved)?; + + let state: tauri::State<'_, RemoteState> = app.state(); + if let Some(previous) = state.embed_take_task() { + previous.abort(); + } + state.embed_update_snapshot(EmbedSnapshot { + running: true, + ..EmbedSnapshot::default() + }); + std::env::set_var(EXPERIMENT_ENV, EXPERIMENT_VALUE); + let task = tauri::async_runtime::spawn(run(app.clone())); + state.embed_replace_task(task); + Ok(state.embed_snapshot()) +} + +/// Stop the embedded node. The tailnet forgets nothing; restarting reuses +/// the saved keys and rejoins as the same device. Note: aborting drops the +/// Device without a graceful shutdown, so the experimental runtime may log +/// teardown panics in background threads on stop — contained noise, the app +/// itself is unaffected. +pub async fn stop(app: AppHandle) -> Result { + let state: tauri::State<'_, RemoteState> = app.state(); + if let Some(task) = state.embed_take_task() { + task.abort(); + } + let mut saved = load_config(&app); + saved.wanted = false; + let _ = save_config(&app, &saved); + state.embed_update_snapshot(EmbedSnapshot::default()); + Ok(state.embed_snapshot()) +} + +/// Forget this device on the tailnet and bring Google sign-in back. +pub async fn logout(app: AppHandle) -> Result { + let _ = stop(app.clone()).await; + if let Ok(path) = key_path(&app) { + let _ = std::fs::remove_file(path); + } + let mut saved = load_config(&app); + saved.auth_key = None; + saved.wanted = true; + let _ = save_config(&app, &saved); + start(app, None, Vec::new()).await +} + +/// Boot-time restore: if the node was wanted when the app last ran, start +/// it again without opening Settings. Called from setup(). +pub fn autostart(app: &AppHandle) { + if !load_config(app).wanted { + return; + } + let state: tauri::State<'_, RemoteState> = app.state(); + if state.embed_snapshot().running { + return; + } + state.embed_update_snapshot(EmbedSnapshot { + running: true, + ..EmbedSnapshot::default() + }); + std::env::set_var(EXPERIMENT_ENV, EXPERIMENT_VALUE); + // tauri::spawn, not tokio::spawn: setup() runs on the main thread with + // no runtime yet, where tokio::spawn panics ("no reactor running"). + let task = tauri::async_runtime::spawn(run(app.clone())); + if let Some(previous) = state.embed_replace_task(task) { + previous.abort(); + } +} + +async fn run(app: AppHandle) { + if let Err(error) = run_inner(&app).await { + let state: tauri::State<'_, RemoteState> = app.state(); + let mut snapshot = state.embed_snapshot(); + snapshot.running = false; + snapshot.error = Some(error); + state.embed_update_snapshot(snapshot); + } +} + +async fn run_inner(app: &AppHandle) -> Result<(), String> { + // Pin the process-wide rustls provider BEFORE any TLS: the tree enables + // multiple providers, and without an explicit default the first control + // connection panics ("could not automatically determine CryptoProvider"). + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let saved = load_config(app); + let key_file = secret_path(&key_path(app)?); + let mut config = tailscale::Config::default_with_key_file(&key_file) + .await + .map_err(|e| format!("Tailnet key storage failed: {e}"))?; + // Official control plane only. Hostname/client name identify the node. + config.requested_hostname = Some(crate::remote::node_hostname()); + config.client_name = Some(CLIENT_NAME.to_string()); + // Tags gate the node into tailnet ACLs (tagOwners + grants). Requesting + // an unpermitted tag fails registration with a clear control error. + config.requested_tags = saved.tags.clone(); + + let device = tailscale::Device::new(&config, saved.auth_key.clone()) + .await + .map_err(|e| format!("Tailnet node failed to start: {e}"))?; + + // Interactive auth: poll until the control plane authorizes us. Each + // poll returns the current state plus the browser URL for Google SSO. + loop { + match device.is_authorized().await { + Ok(tailscale::AuthState::Authorized) => break, + Ok(tailscale::AuthState::NotAuthorized(url)) => { + set_snapshot(app, |snapshot| { + snapshot.running = true; + snapshot.authorized = false; + snapshot.login_url = Some(url.to_string()); + snapshot.error = None; + snapshot.hostname = Some(crate::remote::node_hostname()); + }); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + Err(error) => { + return Err(format!("Tailnet authorization failed: {error}")); + } + } + } + + let ip = device + .ipv4_addr() + .await + .map_err(|e| format!("Tailnet address unavailable: {e}"))?; + set_snapshot(app, |snapshot| { + snapshot.running = true; + snapshot.authorized = true; + snapshot.tailnet_ip = Some(ip.to_string()); + snapshot.login_url = None; + snapshot.error = None; + snapshot.hostname = Some(crate::remote::node_hostname()); + }); + + let state: tauri::State<'_, RemoteState> = app.state(); + let port = state.companion_port(); + let token = state.pairing_token(app)?; + let listener = device + .tcp_listen((ip, port).into()) + .await + .map_err(|e| format!("Tailnet listen on {ip}:{port} failed: {e}"))?; + + loop { + let stream = listener + .accept() + .await + .map_err(|e| format!("Tailnet accept failed: {e}"))?; + eprintln!("companion: tailnet accept {ip}:{port}"); + let app = app.clone(); + let token = token.clone(); + let shared = state.companion_shared(); + tokio::spawn(async move { + crate::remote_server::serve_connection(app, stream, token, shared).await; + }); + } +} + +fn set_snapshot(app: &AppHandle, update: impl FnOnce(&mut EmbedSnapshot)) { + let state: tauri::State<'_, RemoteState> = app.state(); + let mut snapshot = state.embed_snapshot(); + update(&mut snapshot); + state.embed_update_snapshot(snapshot); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tag_validation() { + assert!(validate_tag("tag:monocode").is_ok()); + assert!(validate_tag(" tag:monocode-2 ").is_ok()); + assert!(validate_tag("monocode").is_err()); + assert!(validate_tag("tag:").is_err()); + assert!(validate_tag("tag:HasCaps").is_err()); + assert!(validate_tag("tag:has space").is_err()); + assert!(validate_tag("tag:has_underscore").is_err()); + } +} diff --git a/src-tauri/src/tsnet_mobile.rs b/src-tauri/src/tsnet_mobile.rs new file mode 100644 index 00000000..7e18989b --- /dev/null +++ b/src-tauri/src/tsnet_mobile.rs @@ -0,0 +1,299 @@ +// Userspace Tailscale (tsnet c-archive) for the iPad companion. +// +// MERGE NOTE: ios-gated module. The iPad joins login.tailscale.com as +// `monocode-ipad` and dials the Mac's tailnet IP without the Tailscale iOS +// app and without a Network Extension / VPN entitlement. + +use std::ffi::{CStr, CString}; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::sync::Mutex; + +use tauri::{AppHandle, Manager}; + +use crate::remote::{EmbedSnapshot, RemoteState}; + +const CONFIG_FILE: &str = "tailnet.json"; + +#[link(name = "monocode_tsnet")] +extern "C" { + fn monocode_tsnet_new() -> i32; + fn monocode_tsnet_start(sd: i32) -> i32; + fn monocode_tsnet_close(sd: i32) -> i32; + fn monocode_tsnet_set_dir(sd: i32, dir: *const std::os::raw::c_char) -> i32; + fn monocode_tsnet_set_hostname(sd: i32, hostname: *const std::os::raw::c_char) -> i32; + fn monocode_tsnet_set_authkey(sd: i32, key: *const std::os::raw::c_char) -> i32; + fn monocode_tsnet_errmsg(sd: i32, buf: *mut std::os::raw::c_char, buflen: usize) -> i32; + fn monocode_tsnet_status_json( + sd: i32, + json_out: *mut *mut std::os::raw::c_char, + ) -> i32; + fn monocode_tsnet_dial( + sd: i32, + network: *const std::os::raw::c_char, + addr: *const std::os::raw::c_char, + conn_out: *mut i32, + ) -> i32; +} + +static HANDLE: Mutex> = Mutex::new(None); + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct EmbedConfig { + #[serde(default)] + auth_key: Option, + #[serde(default)] + wanted: bool, +} + +fn lock_handle() -> std::sync::MutexGuard<'static, Option> { + HANDLE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn config_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("companion"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join(CONFIG_FILE)) +} + +fn state_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("companion") + .join("tsnet"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn load_config(app: &AppHandle) -> EmbedConfig { + let Ok(path) = config_path(app) else { + return EmbedConfig::default(); + }; + let Ok(raw) = std::fs::read_to_string(&path) else { + return EmbedConfig::default(); + }; + serde_json::from_str(&raw).unwrap_or_default() +} + +fn save_config(app: &AppHandle, config: &EmbedConfig) -> Result<(), String> { + let path = config_path(app)?; + let raw = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?; + std::fs::write(&path, raw).map_err(|e| e.to_string())?; + Ok(()) +} + +fn set_wanted(app: &AppHandle, wanted: bool) { + let mut saved = load_config(app); + saved.wanted = wanted; + let _ = save_config(app, &saved); +} + +fn errmsg(sd: i32) -> String { + let mut buf = [0u8; 512]; + unsafe { + monocode_tsnet_errmsg(sd, buf.as_mut_ptr() as *mut _, buf.len()); + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).into_owned() +} + +fn cstr(value: &str) -> Result { + CString::new(value).map_err(|_| "tailnet string contained NUL".to_string()) +} + +fn status_json(sd: i32) -> Result { + let mut ptr: *mut std::os::raw::c_char = std::ptr::null_mut(); + let rc = unsafe { monocode_tsnet_status_json(sd, &mut ptr) }; + if rc != 0 || ptr.is_null() { + return Err(errmsg(sd).if_empty("tailnet status unavailable")); + } + let json = unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(ptr as *mut libc::c_void) }; + serde_json::from_str(&json).map_err(|e| format!("tailnet status JSON: {e}")) +} + +trait IfEmpty { + fn if_empty(self, fallback: &str) -> String; +} + +impl IfEmpty for String { + fn if_empty(self, fallback: &str) -> String { + if self.trim().is_empty() { + fallback.to_string() + } else { + self + } + } +} + +fn snapshot_from_handle(sd: i32) -> EmbedSnapshot { + match status_json(sd) { + Ok(json) => crate::remote::embed_from_status_json(&json), + Err(error) => EmbedSnapshot { + running: true, + error: Some(error), + ..EmbedSnapshot::default() + }, + } +} + +fn close_handle() { + if let Some(sd) = lock_handle().take() { + unsafe { + monocode_tsnet_close(sd); + } + } +} + +/// Dial `host:port` through the userspace node. Blocking CGO; call from +/// `spawn_blocking`. Returns a connected Unix socketpair fd. +pub fn dial_blocking(addr: &str) -> Result { + let sd = lock_handle() + .ok_or_else(|| "Sign in with Google in Companion settings first.".to_string())?; + let network = cstr("tcp")?; + let addr = cstr(addr)?; + let mut fd: i32 = -1; + let rc = unsafe { monocode_tsnet_dial(sd, network.as_ptr(), addr.as_ptr(), &mut fd) }; + if rc != 0 || fd < 0 { + return Err(errmsg(sd).if_empty("tailnet dial failed")); + } + let owned = unsafe { OwnedFd::from_raw_fd(fd) }; + let std_stream = std::os::unix::net::UnixStream::from(owned); + std_stream + .set_nonblocking(true) + .map_err(|e| format!("tailnet socket: {e}"))?; + tokio::net::UnixStream::from_std(std_stream).map_err(|e| format!("tailnet socket: {e}")) +} + +pub async fn start(app: AppHandle, auth_key: Option) -> Result { + let mut saved = load_config(&app); + if auth_key.is_some() { + saved.auth_key = auth_key.filter(|key| !key.trim().is_empty()); + } + saved.wanted = true; + save_config(&app, &saved)?; + + let dir = state_dir(&app)?; + let dir_c = cstr(&dir.to_string_lossy())?; + let host_c = cstr(&crate::remote::node_hostname())?; + let key_c = saved + .auth_key + .as_deref() + .filter(|key| !key.is_empty()) + .map(cstr) + .transpose()?; + + let state: tauri::State<'_, RemoteState> = app.state(); + if let Some(previous) = state.embed_take_task() { + previous.abort(); + } + close_handle(); + state.embed_update_snapshot(EmbedSnapshot { + running: true, + ..EmbedSnapshot::default() + }); + + let sd = tauri::async_runtime::spawn_blocking(move || { + let sd = unsafe { monocode_tsnet_new() }; + if sd == 0 { + return Err("tailnet node could not be created".to_string()); + } + if unsafe { monocode_tsnet_set_dir(sd, dir_c.as_ptr()) } != 0 { + let err = errmsg(sd); + unsafe { monocode_tsnet_close(sd) }; + return Err(err.if_empty("failed to set tailnet state dir")); + } + if unsafe { monocode_tsnet_set_hostname(sd, host_c.as_ptr()) } != 0 { + let err = errmsg(sd); + unsafe { monocode_tsnet_close(sd) }; + return Err(err.if_empty("failed to set tailnet hostname")); + } + if let Some(key) = key_c { + if unsafe { monocode_tsnet_set_authkey(sd, key.as_ptr()) } != 0 { + let err = errmsg(sd); + unsafe { monocode_tsnet_close(sd) }; + return Err(err.if_empty("failed to set tailnet auth key")); + } + } + if unsafe { monocode_tsnet_start(sd) } != 0 { + let err = errmsg(sd); + unsafe { monocode_tsnet_close(sd) }; + return Err(err.if_empty("tailnet node failed to start")); + } + Ok(sd) + }) + .await + .map_err(|e| e.to_string())??; + + *lock_handle() = Some(sd); + let snapshot = snapshot_from_handle(sd); + state.embed_update_snapshot(snapshot.clone()); + let task = tauri::async_runtime::spawn(poll_status(app.clone(), sd)); + state.embed_replace_task(task); + Ok(snapshot) +} + +pub async fn stop(app: AppHandle) -> Result { + let state: tauri::State<'_, RemoteState> = app.state(); + if let Some(task) = state.embed_take_task() { + task.abort(); + } + close_handle(); + set_wanted(&app, false); + let snapshot = EmbedSnapshot::default(); + state.embed_update_snapshot(snapshot.clone()); + Ok(snapshot) +} + +/// Wipe tsnet state so the next start asks for Google again. +pub async fn logout(app: AppHandle) -> Result { + let _ = stop(app.clone()).await; + if let Ok(dir) = state_dir(&app) { + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + } + let mut saved = load_config(&app); + saved.auth_key = None; + saved.wanted = true; + let _ = save_config(&app, &saved); + start(app, None).await +} + +pub fn autostart(app: &AppHandle) { + if !load_config(app).wanted { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = start(app, None).await { + eprintln!("companion: iPad tailnet autostart failed: {error}"); + } + }); +} + +async fn poll_status(app: AppHandle, sd: i32) { + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let still = lock_handle().is_some_and(|live| live == sd); + if !still { + break; + } + let snapshot = match tauri::async_runtime::spawn_blocking(move || snapshot_from_handle(sd)) + .await + { + Ok(snapshot) => snapshot, + Err(_) => break, + }; + let state: tauri::State<'_, RemoteState> = app.state(); + state.embed_update_snapshot(snapshot); + } +} + + diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index e57fb554..91adaea3 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -30,7 +30,9 @@ pub fn open_new_window(app: &AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] crate::macos::install(&window); - #[cfg(not(target_os = "macos"))] + // Frameless-window styling is desktop-only; the mobile shell owns its + // chrome and these methods do not exist there. + #[cfg(all(not(target_os = "macos"), desktop))] { let _ = window.set_decorations(false); let _ = window.set_shadow(true); @@ -75,6 +77,8 @@ pub fn show_hidden_or_open_new(app: &AppHandle) -> Result<(), String> { } windows.sort_by(|a, b| a.label().cmp(b.label())); for window in &windows { + // No minimize concept on mobile; `show` alone re-presents the view. + #[cfg(desktop)] let _ = window.unminimize(); let _ = window.show(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b77a1077..d46400b6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,8 +28,8 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", - "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost ws: wss: http: https:; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https://avatars.githubusercontent.com https://uploads.linear.app https://lh3.googleusercontent.com; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://ipc.localhost http://localhost:1420 ws://localhost:1420 http://127.0.0.1:1420 ws://127.0.0.1:1420 ws: wss: http: https:; media-src 'self' asset: http://asset.localhost https://asset.localhost blob:; object-src 'none'; base-uri 'self'; frame-src 'none'", "dangerousDisableAssetCspModification": ["style-src"], "assetProtocol": { "enable": true, @@ -56,6 +56,11 @@ "macOS": { "entitlements": "Entitlements.plist", "signingIdentity": "-" + }, + "iOS": { + "minimumSystemVersion": "17.0", + "developmentTeam": "5H4D7574Z4", + "infoPlist": "Info.ios.plist" } }, "plugins": { diff --git a/src-tauri/tsnet/clangwrap-ios.sh b/src-tauri/tsnet/clangwrap-ios.sh new file mode 100755 index 00000000..c2b67eb0 --- /dev/null +++ b/src-tauri/tsnet/clangwrap-ios.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# CGO compiler wrapper for an iOS device c-archive (arm64 / iphoneos). +SDK=iphoneos +PLATFORM=ios +CLANGARCH=arm64 +MIN=17.0 +SDK_PATH=$(xcrun --sdk "$SDK" --show-sdk-path) +CLANG=$(xcrun --sdk "$SDK" --find clang) +exec "$CLANG" -arch "$CLANGARCH" -isysroot "$SDK_PATH" -m${PLATFORM}-version-min=$MIN "$@" diff --git a/src-tauri/tsnet/go.mod b/src-tauri/tsnet/go.mod new file mode 100644 index 00000000..cdfa78a7 --- /dev/null +++ b/src-tauri/tsnet/go.mod @@ -0,0 +1,51 @@ +module github.com/hardbeat920/monocode/tsnet + +go 1.27.0 + +require tailscale.com v1.102.3 + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/creachadair/msync v0.8.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.1 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) diff --git a/src-tauri/tsnet/go.sum b/src-tauri/tsnet/go.sum new file mode 100644 index 00000000..87b8c473 --- /dev/null +++ b/src-tauri/tsnet/go.sum @@ -0,0 +1,236 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0 h1:IOdss+igJDFdic9w3WKwxGCmHqUxydvIhJOm9LJ32Dk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.45.0/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM= +github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM= +github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914 h1:kD8PseueGeYiid/Mmcv17Q0Qqicc4F46jcX22L/e/Hs= +github.com/insomniacslk/dhcp v0.0.0-20240129002554-15c9b8791914/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= +github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 h1:Azz5ILxxVsHN/KjIu3wkJPAmmtiijucZw4Ax5Ye8n+s= +github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU= +github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= +golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.102.3 h1:M1czCAtMuIcg+2Z+FBPbJyAk3ZEQGEFKnvHthtE1c6M= +tailscale.com v1.102.3/go.mod h1:47bv91Xbg4K1p5wti7F1dmKvUVWV5BXF78d9EWJ+d6c= diff --git a/src-tauri/tsnet/tsnet.go b/src-tauri/tsnet/tsnet.go new file mode 100644 index 00000000..7d02927e --- /dev/null +++ b/src-tauri/tsnet/tsnet.go @@ -0,0 +1,268 @@ +// Userspace Tailscale node for the iPad companion (libtailscale-style c-archive). +// +// The iPad reaches the Mac's tailnet IP without the Tailscale iOS app and +// without a Network Extension / packet-tunnel entitlement. Google SSO is +// the same login.tailscale.com flow the Mac embed uses: Start() then poll +// status JSON for AuthURL / Running. +package main + +/* +#include +*/ +import "C" + +import ( + "context" + "encoding/json" + "io" + "os" + "sync" + "syscall" + "time" + "unsafe" + + "tailscale.com/hostinfo" + "tailscale.com/tsnet" +) + +func main() {} + +var servers struct { + mu sync.Mutex + next C.int + m map[C.int]*server +} + +type server struct { + s *tsnet.Server + lastErr string + started bool +} + +type conn struct { + c netConn + r *os.File +} + +// netConn is the subset of net.Conn we copy. Avoids importing net in the +// type identity of the map (the real type is net.Conn from tsnet.Dial). +type netConn interface { + io.ReadWriteCloser +} + +var conns struct { + mu sync.Mutex + m map[C.int]*conn +} + +func getServer(sd C.int) *server { + servers.mu.Lock() + defer servers.mu.Unlock() + return servers.m[sd] +} + +func (s *server) recErr(err error) C.int { + if err == nil { + s.lastErr = "" + return 0 + } + s.lastErr = err.Error() + return -1 +} + +//export monocode_tsnet_new +func monocode_tsnet_new() C.int { + servers.mu.Lock() + defer servers.mu.Unlock() + if servers.m == nil { + servers.m = map[C.int]*server{} + hostinfo.SetApp("monocode") + } + if servers.next == 0 { + servers.next = 42<<16 + 1 + } + sd := servers.next + servers.next++ + servers.m[sd] = &server{s: &tsnet.Server{}} + return sd +} + +//export monocode_tsnet_start +func monocode_tsnet_start(sd C.int) C.int { + s := getServer(sd) + if s == nil { + return -1 + } + err := s.s.Start() + if err == nil { + s.started = true + } + return s.recErr(err) +} + +//export monocode_tsnet_close +func monocode_tsnet_close(sd C.int) C.int { + servers.mu.Lock() + s := servers.m[sd] + if s != nil { + delete(servers.m, sd) + } + servers.mu.Unlock() + if s == nil { + return -1 + } + if !s.started { + return 0 + } + if err := s.s.Close(); err != nil { + return -1 + } + return 0 +} + +//export monocode_tsnet_set_dir +func monocode_tsnet_set_dir(sd C.int, dir *C.char) C.int { + s := getServer(sd) + if s == nil { + return -1 + } + s.s.Dir = C.GoString(dir) + return 0 +} + +//export monocode_tsnet_set_hostname +func monocode_tsnet_set_hostname(sd C.int, hostname *C.char) C.int { + s := getServer(sd) + if s == nil { + return -1 + } + s.s.Hostname = C.GoString(hostname) + return 0 +} + +//export monocode_tsnet_set_authkey +func monocode_tsnet_set_authkey(sd C.int, key *C.char) C.int { + s := getServer(sd) + if s == nil { + return -1 + } + s.s.AuthKey = C.GoString(key) + return 0 +} + +//export monocode_tsnet_errmsg +func monocode_tsnet_errmsg(sd C.int, buf *C.char, buflen C.size_t) C.int { + if buf == nil || buflen == 0 { + return -1 + } + out := unsafe.Slice((*byte)(unsafe.Pointer(buf)), buflen) + s := getServer(sd) + msg := "" + if s != nil { + msg = s.lastErr + } else { + msg = "invalid tailnet handle" + } + n := copy(out, msg) + if n >= len(out) { + out[len(out)-1] = 0 + return -1 + } + out[n] = 0 + return 0 +} + +//export monocode_tsnet_status_json +func monocode_tsnet_status_json(sd C.int, jsonOut **C.char) C.int { + if jsonOut == nil { + return -1 + } + *jsonOut = nil + s := getServer(sd) + if s == nil { + return -1 + } + lc, err := s.s.LocalClient() + if err != nil { + return s.recErr(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + st, err := lc.Status(ctx) + if err != nil { + return s.recErr(err) + } + b, err := json.Marshal(st) + if err != nil { + return s.recErr(err) + } + *jsonOut = C.CString(string(b)) + return 0 +} + +func newConn(netC io.ReadWriteCloser, connOut *C.int) error { + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return err + } + r := os.NewFile(uintptr(fds[1]), "socketpair-r") + c := &conn{c: netC, r: r} + fdC := C.int(fds[0]) + + conns.mu.Lock() + if conns.m == nil { + conns.m = map[C.int]*conn{} + } + conns.m[fdC] = c + conns.mu.Unlock() + + cleanup := func() { + conns.mu.Lock() + live, ok := conns.m[fdC] + if ok && live.c == netC { + delete(conns.m, fdC) + } else { + ok = false + } + conns.mu.Unlock() + if !ok { + return + } + r.Close() + netC.Close() + } + go func() { + defer cleanup() + var b [1 << 16]byte + io.CopyBuffer(r, netC, b[:]) + _ = syscall.Shutdown(int(r.Fd()), syscall.SHUT_WR) + }() + go func() { + defer cleanup() + var b [1 << 16]byte + io.CopyBuffer(netC, r, b[:]) + _ = syscall.Shutdown(int(r.Fd()), syscall.SHUT_RD) + }() + *connOut = fdC + return nil +} + +//export monocode_tsnet_dial +func monocode_tsnet_dial(sd C.int, network, addr *C.char, connOut *C.int) C.int { + s := getServer(sd) + if s == nil { + return -1 + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + netC, err := s.s.Dial(ctx, C.GoString(network), C.GoString(addr)) + if err != nil { + return s.recErr(err) + } + s.started = true + if err := newConn(netC, connOut); err != nil { + netC.Close() + return s.recErr(err) + } + return 0 +} diff --git a/src/App.tsx b/src/App.tsx index 64138e1e..2a58a958 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke, isCompanionClient, isRemote, listen } from "./lib/transport"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { ask, message } from "@tauri-apps/plugin-dialog"; +import { askDialog as ask, messageDialog as message } from "./lib/transport/dialog"; import { useCallback, useEffect, @@ -17,6 +17,7 @@ import { WhatsNewDialog } from "./chrome/WhatsNewDialog"; import { TitleBar, type Tab as TitleTab } from "./chrome/TitleBar"; import { MenuBar } from "./chrome/MenuBar"; import { FilePicker } from "./chrome/FilePicker"; +import { RemoteProjectPicker } from "./chrome/RemoteProjectPicker"; import { UsageFooter } from "./chrome/UsageFooter"; import { useProjectBranches } from "./hooks/useProjectBranches"; import { @@ -25,7 +26,7 @@ import { saveProjectRailOpen, type SidebarTabId, } from "./lib/appearance"; -import { IS_MAC } from "./lib/platform"; +import { IS_MAC, IS_MACOS } from "./lib/platform"; import { runUpdateFlow } from "./lib/updater"; import { displayAttachments, prepareAttachments } from "./lib/attachments"; import { @@ -252,6 +253,8 @@ import { setSessionPinned, shouldPersistSession, upsertSession, + parseSessionStoreChanged, + SESSION_STORE_CHANGED, type SessionSummary, } from "./lib/sessionStore"; import { syncDockBadge } from "./lib/dockBadge"; @@ -304,6 +307,7 @@ import { NotesView } from "./surfaces/NotesView"; import { inboxComposerCard, type InboxItem } from "./lib/githubTasks"; import { linearIssueDetails, peekLinearIssueDetails } from "./lib/linear"; import { + clampSettingsSection, loadLiveAgentsEnabled, loadNotesEnabled, loadDiffViewer, @@ -603,12 +607,18 @@ export default function App({ const [settingsOpen, setSettingsOpen] = useState(false); const [updateNotice, setUpdateNotice] = useState(installedUpdate); const [whatsNewVersion, setWhatsNewVersion] = useState(null); - const [settingsSection, setSettingsSection] = - useState(loadSettingsSection); + const [settingsSection, setSettingsSection] = useState( + () => clampSettingsSection(loadSettingsSection(), isCompanionClient()), + ); + const shownSettingsSection = clampSettingsSection( + settingsSection, + isCompanionClient(), + ); const [editorNavigation, setEditorNavigation] = useState(null); const editorNavigationToken = useRef(0); const [filePickerOpen, setFilePickerOpen] = useState(false); + const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [dirtyFiles, setDirtyFiles] = useState>( () => new Set(windowTransfer?.dirtyFileIds ?? []), ); @@ -979,6 +989,9 @@ export default function App({ }, [sessions]); useEffect(() => { + // Companion has no OS window: focus tracking runs through the + // visibilitychange listener below instead. + if (isRemote()) return; let unlisten: (() => void) | undefined; void getCurrentWindow() .onFocusChanged(({ payload: focused }) => { @@ -1004,6 +1017,9 @@ export default function App({ }, [flushHarnessEvents]); useEffect(() => { + // Companion has no OS window to close: quitting is leaving the page, + // and transcripts persist continuously on the host. + if (isRemote()) return; let unlistenClose: (() => void) | undefined; const releaseQuit = setQuitWorkspace( () => sessionsRef.current, @@ -1073,6 +1089,34 @@ export default function App({ void refreshHistory(sidebarCwd); }, [sidebarCwd, refreshHistory]); + useEffect(() => { + const unlisten = listen(SESSION_STORE_CHANGED, (event) => { + const payload = parseSessionStoreChanged(event.payload); + if (!payload) return; + switch (payload.kind) { + case "upserted": + if (sameProjectPath(payload.summary.cwd, sidebarCwdRef.current)) { + setHistory((current) => + mergeProjectHistorySummary(current, payload.summary), + ); + } + return; + case "deleted": + case "archived": + case "pinned": + void refreshHistory(sidebarCwdRef.current); + return; + default: { + const _exhaustive: never = payload; + void _exhaustive; + } + } + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, [refreshHistory]); + useEffect(() => { prefetchProjectFiles(sidebarCwd); }, [sidebarCwd]); @@ -2930,6 +2974,12 @@ export default function App({ ); const pickProject = useCallback(async () => { + // Companion: the native sheet would open on the iPad, so browse the + // host filesystem in-app instead. + if (isRemote()) { + setProjectPickerOpen(true); + return; + } const path = await pickFolder(); if (path) onSelectProject(path); }, [onSelectProject]); @@ -4816,7 +4866,7 @@ export default function App({ return (
- {!IS_MAC ? ( + {!IS_MAC && !isCompanionClient() ? ( ) : null} + {projectPickerOpen ? ( + { + setProjectPickerOpen(false); + onSelectProject(path); + }} + onClose={() => setProjectPickerOpen(false)} + /> + ) : null} + new Set(slashItems.map((skill) => skill.invocation)), [slashItems], @@ -1039,7 +1044,7 @@ export function Composer({ }; const attachFromPicker = () => { - if (!attachmentsSupported) return; + if (!pickerSupported) return; void pickAttachments().then((files) => { addAttachments(files); ref.current?.focus(); @@ -1281,7 +1286,7 @@ export function Composer({

diff --git a/src/chrome/GitChangesPanel.tsx b/src/chrome/GitChangesPanel.tsx index b31c8434..2ee14ff5 100644 --- a/src/chrome/GitChangesPanel.tsx +++ b/src/chrome/GitChangesPanel.tsx @@ -1,4 +1,4 @@ -import { ask } from "@tauri-apps/plugin-dialog"; +import { askDialog as ask } from "../lib/transport/dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { Check, diff --git a/src/chrome/MenuBar.tsx b/src/chrome/MenuBar.tsx index 1570ca4e..92003b47 100644 --- a/src/chrome/MenuBar.tsx +++ b/src/chrome/MenuBar.tsx @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke, isCompanionClient, isRemote } from "../lib/transport"; import { useCallback, useEffect, useRef, useState } from "react"; import { ExplorerMenu, type ExplorerMenuItem } from "./ExplorerMenu"; import { ALT, MOD, SHIFT } from "../lib/platform"; @@ -168,12 +169,19 @@ export function MenuBar({ ); const getMenuItems = (key: MenuKey): ExplorerMenuItem[] => { + // Companion has no windows of its own and updates through the App + // Store: drop both entries instead of showing dead actions. + const remote = isRemote() || isCompanionClient(); switch (key) { case "file": return [ { kind: "item", id: "new_tab", label: "New Tab", shortcut: `${MOD}T` }, { kind: "item", id: "new_terminal", label: "New Terminal", shortcut: `${MOD}\`` }, - { kind: "item", id: "new_window", label: "New Window", shortcut: `${MOD}${SHIFT}N` }, + ...(remote + ? [] + : [ + { kind: "item" as const, id: "new_window", label: "New Window", shortcut: `${MOD}${SHIFT}N` }, + ]), { kind: "sep" }, { kind: "item", id: "open_project", label: "Open Project…", shortcut: `${MOD}O` }, { kind: "item", id: "open_search", label: "Search…", shortcut: `${MOD}K` }, @@ -187,8 +195,12 @@ export function MenuBar({ label: "Close Other Tabs", shortcut: `${MOD}${ALT}T`, }, - { kind: "sep" }, - { kind: "item", id: "check_for_updates", label: "Check for Updates…" }, + ...(remote + ? [] + : [ + { kind: "sep" as const }, + { kind: "item" as const, id: "check_for_updates", label: "Check for Updates…" }, + ]), ]; case "view": return [ diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index 1fe7f6b1..2bc1ad37 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -29,7 +29,7 @@ import { saveProjectRailWidth, } from "../lib/appearance"; import { basename, revealPath, type GitDiffStats } from "../lib/fs"; -import { IS_MAC, MOD } from "../lib/platform"; +import { IS_MAC, IS_MACOS, MOD, SHOW_KEY_SHORTCUTS } from "../lib/platform"; import { projectName } from "../lib/paths"; import { collectRailProjects, @@ -360,7 +360,7 @@ export function ProjectRail({ className="flex h-10 shrink-0 select-none items-center pr-1.5" data-tauri-drag-region="deep" > - {IS_MAC ?
: null} + {IS_MACOS ?
: null}
diff --git a/src/chrome/RemoteProjectPicker.tsx b/src/chrome/RemoteProjectPicker.tsx new file mode 100644 index 00000000..10dfe519 --- /dev/null +++ b/src/chrome/RemoteProjectPicker.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { homeDir, listDir, type FsEntry } from "../lib/fs"; +import { LAYER } from "../lib/layers"; +import { displayPath } from "../lib/paths"; + +type Props = { + open: boolean; + initialCwd: string; + onSelect: (path: string) => void; + onClose: () => void; +}; + +/** + * Host-side project picker for companion mode, where the native folder + * sheet would open on the wrong device. Browses host directories through + * `list_dir` (which already routes remotely) instead of `pickFolder`. + */ +export function RemoteProjectPicker({ + open, + initialCwd, + onSelect, + onClose, +}: Props) { + const [cwd, setCwd] = useState(initialCwd); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setError(null); + setLoading(true); + let cancelled = false; + void (async () => { + try { + const start = + initialCwd && initialCwd !== "~" ? initialCwd : await homeDir(); + if (cancelled) return; + setCwd(start); + setEntries(await listDir(start)); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, initialCwd]); + + const navigate = useCallback(async (path: string) => { + setError(null); + setLoading(true); + try { + setEntries(await listDir(path)); + setCwd(path); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + if (!open) return null; + + const parent = + cwd === "/" ? null : cwd.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "/"; + const dirs = entries.filter((entry) => entry.isDir); + + return createPortal( +
+
+
e.stopPropagation()} + className="absolute left-1/2 top-[12%] flex max-h-[70vh] w-[min(560px,calc(100vw-24px))] -translate-x-1/2 flex-col overflow-hidden rounded-lg border border-content/10 bg-content/5 backdrop-blur-xl" + > +
+
+ Open project on host +
+

+ {displayPath(cwd)} +

+
+
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : ( + <> + {parent ? ( + void navigate(parent)} /> + ) : null} + {dirs.map((entry) => ( + void navigate(entry.path)} + /> + ))} + {dirs.length === 0 && !parent ? ( +

+ No folders here +

+ ) : null} + + )} +
+
+ + +
+
+
, + document.body, + ); +} + +function Row({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/src/chrome/SettingsRail.tsx b/src/chrome/SettingsRail.tsx index 02a60ec9..67c4750e 100644 --- a/src/chrome/SettingsRail.tsx +++ b/src/chrome/SettingsRail.tsx @@ -2,14 +2,16 @@ import { Archive, ArrowLeft, Bot, + ExternalLink, Keyboard, Palette, SlidersHorizontal, type IconComponent, } from "./icons"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; +import { isCompanionClient } from "../lib/transport"; import { - SETTINGS_SECTIONS, + visibleSettingsSections, type SettingsSectionId, } from "../lib/settings"; @@ -19,6 +21,7 @@ const SECTION_ICONS: Record = { keybindings: Keyboard, providers: Bot, archive: Archive, + companion: ExternalLink, }; type Props = { @@ -30,6 +33,9 @@ type Props = { /** Body of the project rail while settings are open. */ export function SettingsNav({ section, onSelect, onClose }: Props) { const lockOverscroll = useLockOverscroll(); + // iPad / companion: keyboard shortcuts don't apply, so the keybindings + // section stays hidden even after disconnect (`isRemote()` is then false). + const visible = visibleSettingsSections(isCompanionClient()); return ( <> @@ -38,7 +44,7 @@ export function SettingsNav({ section, onSelect, onClose }: Props) { aria-label="Settings" className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-none px-2 pb-2" > - {SETTINGS_SECTIONS.map((item) => ( + {visible.map((item) => ( - {IS_MAC ?
: null} + {IS_MACOS ?
: null}
diff --git a/src/chrome/SidebarUpdate.tsx b/src/chrome/SidebarUpdate.tsx index 02fe933f..9254cd68 100644 --- a/src/chrome/SidebarUpdate.tsx +++ b/src/chrome/SidebarUpdate.tsx @@ -1,5 +1,6 @@ import { ArrowDownCircle, Loader, RefreshCw } from "./icons"; import { useCallback, useEffect, useState } from "react"; +import { isCompanionClient } from "../lib/transport"; import { installPendingUpdate, probeForUpdate, @@ -19,6 +20,8 @@ export function SidebarUpdateFooter({ onOpenWhatsNew?: (version: string) => void; onDismissUpdate?: () => void; }) { + // Companions update through the App Store, never self-update. + if (isCompanionClient()) return null; return (
{update && onOpenWhatsNew && onDismissUpdate ? ( diff --git a/src/chrome/TitleBar.tsx b/src/chrome/TitleBar.tsx index a978a591..0f1583a7 100644 --- a/src/chrome/TitleBar.tsx +++ b/src/chrome/TitleBar.tsx @@ -31,7 +31,7 @@ import { HarnessIcon } from "./HarnessIcon"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { TerminalSpinner } from "./TerminalSpinner"; import { WindowControls } from "./WindowControls"; -import { IS_MAC, MOD } from "../lib/platform"; +import { IS_MAC, IS_MACOS, MOD } from "../lib/platform"; import type { RecentProject } from "../lib/recents"; export type Tab = { @@ -670,7 +670,7 @@ function TitleBarComponent({ title bar takes over the traffic lights and the rail toggle. */} {projectless && railClosed ? ( <> -
+ {IS_MACOS ?
: null}
+ {title} + + ); +} + +export function Row({ + label, + description, + children, +}: { + label: ReactNode; + description?: string; + children?: ReactNode; +}) { + return ( +
+
+
{label}
+ {description ? ( +

+ {description} +

+ ) : null} +
+
+ {children} +
+
+ ); +} + +export function Toggle({ + label, + on, + onChange, + disabled = false, +}: { + label: string; + on: boolean; + onChange: (on: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function SecondaryButton({ + onClick, + disabled = false, + danger = false, + children, +}: { + onClick: () => void; + disabled?: boolean; + danger?: boolean; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/src/index.css b/src/index.css index 1378ce63..bab57e2f 100644 --- a/src/index.css +++ b/src/index.css @@ -101,6 +101,19 @@ html.is-mac #root { background: transparent; } +/* iPad WKWebView has no desktop blur behind the page. Transparent + fills flash to the system surface on every layout. */ +html.is-ios, +html.is-ios body, +html.is-ios #root, +html.is-ios #boot-splash { + background: var(--color-background-base); +} + +html.is-ios.glass-body .body-glass { + background: var(--color-background-base); +} + .sidebar-glass { background: color-mix(in srgb, var(--color-background-base) 90%, black); } @@ -1161,3 +1174,30 @@ header[data-tauri-drag-region] button { var(--color-background-base) ); } + +/* ---- Companion touch hardening (additive; desktop unaffected) ---- */ + +/* Kill the double-tap-zoom delay on every interactive element. */ +button, +[role="button"], +input, +select, +textarea, +a { + touch-action: manipulation; +} + +/* CodeMirror/xterm keep their own touch handling for editing gestures. */ +.cm-editor, +.xterm { + touch-action: auto; +} + +#root { + padding-top: env(safe-area-inset-top); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); +} + + diff --git a/src/lib/appLifecycle.ts b/src/lib/appLifecycle.ts index f6199da0..3a554e76 100644 --- a/src/lib/appLifecycle.ts +++ b/src/lib/appLifecycle.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { ask } from "@tauri-apps/plugin-dialog"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; +import { askDialog as ask } from "./transport/dialog"; import { bindHarnessSession, forgetHarnessSession, diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index 3647f755..e72eba67 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { IS_MAC } from "./platform"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; +import { IS_IPAD, IS_MACOS } from "./platform"; const THEME_HUE_KEY = "monocode.themeHue"; const THEME_SATURATION_KEY = "monocode.themeSaturation"; @@ -154,13 +155,14 @@ export function applyThemeTint(hue: number, saturation: number) { } export function initAppearance() { - document.documentElement.classList.toggle("is-mac", IS_MAC); + document.documentElement.classList.toggle("is-mac", IS_MACOS); + document.documentElement.classList.toggle("is-ios", IS_IPAD); applyThemeTint(loadThemeHue(), loadThemeSaturation()); applyThemePreference(loadThemePreference()); watchSystemColorScheme(); applySidebarOpacity(loadSidebarOpacity()); applySidebarBlur(loadSidebarBlur()); - applyBodyGlass(loadBodyGlass()); + applyBodyGlass(IS_IPAD ? false : loadBodyGlass()); } function isThemePreference(value: unknown): value is ThemePreference { diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts index 2ed334d7..d76cac5a 100644 --- a/src/lib/attachments.ts +++ b/src/lib/attachments.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { basename, pickFiles as pickFilePaths } from "./fs"; import type { Attachment, AttachmentKind } from "./session"; diff --git a/src/lib/checkpoint.ts b/src/lib/checkpoint.ts index 207d66b6..c3faf869 100644 --- a/src/lib/checkpoint.ts +++ b/src/lib/checkpoint.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; export type CheckpointFile = { path: string; diff --git a/src/lib/dockBadge.ts b/src/lib/dockBadge.ts index 673d160b..77dd26a1 100644 --- a/src/lib/dockBadge.ts +++ b/src/lib/dockBadge.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { sessionNeedsInput, type Session } from "./session"; let lastCount = -1; diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 4bd206e9..e0c4db68 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { open } from "@tauri-apps/plugin-dialog"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; +import { openDialog as open } from "./transport/dialog"; export type FsEntry = { name: string; diff --git a/src/lib/githubTasks.ts b/src/lib/githubTasks.ts index 0131641f..ff11768d 100644 --- a/src/lib/githubTasks.ts +++ b/src/lib/githubTasks.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { linearConnected, linearTeamIdsForFetch, diff --git a/src/lib/harness/child.ts b/src/lib/harness/child.ts index 240a5525..02edf17c 100644 --- a/src/lib/harness/child.ts +++ b/src/lib/harness/child.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +// TRANSPORT SEAM: same invoke/listen signatures as Tauri; in companion mode +// these transparently forward to the paired host. See src/lib/transport/. +import { invoke, listen, type UnlistenFn } from "../transport"; type LinePayload = { sessionId: string; line: string }; type ExitPayload = { sessionId: string; code: number | null; pid?: number }; diff --git a/src/lib/harness/cursorStore.ts b/src/lib/harness/cursorStore.ts index 77debdcb..b7a4d1f5 100644 --- a/src/lib/harness/cursorStore.ts +++ b/src/lib/harness/cursorStore.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "../transport"; export type StoredCursorToolCall = { toolCallId: string; diff --git a/src/lib/inboxMedia.ts b/src/lib/inboxMedia.ts index 428bbae7..b4e09c72 100644 --- a/src/lib/inboxMedia.ts +++ b/src/lib/inboxMedia.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { sniffImageMime } from "./filePreview"; /** diff --git a/src/lib/linear.ts b/src/lib/linear.ts index 0af2d428..d6dfa30a 100644 --- a/src/lib/linear.ts +++ b/src/lib/linear.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; export type LinearTeam = { id: string; diff --git a/src/lib/notes.ts b/src/lib/notes.ts index 200238a5..28024e50 100644 --- a/src/lib/notes.ts +++ b/src/lib/notes.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { fuzzyMatch } from "./fuzzy"; import type { ProjectFile } from "./fs"; import type { RankedFile } from "./fileIndex"; diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 3747d89b..8bcc5376 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,7 +1,35 @@ +const USER_AGENT = + typeof navigator !== "undefined" ? navigator.userAgent : ""; + +/** True on iPad, including iPads masquerading as MacIntel in Safari. */ +export const IS_IPAD = + /iPad/.test(USER_AGENT) || + (/Macintosh/.test(USER_AGENT) && + typeof navigator !== "undefined" && + (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints != + null && + (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints! > + 1); + +/** Apple desktop or iPad/iPhone — keyboard glyphs, hide Windows window buttons. */ export const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +/** + * macOS app window only. iPad WKWebView is opaque; the Mac glass class + * (`background: transparent`) flashes through to the system surface. + */ +export const IS_MACOS = IS_MAC && !IS_IPAD; + +/** True when the primary pointer is touch (companion touch-layout switch). */ +export const IS_TOUCH = + typeof window !== "undefined" && + (("ontouchstart" in window && !IS_MAC) || IS_IPAD); + export const MOD = IS_MAC ? "⌘" : "Ctrl+"; export const ALT = IS_MAC ? "⌥" : "Alt+"; export const SHIFT = IS_MAC ? "⇧" : "Shift+"; + +/** Keyboard glyphs in chrome. iPad is IS_MAC but has no modifier keys. */ +export const SHOW_KEY_SHORTCUTS = IS_MACOS; diff --git a/src/lib/projectLogos.ts b/src/lib/projectLogos.ts index 3f86a137..c868ee92 100644 --- a/src/lib/projectLogos.ts +++ b/src/lib/projectLogos.ts @@ -1,5 +1,7 @@ -import { convertFileSrc, invoke } from "@tauri-apps/api/core"; -import { open } from "@tauri-apps/plugin-dialog"; +import { convertFileSrc } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; +import { openDialog as open } from "./transport/dialog"; import { notifyTabGroupLogosChanged, saveTabGroupLogo, diff --git a/src/lib/pty.ts b/src/lib/pty.ts index 4ed47c44..93354960 100644 --- a/src/lib/pty.ts +++ b/src/lib/pty.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +// TRANSPORT SEAM: same invoke/listen signatures as Tauri; in companion mode +// these transparently forward to the paired host. See src/lib/transport/. +import { invoke, listen, type UnlistenFn } from "./transport"; type DataPayload = { id: string; data: string }; type ExitPayload = { id: string; code: number | null }; diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 275f7526..a3a8265b 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { homeDir } from "./fs"; import { errorRateLimits, diff --git a/src/lib/search.ts b/src/lib/search.ts index 7dde5548..6ae90d65 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; export type ProjectSearchMatch = { path: string; diff --git a/src/lib/sessionStore.ts b/src/lib/sessionStore.ts index 83d4e397..d0bc4a31 100644 --- a/src/lib/sessionStore.ts +++ b/src/lib/sessionStore.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import { persistableAttachment } from "./attachments"; import type { ContextUsage } from "./contextUsage"; import { normalizeProjectPath } from "./recents"; @@ -33,6 +34,62 @@ export type SessionSummary = { pinned?: boolean; }; +export const SESSION_STORE_CHANGED = "session-store-changed"; + +export type SessionStoreChanged = + | { kind: "upserted"; summary: SessionSummary } + | { kind: "deleted"; sessionId: string; cwd?: string } + | { kind: "archived"; sessionId: string; cwd?: string; archived: boolean } + | { kind: "pinned"; sessionId: string; cwd?: string; pinned: boolean }; + +function isSessionSummary(value: unknown): value is SessionSummary { + if (!value || typeof value !== "object") return false; + if (!("id" in value) || !("cwd" in value)) return false; + return typeof value.id === "string" && typeof value.cwd === "string"; +} + +export function parseSessionStoreChanged( + value: unknown, +): SessionStoreChanged | null { + if (!value || typeof value !== "object" || !("kind" in value)) return null; + const kind = value.kind; + if (typeof kind !== "string") return null; + if (kind === "upserted") { + if (!("summary" in value) || !isSessionSummary(value.summary)) return null; + return { kind: "upserted", summary: value.summary }; + } + if (!("sessionId" in value) || typeof value.sessionId !== "string") { + return null; + } + const sessionId = value.sessionId; + const cwd = + "cwd" in value && typeof value.cwd === "string" ? value.cwd : undefined; + if (kind === "deleted") { + return { kind: "deleted", sessionId, ...(cwd ? { cwd } : {}) }; + } + if (kind === "archived") { + if (!("archived" in value) || typeof value.archived !== "boolean") { + return null; + } + return { + kind: "archived", + sessionId, + archived: value.archived, + ...(cwd ? { cwd } : {}), + }; + } + if (kind === "pinned") { + if (!("pinned" in value) || typeof value.pinned !== "boolean") return null; + return { + kind: "pinned", + sessionId, + pinned: value.pinned, + ...(cwd ? { cwd } : {}), + }; + } + return null; +} + type SessionRecord = { id: string; cwd: string; diff --git a/src/lib/sessionStoreChanged.test.ts b/src/lib/sessionStoreChanged.test.ts new file mode 100644 index 00000000..0d4a4006 --- /dev/null +++ b/src/lib/sessionStoreChanged.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { parseSessionStoreChanged } from "./sessionStore"; + +describe("session-store-changed payload", () => { + it("parses upserted summaries", () => { + const parsed = parseSessionStoreChanged({ + kind: "upserted", + summary: { id: "s1", cwd: "/proj", harness: "claude", title: "Hi" }, + }); + expect(parsed).toEqual({ + kind: "upserted", + summary: { id: "s1", cwd: "/proj", harness: "claude", title: "Hi" }, + }); + }); + + it("parses deleted / archived / pinned and rejects junk", () => { + expect( + parseSessionStoreChanged({ + kind: "deleted", + sessionId: "s1", + cwd: "/proj", + }), + ).toEqual({ kind: "deleted", sessionId: "s1", cwd: "/proj" }); + expect( + parseSessionStoreChanged({ + kind: "archived", + sessionId: "s1", + archived: true, + }), + ).toEqual({ kind: "archived", sessionId: "s1", archived: true }); + expect( + parseSessionStoreChanged({ + kind: "pinned", + sessionId: "s1", + pinned: false, + }), + ).toEqual({ kind: "pinned", sessionId: "s1", pinned: false }); + expect(parseSessionStoreChanged({ kind: "upserted" })).toBeNull(); + expect(parseSessionStoreChanged({ kind: "deleted" })).toBeNull(); + expect(parseSessionStoreChanged(null)).toBeNull(); + }); +}); diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1a472f1b..0905510a 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -19,6 +19,9 @@ import { saveGridArcadeEnabled, saveLiveAgentsEnabled, saveNotesEnabled, + SETTINGS_SECTIONS, + clampSettingsSection, + visibleSettingsSections, } from "./settings"; const KEY = "monocode.composerRunner"; @@ -154,6 +157,27 @@ describe("grid arcade enabled setting", () => { }); }); +describe("companion settings sections", () => { + it("hides host-only sections on the iPad client and keeps the rest", () => { + const visible = visibleSettingsSections(true); + expect(visible.map((section) => section.id)).not.toContain("keybindings"); + expect(visible.map((section) => section.id)).not.toContain("providers"); + expect(visible.map((section) => section.id)).toEqual( + SETTINGS_SECTIONS.filter( + (section) => section.id !== "keybindings" && section.id !== "providers", + ).map((section) => section.id), + ); + expect(visibleSettingsSections(false)).toBe(SETTINGS_SECTIONS); + }); + + it("clamps a stored keybindings section to general on the client", () => { + expect(clampSettingsSection("keybindings", true)).toBe("general"); + expect(clampSettingsSection("providers", true)).toBe("general"); + expect(clampSettingsSection("appearance", true)).toBe("appearance"); + expect(clampSettingsSection("keybindings", false)).toBe("keybindings"); + }); +}); + describe("workspace navigation keybindings", () => { it("documents session and project cycling in the shortcut list", () => { const rows = KEYBINDINGS.filter( diff --git a/src/lib/settings.ts b/src/lib/settings.ts index ca30b6bc..58fa777d 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -3,7 +3,12 @@ import { ALT, IS_MAC, MOD, SHIFT } from "./platform"; const SECTION_KEY = "monocode.settingsSection"; export type SettingsSectionId = - "general" | "appearance" | "keybindings" | "providers" | "archive"; + | "general" + | "appearance" + | "keybindings" + | "providers" + | "archive" + | "companion"; export const SETTINGS_SECTIONS: { id: SettingsSectionId; @@ -37,6 +42,12 @@ export const SETTINGS_SECTIONS: { label: "Archive", description: "Projects and conversations you have archived.", }, + { + id: "companion", + label: "Companion", + description: + "Serve this machine to the thin iPad client over LAN or Tailscale.", + }, ]; export const SETTINGS_SECTION_DEFAULT: SettingsSectionId = "general"; @@ -59,6 +70,29 @@ export function settingsSectionDescription(id: SettingsSectionId): string { ); } +/** Sections that only apply to the Mac host (keyboard, CLIs, window chrome). */ +export const COMPANION_HIDDEN_SECTION_IDS: ReadonlySet = + new Set(["keybindings", "providers"]); + +export function visibleSettingsSections( + companionClient: boolean, +): typeof SETTINGS_SECTIONS { + if (!companionClient) return SETTINGS_SECTIONS; + return SETTINGS_SECTIONS.filter( + (section) => !COMPANION_HIDDEN_SECTION_IDS.has(section.id), + ); +} + +export function clampSettingsSection( + id: SettingsSectionId, + companionClient: boolean, +): SettingsSectionId { + const visible = visibleSettingsSections(companionClient); + return visible.some((section) => section.id === id) + ? id + : SETTINGS_SECTION_DEFAULT; +} + export function loadSettingsSection(): SettingsSectionId { try { const raw = localStorage.getItem(SECTION_KEY); diff --git a/src/lib/tailscaleLogin.ts b/src/lib/tailscaleLogin.ts new file mode 100644 index 00000000..93cf1e26 --- /dev/null +++ b/src/lib/tailscaleLogin.ts @@ -0,0 +1,31 @@ +/** + * Device registration URL from tsnet/tailscale-rs (`/a/…`). + * Must not go through /logout?next= — that drops the node auth and lands + * on the admin console, so the iPad never joins. + */ +export function googleSignInHref(loginUrl: string): string { + try { + const parsed = new URL(loginUrl); + if (parsed.hostname !== "login.tailscale.com") return loginUrl; + if (parsed.pathname === "/logout") { + const next = parsed.searchParams.get("next"); + if (next) return googleSignInHref(next); + } + return loginUrl; + } catch { + return loginUrl; + } +} + +/** True when this is the per-device auth page, not the admin console. */ +export function isTailscaleDeviceAuthUrl(loginUrl: string): boolean { + try { + const parsed = new URL(googleSignInHref(loginUrl)); + return ( + parsed.hostname === "login.tailscale.com" && + parsed.pathname.startsWith("/a/") + ); + } catch { + return false; + } +} diff --git a/src/lib/terminalClose.ts b/src/lib/terminalClose.ts index f46db80c..670728db 100644 --- a/src/lib/terminalClose.ts +++ b/src/lib/terminalClose.ts @@ -1,4 +1,4 @@ -import { ask } from "@tauri-apps/plugin-dialog"; +import { askDialog as ask } from "./transport/dialog"; import type { FilePaneTab } from "./layout"; import { getPtyStatus } from "./pty"; import { terminalTabLabel } from "./terminalTab"; diff --git a/src/lib/transport/dialog.ts b/src/lib/transport/dialog.ts new file mode 100644 index 00000000..f33812fc --- /dev/null +++ b/src/lib/transport/dialog.ts @@ -0,0 +1,61 @@ +import { + ask as tauriAsk, + message as tauriMessage, + open as tauriOpen, + type OpenDialogOptions, +} from "@tauri-apps/plugin-dialog"; + +/** + * Companion-safe dialogs. + * + * Native-first: on desktop and in the Tauri iOS shell these are the exact + * Tauri sheets/pickers as before (plugin calls run against the *local* + * shell, so a confirm on the iPad never pops a sheet on the Mac). Anywhere + * the plugin is missing — pure-web preview builds, or a future PWA — they + * degrade to blocking browser primitives (confirm/alert) or a null pick, + * which every caller already handles as "dismissed". New code should import + * from here, never from `@tauri-apps/plugin-dialog` directly. + */ + +export type DialogOptions = { + title?: string; + kind?: "info" | "warning" | "error"; + okLabel?: string; +}; + +export async function askDialog( + message: string, + options?: DialogOptions, +): Promise { + try { + return await tauriAsk(message, options); + } catch { + return window.confirm(message); + } +} + +export async function messageDialog( + message: string, + options?: DialogOptions, +): Promise { + try { + await tauriMessage(message, options); + } catch { + window.alert(message); + } +} + +export type OpenResult = string | string[] | null; + +export async function openDialog( + options: OpenDialogOptions, +): Promise { + try { + const selected = await tauriOpen(options); + if (selected == null) return null; + return Array.isArray(selected) ? selected.map(String) : String(selected); + } catch { + // No native picker (pure web): callers treat null as "no selection". + return null; + } +} diff --git a/src/lib/transport/index.ts b/src/lib/transport/index.ts new file mode 100644 index 00000000..31817aba --- /dev/null +++ b/src/lib/transport/index.ts @@ -0,0 +1,372 @@ +import { IS_IPAD } from "../platform"; +import { LocalTransport } from "./local"; +import { + isCompanionAuthFailure, + RemoteTransport, + type RemoteOptions, +} from "./remote"; +import { + companionDialUrls, + isLocalOnlyCommand, + otherPairingHost, + type PairingDetails, +} from "./protocol"; +import type { + Transport, + TransportEventHandler, + TransportMode, + UnlistenFn, +} from "./types"; + +export type { Transport, TransportEventHandler, TransportMode, UnlistenFn }; +export { LocalTransport } from "./local"; +export { RemoteTransport, claimPairingCode, isCompanionAuthFailure } from "./remote"; +export type { ClaimOptions } from "./remote"; +export * from "./dialog"; +export * from "./protocol"; + +/** + * Process-wide backend binding. Desktop boots (and stays) on LocalTransport; + * the companion switches to RemoteTransport after pairing. Call sites import + * `invoke`/`listen` from here instead of `@tauri-apps/api/*` directly — + * that one-line import swap per file is the entire rebase surface for + * upstream updates. + */ + +let active: Transport = new LocalTransport(); +let companion: RemoteTransport | null = null; + +export type CompanionStatus = + | "local" + | "connecting" + | "connected" + | "reconnecting" + | "failed"; + +const statusHandlers = new Set<(status: CompanionStatus) => void>(); +let companionStatus: CompanionStatus = "local"; +let companionUnlisten: UnlistenFn | null = null; +let companionError: string | null = null; + +export function getCompanionError(): string | null { + return companionError; +} + +function setCompanionStatus(next: CompanionStatus): void { + if (companionStatus === next) return; + companionStatus = next; + for (const handler of [...statusHandlers]) { + try { + handler(next); + } catch { + // Status observers must never break the transport switch. + } + } +} + +export function getTransport(): Transport { + return active; +} + +export function getTransportMode(): TransportMode { + return active.mode; +} + +/** True once a companion link is active, even while reconnecting. */ +export function isRemote(): boolean { + return companion != null; +} + +export function getCompanionStatus(): CompanionStatus { + return companionStatus; +} + +export function onCompanionStatusChange( + handler: (status: CompanionStatus) => void, +): UnlistenFn { + statusHandlers.add(handler); + return () => { + statusHandlers.delete(handler); + }; +} + +/** Drop-in for `invoke` from `@tauri-apps/api/core`. */ +export function invoke( + command: string, + args?: Record, +): Promise { + if (companion && isLocalOnlyCommand(command)) { + // Window chrome belongs to the host; silently absorb on the companion. + return Promise.resolve(undefined as T); + } + return active.invoke(command, args); +} + +/** Drop-in for `listen` from `@tauri-apps/api/event`. */ +export function listen( + event: string, + handler: TransportEventHandler, +): Promise { + return active.listen(event, handler); +} + +/** + * Switch the UI to a paired host. Old link (if any) is disposed first. + * Reconnects automatically; observe via onCompanionStatusChange. + */ +export function connectCompanion( + details: PairingDetails, + options: RemoteOptions = {}, +): RemoteTransport { + disconnectCompanion(); + const transport = new RemoteTransport(companionDialUrls(details), options); + companion = transport; + active = transport; + companionError = null; + setCompanionStatus("connecting"); + companionUnlisten = transport.onStatusChange((status) => { + if (companion !== transport) return; + if (status === "open") { + companionError = null; + setCompanionStatus("connected"); + persistAdvertisedAltHost(); + return; + } + if ( + status === "closed" && + companionError && + isCompanionAuthFailure(companionError) + ) { + setCompanionStatus("failed"); + return; + } + setCompanionStatus("reconnecting"); + }); + void transport.connect().catch((error) => { + if (companion !== transport) return; + companionError = error instanceof Error ? error.message : String(error); + setCompanionStatus( + isCompanionAuthFailure(companionError) ? "failed" : "reconnecting", + ); + }); + return transport; +} + +/** Redial the saved host. Null when this install has no pairing. */ +export function reconnectCompanion( + options: RemoteOptions = {}, +): RemoteTransport | null { + const saved = loadPairing(); + if (!saved) return null; + return connectCompanion(saved, options); +} + +/** Swap LAN ↔ tailnet host when the pairing URL carried both. */ +export function switchCompanionRoute( + options: RemoteOptions = {}, +): RemoteTransport | null { + const saved = loadPairing(); + if (!saved?.altHost) return null; + return connectAndRememberCompanion( + { + ...saved, + host: saved.altHost, + altHost: saved.host, + }, + options, + ); +} + +/** Drop the companion link and return to in-process desktop behavior. */ +export function disconnectCompanion(): void { + companionUnlisten?.(); + companionUnlisten = null; + companionError = null; + if (companion) { + const live = companion; + companion = null; + live.dispose(); + } + if (active.mode !== "local") active = new LocalTransport(); + setCompanionStatus("local"); +} + +const COMPANION_STORE_PREFIX = "monocode.companion."; + +function storageGet(key: string): string | null { + try { + if (typeof localStorage === "undefined") return null; + return localStorage.getItem(COMPANION_STORE_PREFIX + key); + } catch { + return null; + } +} + +function storageSet(key: string, value: string): void { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(COMPANION_STORE_PREFIX + key, value); + } catch { + // Private browsing etc: pairing just won't persist. + } +} + +function storageRemove(key: string): void { + try { + if (typeof localStorage === "undefined") return; + localStorage.removeItem(COMPANION_STORE_PREFIX + key); + } catch { + // Private browsing etc. + } +} + +function storageClear(): void { + for (const key of ["host", "port", "token", "secure", "alt"]) { + storageRemove(key); + } +} + +export function savePairing(details: PairingDetails): void { + storageSet("host", details.host); + storageSet("port", String(details.port)); + storageSet("token", details.token); + if (details.secure) storageSet("secure", "1"); + else storageRemove("secure"); + if (details.altHost && details.altHost !== details.host) { + storageSet("alt", details.altHost); + } else storageRemove("alt"); +} + +/** + * Once the socket is up, remember the host's other live route so the + * iPad can switch LAN ↔ Tailscale without opening Companion settings. + * Retries: after a host restart the tailnet address can lag the LAN + * listener by a few seconds. + */ +function persistAdvertisedAltHost(attempt = 0): void { + const saved = loadPairing(); + if (!saved) return; + void invoke<{ lanIp?: string | null; tailnetHost?: string | null }>( + "remote_status", + ) + .then((status) => { + if (loadPairing()?.host !== saved.host) return; + const altHost = otherPairingHost(saved.host, status); + if (!altHost) { + if (attempt < 8 && typeof window !== "undefined") { + window.setTimeout(() => persistAdvertisedAltHost(attempt + 1), 1500); + } + return; + } + if (altHost === saved.altHost) return; + savePairing({ ...saved, altHost }); + }) + .catch(() => { + if (attempt < 8 && typeof window !== "undefined") { + window.setTimeout(() => persistAdvertisedAltHost(attempt + 1), 1500); + } + }); +} + +export function loadPairing(): PairingDetails | null { + const host = storageGet("host") ?? ""; + const port = Number(storageGet("port") ?? ""); + const token = storageGet("token") ?? ""; + if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { + return null; + } + if (!token) return null; + const secure = storageGet("secure") === "1"; + const altHost = (storageGet("alt") ?? "").trim(); + return { + host, + port, + token, + ...(secure ? { secure: true as const } : {}), + ...(altHost && altHost !== host ? { altHost } : {}), + }; +} + +export function clearPairing(): void { + storageClear(); +} + +const COMPANION_MODE_KEY = "mode"; + +/** + * Explicit "this install is a companion" flag. Set only by the pairing flow + * on the iPad — never by the desktop — so a saved pairing alone can never + * hijack a desktop into remote mode on a future update. + */ +export function setCompanionMode(companion: boolean): void { + if (companion) storageSet(COMPANION_MODE_KEY, "1"); + else { + try { + if (typeof localStorage === "undefined") return; + localStorage.removeItem(COMPANION_STORE_PREFIX + COMPANION_MODE_KEY); + } catch { + // Private browsing etc. + } + } +} + +export function loadCompanionMode(): boolean { + return storageGet(COMPANION_MODE_KEY) === "1"; +} + +/** + * iPad app, or any install that paired as a thin client. Use this — not + * `isRemote()` — for chrome that must stay hidden after disconnect. + */ +export function isCompanionClient(): boolean { + return IS_IPAD || loadCompanionMode(); +} + +/** Pair, remember, and dial — the iPad pairing screen's one call. */ +export function connectAndRememberCompanion( + details: PairingDetails, + options: RemoteOptions = {}, +): RemoteTransport { + savePairing(details); + setCompanionMode(true); + return connectCompanion(details, options); +} + +/** Disconnect and forget everything: this install is a desktop again. */ +export function forgetCompanion(): void { + disconnectCompanion(); + clearPairing(); + setCompanionMode(false); +} + +/** + * Boot gate for companion installs. Resolves true when invokes will reach + * the host (local mode, or the link opened in time). Resolves false on + * timeout — boot proceeds into a disconnected shell that reloads itself + * once the link opens (see main.tsx). + */ +export function waitForCompanionLink(timeoutMs = 8000): Promise { + if (!loadCompanionMode()) return Promise.resolve(true); + const saved = loadPairing(); + if (!saved) return Promise.resolve(true); + if (!companion) connectCompanion(saved); + if (getCompanionStatus() === "connected") return Promise.resolve(true); + return new Promise((resolve) => { + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + unlisten(); + resolve(false); + }, timeoutMs); + const unlisten = onCompanionStatusChange((status) => { + if (done) return; + if (status === "connected" || status === "local") { + done = true; + clearTimeout(timer); + unlisten(); + resolve(status === "connected"); + } + }); + }); +} diff --git a/src/lib/transport/link.test.ts b/src/lib/transport/link.test.ts new file mode 100644 index 00000000..8fa48883 --- /dev/null +++ b/src/lib/transport/link.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPairing, + connectAndRememberCompanion, + connectCompanion, + disconnectCompanion, + forgetCompanion, + getCompanionStatus, + isRemote, + loadCompanionMode, + loadPairing, + reconnectCompanion, + savePairing, + setCompanionMode, + switchCompanionRoute, + waitForCompanionLink, +} from "./index"; + +function installMemoryStorage(): void { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }); +} + +beforeEach(() => { + disconnectCompanion(); + clearPairing(); + setCompanionMode(false); + installMemoryStorage(); +}); + +describe("companion link state", () => { + it("persists pairing details across loads", () => { + expect(loadPairing()).toBeNull(); + savePairing({ host: "macbook", port: 17233, token: "0123456789abcdef" }); + expect(loadPairing()).toEqual({ + host: "macbook", + port: 17233, + token: "0123456789abcdef", + }); + }); + + it("persists an alternate host and switchCompanionRoute swaps it", () => { + const inertSocket = () => ({ + send: () => {}, + close: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }); + savePairing({ + host: "192.168.1.20", + port: 17233, + token: "0123456789abcdef", + altHost: "mac.tail.ts.net", + }); + expect(loadPairing()?.altHost).toBe("mac.tail.ts.net"); + setCompanionMode(true); + const transport = switchCompanionRoute({ + reconnect: false, + socket: inertSocket, + }); + expect(transport).not.toBeNull(); + expect(loadPairing()).toEqual({ + host: "mac.tail.ts.net", + port: 17233, + token: "0123456789abcdef", + altHost: "192.168.1.20", + }); + transport?.dispose(); + forgetCompanion(); + }); + + it("persists the secure flag and can drop it", () => { + savePairing({ + host: "macbook.tail.ts.net", + port: 443, + token: "0123456789abcdef", + secure: true, + }); + expect(loadPairing()).toEqual({ + host: "macbook.tail.ts.net", + port: 443, + token: "0123456789abcdef", + secure: true, + }); + savePairing({ host: "192.168.1.20", port: 17233, token: "0123456789abcdef" }); + expect(loadPairing()?.secure).toBeUndefined(); + }); + + it("keeps companion mode separate from pairing", () => { + expect(loadCompanionMode()).toBe(false); + setCompanionMode(true); + expect(loadCompanionMode()).toBe(true); + setCompanionMode(false); + expect(loadCompanionMode()).toBe(false); + }); + + it("connectAndRemember flips to remote and forget restores local", () => { + expect(isRemote()).toBe(false); + // Never open, never close: no network, deterministic teardown. + const inertSocket = () => ({ + send: () => {}, + close: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }); + const transport = connectAndRememberCompanion( + { host: "127.0.0.1", port: 1, token: "0123456789abcdef" }, + { reconnect: false, socket: inertSocket }, + ); + expect(isRemote()).toBe(true); + expect(loadCompanionMode()).toBe(true); + expect(loadPairing()).toEqual({ + host: "127.0.0.1", + port: 1, + token: "0123456789abcdef", + }); + transport.dispose(); + + forgetCompanion(); + expect(isRemote()).toBe(false); + expect(loadCompanionMode()).toBe(false); + expect(loadPairing()).toBeNull(); + expect(getCompanionStatus()).toBe("local"); + }); + + it("disconnect keeps pairing and reconnect redials it", () => { + const inertSocket = () => ({ + send: () => {}, + close: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }); + savePairing({ host: "mac.local", port: 17233, token: "0123456789abcdef" }); + setCompanionMode(true); + connectCompanion( + { host: "mac.local", port: 17233, token: "0123456789abcdef" }, + { reconnect: false, socket: inertSocket }, + ); + expect(isRemote()).toBe(true); + + disconnectCompanion(); + expect(isRemote()).toBe(false); + expect(loadCompanionMode()).toBe(true); + expect(loadPairing()).toEqual({ + host: "mac.local", + port: 17233, + token: "0123456789abcdef", + }); + expect(getCompanionStatus()).toBe("local"); + + const transport = reconnectCompanion({ + reconnect: false, + socket: inertSocket, + }); + expect(transport).not.toBeNull(); + expect(isRemote()).toBe(true); + transport?.dispose(); + forgetCompanion(); + expect(reconnectCompanion({ reconnect: false, socket: inertSocket })).toBeNull(); + }); + + it("waitForCompanionLink resolves immediately on desktop installs", async () => { + setCompanionMode(false); + await expect(waitForCompanionLink(10)).resolves.toBe(true); + }); + + it("waitForCompanionLink resolves immediately with no saved pairing", async () => { + setCompanionMode(true); + clearPairing(); + await expect(waitForCompanionLink(10)).resolves.toBe(true); + }); +}); diff --git a/src/lib/transport/local.ts b/src/lib/transport/local.ts new file mode 100644 index 00000000..08d32693 --- /dev/null +++ b/src/lib/transport/local.ts @@ -0,0 +1,29 @@ +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { listen as tauriListen } from "@tauri-apps/api/event"; +import type { + Transport, + TransportEventHandler, + UnlistenFn, +} from "./types"; + +/** Desktop default: direct in-process Tauri calls, byte-identical to before. */ +export class LocalTransport implements Transport { + readonly mode = "local" as const; + + invoke(command: string, args?: Record): Promise { + return tauriInvoke(command, args); + } + + async listen( + event: string, + handler: TransportEventHandler, + ): Promise { + return tauriListen(event, (tauriEvent) => { + handler({ payload: tauriEvent.payload }); + }); + } + + dispose(): void { + // Nothing to tear down; window-owned listeners clean themselves up. + } +} diff --git a/src/lib/transport/native.test.ts b/src/lib/transport/native.test.ts new file mode 100644 index 00000000..b93d19de --- /dev/null +++ b/src/lib/transport/native.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { canUseNativeSocket, parseNativeWsEvent } from "./native"; + +describe("native companion socket", () => { + it("is off outside Tauri (tests, browsers)", () => { + expect(canUseNativeSocket()).toBe(false); + }); + + it("parses tagged client events and rejects junk", () => { + expect( + parseNativeWsEvent({ kind: "message", id: "abc", data: "{\"ok\":true}" }), + ).toEqual({ kind: "message", id: "abc", data: "{\"ok\":true}" }); + expect( + parseNativeWsEvent({ kind: "close", id: "abc", code: 1000, reason: "bye" }), + ).toEqual({ kind: "close", id: "abc", code: 1000, reason: "bye" }); + expect( + parseNativeWsEvent({ kind: "error", id: "abc", message: "timeout" }), + ).toEqual({ kind: "error", id: "abc", message: "timeout" }); + expect(parseNativeWsEvent({ kind: "message", id: "abc" })).toBeNull(); + expect(parseNativeWsEvent({ kind: "open", id: "abc" })).toEqual({ + kind: "open", + id: "abc", + }); + expect(parseNativeWsEvent({ kind: "noop", id: "abc" })).toBeNull(); + expect(parseNativeWsEvent(null)).toBeNull(); + }); +}); diff --git a/src/lib/transport/native.ts b/src/lib/transport/native.ts new file mode 100644 index 00000000..355dacc9 --- /dev/null +++ b/src/lib/transport/native.ts @@ -0,0 +1,135 @@ +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { listen as tauriListen } from "@tauri-apps/api/event"; +import type { WebSocketLike } from "./remote"; + +/** + * Native (Rust) WebSocket, used inside Tauri. WKWebView pages are https, so + * a JS `WebSocket` to ws://LAN is mixed content and never leaves the app. + */ + +export type NativeWsEvent = + | { kind: "open"; id: string } + | { kind: "message"; id: string; data: string } + | { kind: "close"; id: string; code: number; reason: string } + | { kind: "error"; id: string; message: string }; + +export function canUseNativeSocket(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export function parseNativeWsEvent(value: unknown): NativeWsEvent | null { + if (!value || typeof value !== "object") return null; + if (!("kind" in value) || !("id" in value)) return null; + const kind = value.kind; + const id = value.id; + if (typeof kind !== "string" || typeof id !== "string" || !id) return null; + if (kind === "open") { + return { kind: "open", id }; + } + if (kind === "message") { + if (!("data" in value) || typeof value.data !== "string") return null; + return { kind: "message", id, data: value.data }; + } + if (kind === "close") { + if (!("code" in value) || typeof value.code !== "number") return null; + const reason = "reason" in value && typeof value.reason === "string" ? value.reason : ""; + return { kind: "close", id, code: value.code, reason }; + } + if (kind === "error") { + if (!("message" in value) || typeof value.message !== "string") return null; + return { kind: "error", id, message: value.message }; + } + return null; +} + +function newSocketId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `c-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export function createNativeSocket(url: string): WebSocketLike { + const id = newSocketId(); + let ready = false; + let closed = false; + const queued: string[] = []; + let unlisten: (() => void) | null = null; + + const socket: WebSocketLike = { + send(data: string) { + if (closed) return; + if (!ready) { + queued.push(data); + return; + } + void tauriInvoke("companion_ws_send", { id, data }); + }, + close(code = 1000, reason = "") { + if (closed) return; + closed = true; + ready = false; + unlisten?.(); + unlisten = null; + void tauriInvoke("companion_ws_close", { id }); + socket.onclose?.({ code, reason }); + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }; + + void (async () => { + try { + unlisten = await tauriListen("companion-ws", (event) => { + const payload = parseNativeWsEvent(event.payload); + if (!payload || payload.id !== id || closed) return; + switch (payload.kind) { + case "open": + ready = true; + for (const data of queued.splice(0)) { + void tauriInvoke("companion_ws_send", { id, data }); + } + socket.onopen?.({}); + return; + case "message": + socket.onmessage?.({ data: payload.data }); + return; + case "error": + socket.onerror?.(payload); + return; + case "close": + closed = true; + ready = false; + unlisten?.(); + unlisten = null; + socket.onclose?.({ code: payload.code, reason: payload.reason }); + return; + default: { + const _exhaustive: never = payload; + void _exhaustive; + } + } + }); + if (closed) { + unlisten?.(); + unlisten = null; + return; + } + await tauriInvoke("companion_ws_open", { id, url }); + } catch (error) { + if (closed) return; + closed = true; + socket.onerror?.(error); + socket.onclose?.({ + code: 1006, + reason: error instanceof Error ? error.message : "open failed", + }); + unlisten?.(); + unlisten = null; + } + })(); + + return socket; +} diff --git a/src/lib/transport/protocol.test.ts b/src/lib/transport/protocol.test.ts new file mode 100644 index 00000000..3c2e3107 --- /dev/null +++ b/src/lib/transport/protocol.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + buildCompanionWsUrl, + buildPairUrl, + companionDialUrls, + COMPANION_PORT_DEFAULT, + decodeBytesEnvelope, + isBytesEnvelope, + isLocalOnlyCommand, + isPairingToken, + isTailnetHost, + otherPairingHost, + pairingDialOrder, + parsePairUrl, +} from "./protocol"; + +describe("pairing urls", () => { + it("round-trips host, port, and token", () => { + const details = { + host: "macbook.tail9a5.ts.net", + port: COMPANION_PORT_DEFAULT, + token: "abcDEF0123456789-_abcDEF0123456789", + }; + const parsed = parsePairUrl(buildPairUrl(details)); + expect(parsed).toEqual(details); + }); + + it("round-trips an alternate host for dual LAN+Tailscale pairing", () => { + const details = { + host: "192.168.1.20", + port: COMPANION_PORT_DEFAULT, + token: "0123456789abcdef0123456789abcdef", + altHost: "macbook.tail9a5.ts.net", + }; + const parsed = parsePairUrl(buildPairUrl(details)); + expect(parsed).toEqual(details); + expect(buildPairUrl(details)).toContain("alt=macbook.tail9a5.ts.net"); + }); + + it("round-trips tailscale (secure) details", () => { + const details = { + host: "macbook.tail9a5.ts.net", + port: 443, + token: "0123456789abcdef0123456789abcdef", + secure: true as const, + }; + expect(parsePairUrl(buildPairUrl(details))).toEqual(details); + expect(buildCompanionWsUrl(details)).toBe( + "wss://macbook.tail9a5.ts.net:443/v1/connect?token=0123456789abcdef0123456789abcdef&v=1", + ); + }); + + it("treats legacy urls without a secure flag as plain ws", () => { + const parsed = parsePairUrl( + "monocode://pair?host=192.168.1.20&port=17233&token=0123456789abcdef0123456789abcdef&v=1", + ); + expect(parsed).toEqual({ + host: "192.168.1.20", + port: 17233, + token: "0123456789abcdef0123456789abcdef", + }); + expect(parsed?.secure).toBeUndefined(); + }); + + it("rejects foreign schemes and malformed payloads", () => { + expect(parsePairUrl("https://example.com/?token=x")).toBeNull(); + expect(parsePairUrl("monocode://pair?host=&port=17233&token=abc")).toBeNull(); + expect(parsePairUrl("monocode://pair?host=h&port=99999&token=abc")).toBeNull(); + expect( + parsePairUrl("monocode://pair?host=h&port=17233&token=short"), + ).toBeNull(); + expect(parsePairUrl("monocode://pair?host=h&port=17233")).toBeNull(); + }); + + it("builds a ws url carrying the token", () => { + const url = buildCompanionWsUrl({ + host: "192.168.1.20", + port: 17233, + token: "0123456789abcdef0123456789abcdef", + }); + expect(url).toBe( + "ws://192.168.1.20:17233/v1/connect?token=0123456789abcdef0123456789abcdef&v=1", + ); + }); +}); + +describe("isTailnetHost", () => { + it("recognizes CGNAT, MagicDNS, and Tailscale IPv6", () => { + expect(isTailnetHost("100.80.151.26")).toBe(true); + expect(isTailnetHost("mac.tail9a5.ts.net")).toBe(true); + expect(isTailnetHost("[fd7a:115c:a1e0::1]")).toBe(true); + expect(isTailnetHost("192.168.4.191")).toBe(false); + expect(isTailnetHost("10.0.0.1")).toBe(false); + }); +}); + +describe("pairingDialOrder", () => { + it("tries LAN before the tailnet address", () => { + const details = { + host: "100.80.151.26", + port: 17233, + token: "0123456789abcdef0123456789abcdef", + altHost: "192.168.4.191", + }; + expect(pairingDialOrder(details).map((item) => item.host)).toEqual([ + "192.168.4.191", + "100.80.151.26", + ]); + expect(companionDialUrls(details)).toEqual([ + "ws://192.168.4.191:17233/v1/connect?token=0123456789abcdef0123456789abcdef&v=1", + "ws://100.80.151.26:17233/v1/connect?token=0123456789abcdef0123456789abcdef&v=1", + ]); + }); +}); + +describe("otherPairingHost", () => { + it("returns the advertised host that is not the one already connected", () => { + expect( + otherPairingHost("192.168.1.20", { + lanIp: "192.168.1.20", + tailnetHost: "mac.tail.ts.net", + }), + ).toBe("mac.tail.ts.net"); + expect( + otherPairingHost("mac.tail.ts.net", { + lanIp: "192.168.1.20", + tailnetHost: "mac.tail.ts.net", + }), + ).toBe("192.168.1.20"); + expect( + otherPairingHost("192.168.1.20", { lanIp: "192.168.1.20" }), + ).toBeUndefined(); + }); +}); + +describe("pairing tokens", () => { + it("accepts url-safe tokens, rejects everything else", () => { + expect(isPairingToken("0123456789abcdef")).toBe(true); + expect(isPairingToken("a".repeat(15))).toBe(false); + expect(isPairingToken("has space in it 12345678")).toBe(false); + expect(isPairingToken("semi;colon?query=12345678")).toBe(false); + }); +}); + +describe("binary envelopes", () => { + it("round-trips bytes through base64", () => { + // "hi" as base64. + const envelope = { __bytes: "aGk=" }; + expect(isBytesEnvelope(envelope)).toBe(true); + expect(isBytesEnvelope({ __bytes: 42 })).toBe(false); + expect(isBytesEnvelope({ __bytes: "aGk=", extra: 1 })).toBe(false); + expect(isBytesEnvelope(null)).toBe(false); + const buffer = decodeBytesEnvelope(envelope); + expect(Array.from(new Uint8Array(buffer))).toEqual([104, 105]); + }); +}); + +describe("command routing", () => { + it("keeps window chrome local but forwards agent and pty traffic", () => { + for (const command of [ + "set_dock_badge", + "hide_window", + "destroy_window", + "confirm_quit", + "open_new_window", + "enable_window_glass", + "set_traffic_lights_visible", + "set_window_background_blur", + ]) { + expect(isLocalOnlyCommand(command)).toBe(true); + } + for (const command of [ + "harness_spawn", + "harness_write", + "harness_kill", + "harness_http", + "harness_sse_open", + "pty_spawn", + "pty_write", + "session_upsert", + "session_list_by_project", + "workspace_set_snapshot", + "list_dir", + "git_diff_stats", + "search_project", + "notes_list", + // Unknown future upstream commands default to forwarded, never dropped. + "some_future_command", + ]) { + expect(isLocalOnlyCommand(command)).toBe(false); + } + }); +}); diff --git a/src/lib/transport/protocol.ts b/src/lib/transport/protocol.ts new file mode 100644 index 00000000..de09de00 --- /dev/null +++ b/src/lib/transport/protocol.ts @@ -0,0 +1,282 @@ +/** Companion wire protocol (host desktop <-> thin iPad client). + * + * MERGE NOTE (upstream-friendly): this file is additive-only. It introduces + * no changes to existing desktop behavior — the desktop keeps using + * LocalTransport, and every name below is a plain string so future upstream + * commands keep working without edits here. + * + * Transport: WebSocket (works over LAN and Tailscale — both are just IP). + * ws://:/v1/connect?token= + * + * Frames (JSON text): + * client -> host { id, type: "invoke", command, args? } + * host -> client { id, type: "result", ok: true, payload } | + * { id, type: "result", ok: false, error } + * host -> client { type: "event", event, payload } + * + * Sync model: the host stays the single source of truth (sessions, SQLite, + * filesystem, agent CLIs). The companion is a thin client: same React UI, + * but every `invoke` is forwarded and every backend event (`harness-stdout`, + * `pty-data`, ...) is re-broadcast. No separate sync protocol is needed for + * v1 — realtime streaming IS the sync. + */ + +export const COMPANION_PROTO_VERSION = 1; + +/** Default host port. IANA-unassigned in the dynamic range; no conflict. */ +export const COMPANION_PORT_DEFAULT = 17233; + +export const COMPANION_WS_PATH = "/v1/connect"; + +/** Pairing deep-link / QR payload scheme. */ +export const PAIR_URL_SCHEME = "monocode://pair"; + +export type RpcRequest = { + id: number; + type: "invoke"; + command: string; + args?: Record; +}; + +export type RpcResult = + | { id: number; type: "result"; ok: true; payload: unknown } + | { id: number; type: "result"; ok: false; error: string }; + +export type RpcEvent = { + type: "event"; + event: string; + payload: unknown; +}; + +export type RpcIncoming = RpcResult | RpcEvent; + +export function isRpcResult(message: RpcIncoming): message is RpcResult { + return message.type === "result"; +} + +/** + * Binary envelope for the two byte-returning commands (`read_binary_file`, + * `fetch_inbox_media`). Locally Tauri delivers a real ArrayBuffer; over the + * JSON socket the host wraps bytes as `{ __bytes: "" }` and + * RemoteTransport decodes back to ArrayBuffer, so call sites stay identical. + */ +export const BYTES_ENVELOPE_KEY = "__bytes"; + +export function isBytesEnvelope(value: unknown): value is { + [BYTES_ENVELOPE_KEY]: string; +} { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const keys = Object.keys(value); + return ( + keys.length === 1 && + keys[0] === BYTES_ENVELOPE_KEY && + typeof (value as Record)[BYTES_ENVELOPE_KEY] === + "string" + ); +} + +export function decodeBytesEnvelope(envelope: { + [BYTES_ENVELOPE_KEY]: string; +}): ArrayBuffer { + const binary = atob(envelope[BYTES_ENVELOPE_KEY]); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Window-chrome commands that only make sense on the host that owns the + * window. In remote mode these resolve locally as no-ops instead of being + * forwarded. Everything else is forwarded verbatim, so new upstream commands + * automatically work over the companion link without edits here. + */ +export const LOCAL_ONLY_COMMANDS: ReadonlySet = new Set([ + "set_window_background_blur", + "set_traffic_lights_visible", + "set_dock_badge", + "confirm_quit", + "hide_window", + "destroy_window", + "enable_window_glass", + "open_new_window", +]); + +export function isLocalOnlyCommand(command: string): boolean { + return LOCAL_ONLY_COMMANDS.has(command); +} + +export type PairingDetails = { + host: string; + port: number; + token: string; + /** + * True when the host is reached through `tailscale serve` (or any other + * TLS-terminating proxy): dial with wss:// instead of ws://. + * + * Tailscale path (recommended over plain LAN when leaving home): + * tailscale serve --bg --https=443 http://localhost:17233 + * then pair with host=..ts.net, port=443, secure=true. + * The daemon terminates outer TLS (auto-provisioned cert) and proxies the + * WebSocket upgrade through to this server. Raw-TCP alternative: + * tailscale serve --bg --tcp=17233 tcp://localhost:17233 + * (WireGuard already encrypts; keep secure=false and use the MagicDNS name + * or 100.x address as host.) + */ + secure?: boolean; + /** Other live route (LAN if `host` is tailnet, or the reverse). */ + altHost?: string; +}; + +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,256}$/; + +export function isPairingToken(value: string): boolean { + return TOKEN_PATTERN.test(value); +} + +/** Build the QR / deep-link payload, e.g. monocode://pair?host=..&port=..&token=..&v=1 */ +export function buildPairUrl(details: PairingDetails): string { + const params = new URLSearchParams({ + host: details.host, + port: String(details.port), + token: details.token, + v: String(COMPANION_PROTO_VERSION), + }); + if (details.secure) params.set("secure", "1"); + if (details.altHost && details.altHost !== details.host) { + params.set("alt", details.altHost); + } + return `${PAIR_URL_SCHEME}?${params.toString()}`; +} + +/** Parse a pairing URL back. Returns null for foreign schemes / bad tokens. */ +export function parsePairUrl(url: string): PairingDetails | null { + if (!url.startsWith(`${PAIR_URL_SCHEME}?`)) return null; + let params: URLSearchParams; + try { + // Swap only the scheme so the rest parses as a normal hierarchical URL. + params = new URL(url.replace(`${PAIR_URL_SCHEME}://`, "https://")) + .searchParams; + } catch { + return null; + } + const host = (params.get("host") ?? "").trim(); + const port = Number(params.get("port") ?? ""); + const token = params.get("token") ?? ""; + if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { + return null; + } + if (!isPairingToken(token)) return null; + const secure = params.get("secure"); + const altHost = (params.get("alt") ?? "").trim(); + return { + host, + port, + token, + ...(secure === "1" || secure === "true" ? { secure: true as const } : {}), + ...(altHost && altHost !== host ? { altHost } : {}), + }; +} + +/** + * Dial URL for the companion. ws:// for direct LAN, wss:// through + * `tailscale serve --https` (or any TLS-terminating proxy). + */ +export function buildCompanionWsUrl(details: PairingDetails): string { + const scheme = details.secure ? "wss" : "ws"; + const params = new URLSearchParams({ + token: details.token, + v: String(COMPANION_PROTO_VERSION), + }); + return `${scheme}://${details.host}:${details.port}${COMPANION_WS_PATH}?${params.toString()}`; +} + +/** Tailnet CGNAT (100.64/10), Tailscale IPv6 (fd7a:115c:a1e0::/48), or MagicDNS. */ +export function isTailnetHost(host: string): boolean { + const trimmed = host.trim().replace(/^\[|\]$/g, ""); + if (trimmed.endsWith(".ts.net") || trimmed.toLowerCase() === "ts.net") { + return true; + } + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(trimmed); + if (v4) { + const first = Number(v4[1]); + const second = Number(v4[2]); + return first === 100 && second >= 64 && second <= 127; + } + const lower = trimmed.toLowerCase(); + return lower.startsWith("fd7a:115c:a1e0:"); +} + +/** + * Hosts to try, LAN first. Scanning the Tailscale QR used to dial only + * 100.x, and the iPad tsnet path can hang — the LAN alt in the same code + * already reaches the Mac listener. + */ +export function pairingDialOrder(details: PairingDetails): PairingDetails[] { + const hosts: string[] = []; + for (const host of [details.host, details.altHost]) { + const trimmed = host?.trim() ?? ""; + if (trimmed && !hosts.includes(trimmed)) hosts.push(trimmed); + } + hosts.sort( + (left, right) => Number(isTailnetHost(left)) - Number(isTailnetHost(right)), + ); + return hosts.map((host) => { + const altHost = hosts.find((other) => other !== host); + return { + host, + port: details.port, + token: details.token, + ...(details.secure ? { secure: true as const } : {}), + ...(altHost ? { altHost } : {}), + }; + }); +} + +export function companionDialUrls(details: PairingDetails): string[] { + return pairingDialOrder(details).map((item) => buildCompanionWsUrl(item)); +} + +/** + * Pair-mode dial URL: no token yet. The socket may only invoke `pair_claim` + * with the host's 6-digit code; success returns the real token and upgrades + * the socket to fully paired. + */ +export function buildPairWsUrl(details: { + host: string; + port: number; + secure?: boolean; +}): string { + const scheme = details.secure ? "wss" : "ws"; + return `${scheme}://${details.host}:${details.port}${COMPANION_WS_PATH}?pair=1`; +} + +export type PairClaimPayload = { + token: string; + /** LAN IP advertised by the host when that route is on. */ + lanIp?: string | null; + /** Tailnet name or 100.x address advertised when Tailscale is on. */ + tailnetHost?: string | null; +}; + +/** The other live route, if the host advertised one that isn't `connectedHost`. */ +export function otherPairingHost( + connectedHost: string, + extras: { lanIp?: string | null; tailnetHost?: string | null }, +): string | undefined { + const connected = connectedHost.trim(); + for (const candidate of [extras.lanIp, extras.tailnetHost]) { + const host = candidate?.trim(); + if (host && host !== connected) return host; + } + return undefined; +} + +export function normalizePairCode(raw: string): string { + return raw.replace(/\D/g, ""); +} + +export function isPairCode(value: string): boolean { + return normalizePairCode(value).length === 6; +} diff --git a/src/lib/transport/remote.test.ts b/src/lib/transport/remote.test.ts new file mode 100644 index 00000000..62e699ee --- /dev/null +++ b/src/lib/transport/remote.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it, vi } from "vitest"; +import { + claimPairingCode, + RemoteTransport, + type WebSocketLike, +} from "./remote"; + +type Harness = { + socket: WebSocketLike; + received: string[]; + url: string; + serverSend: (frame: unknown) => void; + serverClose: (code?: number, reason?: string) => void; +}; + +function installFakeSocket(): { + factory: (url: string) => WebSocketLike; + harness: () => Harness; +} { + let current: Harness | null = null; + const factory = (url: string) => { + const received: string[] = []; + const socket: WebSocketLike = { + send: (data: string) => { + received.push(data); + }, + close: (code = 1000, reason = "test close") => { + socket.onclose?.({ code, reason }); + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }; + const harness: Harness = { + socket, + received, + url, + serverSend: (frame: unknown) => { + socket.onmessage?.({ data: JSON.stringify(frame) }); + }, + serverClose: (code = 1000, reason = "test close") => { + socket.onclose?.({ code, reason }); + }, + }; + current = harness; + return socket; + }; + return { + factory, + harness: () => { + if (!current) throw new Error("socket not created yet"); + return current; + }, + }; +} + +function openSocket(harness: Harness): void { + harness.socket.onopen?.({}); +} + +describe("claimPairingCode", () => { + function claimSocket( + behavior: (socket: WebSocketLike, sent: string[]) => void, + ): (url: string) => WebSocketLike { + return () => { + const sent: string[] = []; + const socket: WebSocketLike = { + send: (data: string) => { + sent.push(data); + behavior(socket, sent); + }, + close: () => {}, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }; + queueMicrotask(() => socket.onopen?.({})); + return socket; + }; + } + + it("rejects malformed codes without dialing", async () => { + let dials = 0; + await expect( + claimPairingCode({ host: "h", port: 1 }, "12", { + socket: () => { + dials += 1; + throw new Error("unreachable"); + }, + }), + ).rejects.toThrow("6-digit"); + expect(dials).toBe(0); + }); + + it("keeps advertised LAN and Tailscale hosts from the claim payload", async () => { + const factory = claimSocket((socket) => { + socket.onmessage?.({ + data: JSON.stringify({ + id: 1, + type: "result", + ok: true, + payload: { + token: "real-token-value", + lanIp: "192.168.1.20", + tailnetHost: "mac.tail.ts.net", + }, + }), + }); + }); + await expect( + claimPairingCode({ host: "mac", port: 17233 }, "123 456", { + socket: factory, + }), + ).resolves.toEqual({ + token: "real-token-value", + lanIp: "192.168.1.20", + tailnetHost: "mac.tail.ts.net", + }); + }); + + it("exchanges the code for a token", async () => { + const factory = claimSocket((socket) => { + socket.onmessage?.({ + data: JSON.stringify({ + id: 1, + type: "result", + ok: true, + payload: { token: "real-token-value" }, + }), + }); + }); + await expect( + claimPairingCode({ host: "mac", port: 17233 }, "123 456", { + socket: factory, + }), + ).resolves.toEqual({ token: "real-token-value" }); + }); + + it("surfaces host rejections (wrong/expired code)", async () => { + const factory = claimSocket((socket) => { + socket.onmessage?.({ + data: JSON.stringify({ + id: 1, + type: "result", + ok: false, + error: "Wrong code — check the host screen and retry.", + }), + }); + }); + await expect( + claimPairingCode({ host: "mac", port: 17233 }, "000000", { + socket: factory, + }), + ).rejects.toThrow("Wrong code"); + }); + + it("falls back from ws to wss on the same host:port", async () => { + const dialed: string[] = []; + const factory = (url: string) => { + dialed.push(url); + if (url.startsWith("ws://")) { + return claimSocket((socket) => { + socket.onerror?.({}); + })(""); + } + return claimSocket((socket) => { + socket.onmessage?.({ + data: JSON.stringify({ + id: 1, + type: "result", + ok: true, + payload: { token: "t" }, + }), + }); + })(""); + }; + await expect( + claimPairingCode({ host: "mac", port: 17233 }, "123456", { + socket: factory, + }), + ).resolves.toEqual({ token: "t" }); + expect(dialed).toEqual([ + "ws://mac:17233/v1/connect?pair=1", + "wss://mac:17233/v1/connect?pair=1", + ]); + }); +}); + +describe("RemoteTransport", () => { + it("resolves invokes from host result frames", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + const pending = transport.invoke("harness_free_port"); + const sent = JSON.parse(harness().received[0] ?? "{}") as { + id: number; + type: string; + command: string; + }; + expect(sent.type).toBe("invoke"); + expect(sent.command).toBe("harness_free_port"); + harness().serverSend({ id: sent.id, type: "result", ok: true, payload: 54321 }); + await expect(pending).resolves.toBe(54321); + transport.dispose(); + }); + + it("rejects invokes on host error frames", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + const pending = transport.invoke("pty_spawn", { id: "t1" }); + const sent = JSON.parse(harness().received[0] ?? "{}") as { id: number }; + harness().serverSend({ + id: sent.id, + type: "result", + ok: false, + error: "Terminal is not running", + }); + await expect(pending).rejects.toThrow("Terminal is not running"); + transport.dispose(); + }); + + it("fans out host events to matching listeners only", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + const seen: string[] = []; + const other: string[] = []; + const unlisten = await transport.listen<{ line: string }>( + "harness-stdout", + (event) => { + seen.push(event.payload.line); + }, + ); + await transport.listen<{ line: string }>("harness-stderr", (event) => { + other.push(event.payload.line); + }); + harness().serverSend({ + type: "event", + event: "harness-stdout", + payload: { sessionId: "s1", line: "hello" }, + }); + harness().serverSend({ + type: "event", + event: "harness-stderr", + payload: { sessionId: "s1", line: "warn" }, + }); + expect(seen).toEqual(["hello"]); + expect(other).toEqual(["warn"]); + + unlisten(); + harness().serverSend({ + type: "event", + event: "harness-stdout", + payload: { sessionId: "s1", line: "after unlisten" }, + }); + expect(seen).toEqual(["hello"]); + transport.dispose(); + }); + + it("rejects in-flight invokes when the socket closes", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + const pending = transport.invoke("session_upsert", { id: "s1" }); + const failure = expect(pending).rejects.toThrow(); + harness().serverClose(1006); + await failure; + transport.dispose(); + }); + + it("times out invokes that the host never answers", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + invokeTimeoutMs: 20, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + await expect(transport.invoke("pty_spawn")).rejects.toThrow("timed out"); + transport.dispose(); + }); + + it("reports status transitions to observers", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const statuses: string[] = []; + transport.onStatusChange((status) => statuses.push(status)); + const connected = transport.connect(); + openSocket(harness()); + await connected; + transport.disconnect(); + expect(statuses).toContain("open"); + expect(statuses[statuses.length - 1]).toBe("closed"); + transport.dispose(); + }); + + it("connect rejects when the host refuses the dial", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + const failure = expect(connected).rejects.toThrow(); + harness().serverClose(1006); + await failure; + transport.dispose(); + }); + + it("ignores malformed frames without breaking the link", async () => { + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: factory, + reconnect: false, + }); + const connected = transport.connect(); + openSocket(harness()); + await connected; + + const onData = vi.fn(); + await transport.listen("pty-data", onData); + harness().socket.onmessage?.({ data: "not-json{{{" }); + harness().socket.onmessage?.({ data: "42" }); + harness().serverSend({ + type: "event", + event: "pty-data", + payload: { id: "t1", data: "aGk=" }, + }); + expect(onData).toHaveBeenCalledTimes(1); + transport.dispose(); + }); + + it("fails over to the next url when the first dial times out", async () => { + const sockets: Array<{ url: string; socket: WebSocketLike }> = []; + const factory = (url: string) => { + const socket: WebSocketLike = { + send: () => {}, + close: (code = 1000, reason = "test close") => { + socket.onclose?.({ code, reason }); + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }; + sockets.push({ url, socket }); + return socket; + }; + const transport = new RemoteTransport( + [ + "ws://100.80.151.26:17233/v1/connect", + "ws://192.168.4.191:17233/v1/connect", + ], + { socket: factory, reconnect: false, connectTimeoutMs: 30 }, + ); + const connected = transport.connect(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sockets.map((item) => item.url)).toEqual([ + "ws://100.80.151.26:17233/v1/connect", + "ws://192.168.4.191:17233/v1/connect", + ]); + sockets[1]?.socket.onopen?.({}); + await connected; + transport.dispose(); + }); + + it("does not reconnect after a 401", async () => { + let dials = 0; + const { factory, harness } = installFakeSocket(); + const transport = new RemoteTransport("ws://host:17233/v1/connect", { + socket: (url) => { + dials += 1; + return factory(url); + }, + reconnect: true, + connectTimeoutMs: 200, + maxBackoffMs: 20, + }); + const connected = transport.connect(); + harness().serverClose( + 1006, + "websocket handshake failed: HTTP error: 401 Unauthorized", + ); + await expect(connected).rejects.toThrow(/pairing token rejected/); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(dials).toBe(1); + transport.dispose(); + }); +}); diff --git a/src/lib/transport/remote.ts b/src/lib/transport/remote.ts new file mode 100644 index 00000000..37f8a2b3 --- /dev/null +++ b/src/lib/transport/remote.ts @@ -0,0 +1,569 @@ +import { + canUseNativeSocket, + createNativeSocket, +} from "./native"; +import { + buildPairWsUrl, + COMPANION_PROTO_VERSION, + decodeBytesEnvelope, + isBytesEnvelope, + isPairCode, + isRpcResult, + normalizePairCode, + type PairClaimPayload, + type RpcIncoming, + type RpcRequest, +} from "./protocol"; +import type { + Transport, + TransportEventHandler, + UnlistenFn, +} from "./types"; + +/** Minimal WebSocket surface used. Injectable so tests can pass a fake. */ +export type WebSocketLike = { + send(data: string): void; + close(code?: number, reason?: string): void; + onopen: ((ev: unknown) => void) | null; + onmessage: ((ev: { data: unknown }) => void) | null; + onclose: ((ev: { code: number; reason: string }) => void) | null; + onerror: ((ev: unknown) => void) | null; +}; + +export type WebSocketFactory = (url: string) => WebSocketLike; + +export type RemoteStatus = + | "connecting" + | "open" + | "closed"; + +export type RemoteOptions = { + /** Per-request timeout. Host `harness_http` can take ~30s; default 60s. */ + invokeTimeoutMs?: number; + /** Give up on one host and try the next. Default 8s. */ + connectTimeoutMs?: number; + /** Reconnect with backoff after unexpected closes. Default true. */ + reconnect?: boolean; + maxBackoffMs?: number; + socket?: WebSocketFactory; +}; + +const DEFAULT_INVOKE_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_BACKOFF_MS = 10_000; +const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; + +export function isCompanionAuthFailure(text: string): boolean { + return /401|unauthorized|bad pairing token|pairing token rejected/i.test( + text, + ); +} + +function defaultSocket(url: string): WebSocketLike { + // Tauri's WKWebView is https://tauri.localhost; a JS WebSocket to + // ws://LAN is mixed content and never dials. Native TCP bypasses that. + if (canUseNativeSocket()) return createNativeSocket(url); + const Impl = globalThis.WebSocket as unknown as + | (new (url: string) => WebSocketLike) + | undefined; + if (!Impl) throw new Error("companion: WebSocket is not available"); + return new Impl(url); +} + +export function createWebSocket(url: string): WebSocketLike { + return defaultSocket(url); +} + +export type ClaimOptions = { + timeoutMs?: number; + socket?: WebSocketFactory; +}; + +const DEFAULT_CLAIM_TIMEOUT_MS = 15_000; + +/** + * Exchange the host's 6-digit pairing code for the real token over a + * tokenless pair-mode socket. Tries ws:// first, then wss:// on the same + * host:port, so manual entry never asks about connection types. + */ +export async function claimPairingCode( + details: { host: string; port: number }, + code: string, + options: ClaimOptions = {}, +): Promise { + const digits = normalizePairCode(code); + if (!isPairCode(digits)) { + throw new Error("Enter the 6-digit code shown on the host."); + } + const host = details.host.trim(); + const port = details.port; + if (!host) throw new Error("Enter the host first."); + const attempts = [ + buildPairWsUrl({ host, port, secure: false }), + buildPairWsUrl({ host, port, secure: true }), + ]; + let lastError: unknown = null; + for (const url of attempts) { + try { + return await claimOnce(url, digits, options); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new Error("Could not reach the host — check host, port, and network."); +} + +function claimOnce( + url: string, + digits: string, + options: ClaimOptions, +): Promise { + const create = options.socket ?? defaultSocket; + const timeoutMs = options.timeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS; + return new Promise((resolve, reject) => { + let settled = false; + const done = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket.close(); + } catch { + // Already gone; the result below is what matters. + } + fn(); + }; + const timer = setTimeout(() => { + done(() => reject(new Error("The host did not answer — check host and port."))); + }, timeoutMs); + let socket: WebSocketLike; + try { + socket = create(url); + } catch (error) { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + socket.onopen = () => { + socket.send( + JSON.stringify({ + id: 1, + type: "invoke", + command: "pair_claim", + args: { code: digits }, + } satisfies RpcRequest), + ); + }; + socket.onmessage = (event) => { + let message: RpcIncoming; + try { + message = JSON.parse(event.data as string) as RpcIncoming; + } catch { + return; + } + if (!isRpcResult(message)) return; + if (message.ok) { + const payload = message.payload as PairClaimPayload | null; + const token = payload?.token; + if (typeof token === "string" && token) { + done(() => + resolve({ + token, + ...(payload?.lanIp ? { lanIp: payload.lanIp } : {}), + ...(payload?.tailnetHost + ? { tailnetHost: payload.tailnetHost } + : {}), + }), + ); + } else { + done(() => reject(new Error("The host answered, but without a token."))); + } + } else { + done(() => reject(new Error(message.error || "Pairing rejected."))); + } + }; + socket.onerror = () => { + done(() => reject(new Error("Could not reach the host."))); + }; + socket.onclose = () => { + done(() => reject(new Error("Could not reach the host."))); + }; + }); +} + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +export type RemoteStatusHandler = (status: RemoteStatus) => void; + +/** + * Thin-client transport: forwards `invoke` over the companion WebSocket and + * fans out host-broadcast `event` frames to local listeners. Keeps the exact + * same call signatures as Tauri so UI code needs no companion-specific forks. + */ +export class RemoteTransport implements Transport { + readonly mode = "remote" as const; + + private readonly urls: string[]; + private urlIndex = 0; + private readonly invokeTimeoutMs: number; + private readonly connectTimeoutMs: number; + private readonly reconnectEnabled: boolean; + private readonly maxBackoffMs: number; + private readonly createSocket: WebSocketFactory; + + private socket: WebSocketLike | null = null; + private seq = 0; + private readonly pending = new Map(); + private readonly listeners = new Map>>(); + private readonly statusHandlers = new Set(); + private status: RemoteStatus = "connecting"; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private connectTimer: ReturnType | null = null; + private disposed = false; + + constructor(url: string | readonly string[], options: RemoteOptions = {}) { + const urls = (Array.isArray(url) ? [...url] : [url]).filter( + (item) => item.length > 0, + ); + if (urls.length === 0) { + throw new Error("companion: missing websocket url"); + } + this.urls = urls; + this.invokeTimeoutMs = + options.invokeTimeoutMs ?? DEFAULT_INVOKE_TIMEOUT_MS; + this.connectTimeoutMs = + options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + this.reconnectEnabled = options.reconnect ?? true; + this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS; + this.createSocket = options.socket ?? defaultSocket; + } + + getStatus(): RemoteStatus { + return this.status; + } + + onStatusChange(handler: RemoteStatusHandler): UnlistenFn { + this.statusHandlers.add(handler); + return () => { + this.statusHandlers.delete(handler); + }; + } + + /** Dial the host. Resolves on open, rejects on the first error/close. */ + connect(): Promise { + if (this.disposed) return Promise.reject(new Error("companion: disposed")); + return this.connectAttempt(0).catch((error) => { + if ( + !this.disposed && + this.reconnectEnabled && + !isCompanionAuthFailure( + error instanceof Error ? error.message : String(error), + ) + ) { + this.scheduleReconnect(); + } + throw error; + }); + } + + private connectAttempt(urlIndex: number): Promise { + if (this.disposed) return Promise.reject(new Error("companion: disposed")); + if (urlIndex >= this.urls.length) { + return Promise.reject(new Error("companion: host refused the connection")); + } + this.urlIndex = urlIndex; + return this.connectOnce().catch((error) => { + if (this.disposed) throw error; + const message = error instanceof Error ? error.message : String(error); + if (isCompanionAuthFailure(message)) throw error; + if (urlIndex + 1 < this.urls.length) { + return this.connectAttempt(urlIndex + 1); + } + throw error; + }); + } + + private connectOnce(): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const done = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + const onOpen = () => { + done(() => { + this.reconnectAttempt = 0; + this.setStatus("open"); + resolve(); + }); + }; + const onEarlyClose = (reason: string) => { + const message = isCompanionAuthFailure(reason) + ? "companion: pairing token rejected — scan the code on the Mac again" + : reason === "connect-timeout" + ? "companion: timed out connecting to host" + : "companion: host refused the connection"; + done(() => reject(new Error(message))); + }; + try { + this.openSocket(onOpen, onEarlyClose); + } catch (error) { + done(() => + reject(error instanceof Error ? error : new Error(String(error))), + ); + } + }); + } + + invoke(command: string, args?: Record): Promise { + if (this.disposed) return Promise.reject(new Error("companion: disposed")); + if (!this.socket || this.status !== "open") { + return Promise.reject(new Error("companion: not connected to host")); + } + this.seq = (this.seq + 1) % 0x7fffffff; + const id = this.seq === 0 ? (this.seq = 1) : this.seq; + const request: RpcRequest = { id, type: "invoke", command, args }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`companion: request timed out (${command})`)); + }, this.invokeTimeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + try { + this.socket?.send(JSON.stringify(request)); + } catch (error) { + this.pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + async listen( + event: string, + handler: TransportEventHandler, + ): Promise { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(handler as TransportEventHandler); + return () => { + const live = this.listeners.get(event); + if (!live) return; + live.delete(handler as TransportEventHandler); + if (live.size === 0) this.listeners.delete(event); + }; + } + + disconnect(): void { + this.clearReconnectTimer(); + this.clearConnectTimer(); + this.failPending(new Error("companion: disconnected")); + const socket = this.socket; + this.socket = null; + try { + socket?.close(1000, "client disconnect"); + } catch { + // Socket already gone; pending requests already failed above. + } + this.setStatus("closed"); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.disconnect(); + this.listeners.clear(); + this.statusHandlers.clear(); + } + + private setStatus(next: RemoteStatus): void { + if (this.status === next) return; + this.status = next; + for (const handler of [...this.statusHandlers]) { + try { + handler(next); + } catch { + // Status observers must never break the socket. + } + } + } + + private openSocket( + onOpen: () => void, + onEarlyClose: (reason: string) => void, + ): void { + this.clearConnectTimer(); + const previous = this.socket; + this.socket = null; + try { + previous?.close(1000, "replaced"); + } catch { + // Previous socket already gone. + } + const url = this.urls[this.urlIndex] ?? this.urls[0]; + const socket = this.createSocket(url); + this.socket = socket; + this.setStatus("connecting"); + this.connectTimer = setTimeout(() => { + this.connectTimer = null; + try { + socket.close(1000, "connect-timeout"); + } catch { + // Already gone; onclose still settles the waiter. + } + }, this.connectTimeoutMs); + socket.onopen = () => { + this.clearConnectTimer(); + onOpen(); + }; + socket.onmessage = (ev) => this.handleMessage(ev.data); + socket.onerror = () => { + // Browsers also fire close after error; early-close rejection is + // handled there to avoid double-settling. + }; + socket.onclose = (ev) => { + this.clearConnectTimer(); + const wasSocket = this.socket === socket; + if (!wasSocket) return; + this.socket = null; + const wasOpen = this.status === "open"; + this.failPending( + new Error(`companion: connection closed (${ev.code})`), + ); + const reason = ev.reason ?? ""; + onEarlyClose(reason); + if (this.disposed) { + this.setStatus("closed"); + return; + } + if (isCompanionAuthFailure(reason)) { + this.setStatus("closed"); + return; + } + if (wasOpen && this.reconnectEnabled) { + this.advanceUrl(); + this.scheduleReconnect(); + } + }; + } + + private advanceUrl(): void { + if (this.urls.length > 1) { + this.urlIndex = (this.urlIndex + 1) % this.urls.length; + } + } + + private scheduleReconnect(): void { + if (this.disposed || this.reconnectTimer) return; + const backoff = Math.min( + 500 * 2 ** Math.min(this.reconnectAttempt, 5), + this.maxBackoffMs, + ); + this.reconnectAttempt += 1; + this.setStatus("connecting"); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.disposed) return; + try { + this.openSocket( + () => { + this.reconnectAttempt = 0; + this.setStatus("open"); + }, + (reason) => { + if (isCompanionAuthFailure(reason)) { + this.setStatus("closed"); + return; + } + this.advanceUrl(); + this.scheduleReconnect(); + }, + ); + } catch { + this.advanceUrl(); + this.scheduleReconnect(); + } + }, backoff); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private clearConnectTimer(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + } + + private failPending(error: Error): void { + if (this.pending.size === 0) return; + const live = [...this.pending.values()]; + this.pending.clear(); + for (const entry of live) { + clearTimeout(entry.timer); + entry.reject(error); + } + } + + private handleMessage(data: unknown): void { + if (typeof data !== "string") return; + let message: RpcIncoming; + try { + message = JSON.parse(data) as RpcIncoming; + } catch { + return; + } + if (!message || typeof message !== "object") return; + if (isRpcResult(message)) { + const entry = this.pending.get(message.id); + if (!entry) return; + this.pending.delete(message.id); + clearTimeout(entry.timer); + if (message.ok) entry.resolve(decodeTransportPayload(message.payload)); + else entry.reject(new Error(message.error || "companion: host error")); + return; + } + if (message.type === "event") { + const set = this.listeners.get(message.event); + if (!set || set.size === 0) return; + const envelope = { payload: message.payload }; + for (const handler of [...set]) { + try { + (handler as TransportEventHandler)(envelope); + } catch { + // One bad listener must not break the fan-out or the socket. + } + } + } + } +} + +export { COMPANION_PROTO_VERSION }; + +/** + * Host byte-commands arrive as `{ __bytes }` envelopes over JSON; decode to + * the ArrayBuffer the local Tauri path would have delivered. + */ +function decodeTransportPayload(payload: unknown): unknown { + if (isBytesEnvelope(payload)) return decodeBytesEnvelope(payload); + return payload; +} diff --git a/src/lib/transport/types.ts b/src/lib/transport/types.ts new file mode 100644 index 00000000..d09b6518 --- /dev/null +++ b/src/lib/transport/types.ts @@ -0,0 +1,28 @@ +/** Transport seam between the React UI and the command backend. + * + * MERGE NOTE: additive-only. Desktop default path (LocalTransport) calls the + * exact same Tauri APIs as before, so upstream updates to call sites keep + * working unchanged. + */ + +/** Matches `@tauri-apps/api/event` UnlistenFn without importing Tauri here. */ +export type UnlistenFn = () => void; + +export type TransportMode = "local" | "remote"; + +/** Same envelope shape Tauri event listeners already receive (`event.payload`). */ +export interface TransportEnvelope { + payload: T; +} + +export type TransportEventHandler = (event: TransportEnvelope) => void; + +export interface Transport { + readonly mode: TransportMode; + invoke(command: string, args?: Record): Promise; + listen( + event: string, + handler: TransportEventHandler, + ): Promise; + dispose(): void; +} diff --git a/src/lib/updater.ts b/src/lib/updater.ts index bc1df616..46923fee 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -1,5 +1,9 @@ import { getVersion } from "@tauri-apps/api/app"; -import { ask, message } from "@tauri-apps/plugin-dialog"; +import { isRemote } from "./transport"; +import { + askDialog as ask, + messageDialog as message, +} from "./transport/dialog"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { announceUpdateAvailable } from "./sounds"; @@ -48,6 +52,14 @@ export async function runUpdateFlow( onProgress?: (snapshot: UpdaterSnapshot) => void, ): Promise { const currentVersion = await readAppVersion(); + // Companions update through the App Store, never self-update: the updater + // plugin is desktop-only and installing a host build from the iPad would + // be actively harmful. + if (isRemote()) { + const snapshot: UpdaterSnapshot = { phase: "idle", currentVersion }; + onProgress?.(snapshot); + return snapshot; + } const base: UpdaterSnapshot = { phase: "checking", currentVersion }; onProgress?.(base); diff --git a/src/lib/windowTransferBootstrap.ts b/src/lib/windowTransferBootstrap.ts index 0c6e532d..5bbc5b07 100644 --- a/src/lib/windowTransferBootstrap.ts +++ b/src/lib/windowTransferBootstrap.ts @@ -1,4 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke } from "./transport"; import type { WindowTransferPayload } from "./windowTransfer"; let transferPromise: Promise | null = null; diff --git a/src/main.tsx b/src/main.tsx index e2fd8e81..f55bf4ec 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,27 @@ -import React, { useLayoutEffect } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useState } from "react"; import ReactDOM from "react-dom/client"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +// TRANSPORT SEAM: see src/lib/transport/. +import { invoke, listen } from "./lib/transport"; import App from "./App"; import { initAppearance } from "./lib/appearance"; import { initSounds } from "./lib/sounds"; -import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; -import { consumeInstalledUpdate } from "./lib/updateNotice"; +import { IS_IPAD } from "./lib/platform"; +import { + getCompanionStatus, + loadCompanionMode, + onCompanionStatusChange, + waitForCompanionLink, +} from "./lib/transport"; +import { + handleQuitRequested, + loadBootWorkspace, + type BootWorkspace, +} from "./lib/appLifecycle"; +import { CompanionPairing } from "./surfaces/CompanionPairing"; +import { + consumeInstalledUpdate, + type InstalledUpdate, +} from "./lib/updateNotice"; import "./index.css"; initAppearance(); @@ -21,39 +36,82 @@ function dismissBootSplash() { splash.classList.add("boot-splash-out"); window.setTimeout(() => splash.remove(), 180); }; - // useLayoutEffect runs before paint. Two frames later the app is on - // screen, so the fade reveals UI instead of the desktop blur. requestAnimationFrame(() => { requestAnimationFrame(fade); }); } -function BootGate({ children }: { children: React.ReactNode }) { +void listen("quit_requested", () => { + void handleQuitRequested(); +}); + +const COMPANION_FIRST = loadCompanionMode() || IS_IPAD; + +function BootRoot() { + const [paired, setPaired] = useState( + () => !COMPANION_FIRST || getCompanionStatus() === "connected", + ); + const [workspace, setWorkspace] = useState(null); + const [installedUpdate, setInstalledUpdate] = + useState(null); + + const enterApp = useCallback(() => { + setPaired(true); + }, []); + useLayoutEffect(() => { dismissBootSplash(); }, []); - return children; -} -void listen("quit_requested", () => { - void handleQuitRequested(); -}); + useEffect(() => { + if (!COMPANION_FIRST) return; + const stop = onCompanionStatusChange((status) => { + if (status === "connected") enterApp(); + }); + void waitForCompanionLink().then(() => { + if (getCompanionStatus() === "connected") enterApp(); + }); + return stop; + }, [enterApp]); -void loadBootWorkspace().then( - ({ windowTransfer, resumed, history, historyCwd }) => { - const installedUpdate = windowTransfer ? null : consumeInstalledUpdate(); - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - , - ); - }, + useEffect(() => { + if (COMPANION_FIRST && !paired) return; + let cancelled = false; + void loadBootWorkspace().then((next) => { + if (cancelled) return; + setWorkspace(next); + setInstalledUpdate( + next.windowTransfer ? null : consumeInstalledUpdate(), + ); + }); + return () => { + cancelled = true; + }; + }, [paired]); + + if (COMPANION_FIRST && !paired) { + return ; + } + if (!workspace) { + return COMPANION_FIRST ? : null; + } + return ( + + ); +} + +const root = document.getElementById("root"); +if (!root) { + throw new Error("missing #root"); +} +ReactDOM.createRoot(root).render( + + + , ); diff --git a/src/surfaces/CompanionPage.tsx b/src/surfaces/CompanionPage.tsx new file mode 100644 index 00000000..eac7bcfe --- /dev/null +++ b/src/surfaces/CompanionPage.tsx @@ -0,0 +1,887 @@ +import { useCallback, useEffect, useState } from "react"; +import QRCode from "react-qr-code"; +import { + Heading, + Row, + SecondaryButton, + Toggle, +} from "../chrome/settingsControls"; +import { + EmbedLoginSection, + GoogleLoginBlock, + logoutLocalEmbed, + TailnetStatusCard, + useLocalEmbed, +} from "./TailnetStatusCard"; +import { + buildPairUrl, + COMPANION_PORT_DEFAULT, + connectAndRememberCompanion, + disconnectCompanion, + forgetCompanion, + loadCompanionMode, + loadPairing, + otherPairingHost, + parsePairUrl, + reconnectCompanion, + savePairing, + switchCompanionRoute, + type PairingDetails, +} from "../lib/transport"; +import { + getCompanionStatus, + invoke, + isRemote, + onCompanionStatusChange, + type CompanionStatus, +} from "../lib/transport"; + +function lanOn(status: RemoteStatus | null): boolean { + if (!status?.enabled) return false; + if (status.lan === true) return true; + if (status.lan === false) return false; + return status.mode === "lan"; +} + +function tailscaleOn(status: RemoteStatus | null): boolean { + if (!status?.enabled) return false; + if (status.tailscale === true) return true; + if (status.tailscale === false) return false; + return status.mode === "tailscale"; +} + +function withAlt( + details: PairingDetails, + alt: string | null | undefined, +): PairingDetails { + const host = alt?.trim(); + if (!host || host === details.host) return details; + return { ...details, altHost: host }; +} + +type RemotePairing = { + port: number; + token: string; + lanIp?: string | null; + tailnetHost?: string | null; + version: number; +}; + +type RemoteStatus = { + enabled: boolean; + lan?: boolean; + tailscale?: boolean; + mode: "tailscale" | "lan"; + port: number; + version: number; + lanIp?: string | null; + tailnetHost?: string | null; + systemTailscale?: boolean; +}; + +type TailnetStatus = { + installed: boolean; + running: boolean; + loginName?: string | null; + displayName?: string | null; + tailnetName?: string | null; + dnsName?: string | null; + magicDns: boolean; +}; + +type PeerCount = { + connected: number; +}; + +type PairCode = { + code: string; + expiresIn: number; +}; + +type EmbedStatus = { + running: boolean; + authorized: boolean; + tailnetIp?: string | null; + loginUrl?: string | null; + error?: string | null; + loginName?: string | null; + displayName?: string | null; + tailnetName?: string | null; + hostname?: string | null; +}; + +function useCompanionStatus(): CompanionStatus { + const [status, setStatus] = useState(() => + getCompanionStatus(), + ); + useEffect(() => onCompanionStatusChange(setStatus), []); + return status; +} + +async function copyText(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + // Clipboard unavailable (permissions, insecure context): select manually. + return false; + } +} + +function companionStatusLabel(status: CompanionStatus): string { + switch (status) { + case "connected": + return "Connected"; + case "connecting": + case "reconnecting": + return "Reconnecting…"; + case "failed": + case "local": + return "Disconnected"; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +/** + * iPad / parked-companion status. Must not fall through to the Mac host + * pairing UI after disconnect (`isRemote()` is then false). + */ +function CompanionClientCard({ + linkStatus, + onDisconnect, + onReconnect, + onUnpair, +}: { + linkStatus: CompanionStatus; + onDisconnect: () => void; + onReconnect: () => void; + onUnpair: () => void; +}) { + const [saved, setSaved] = useState(loadPairing); + useEffect(() => { + setSaved(loadPairing()); + }, [linkStatus]); + useEffect(() => { + if (linkStatus !== "connected") return; + let cancelled = false; + void invoke("remote_status") + .then((status) => { + if (cancelled) return; + const current = loadPairing(); + if (!current) return; + const altHost = otherPairingHost(current.host, { + lanIp: status.lanIp, + tailnetHost: status.tailnetHost, + }); + if (!altHost || altHost === current.altHost) return; + savePairing({ ...current, altHost }); + setSaved(loadPairing()); + }) + .catch(() => { + // Older hosts omit route fields; the saved pairing still works. + }); + return () => { + cancelled = true; + }; + }, [linkStatus]); + const { embed, logout } = useLocalEmbed(true); + const [copied, setCopied] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); + const onCopy = useCallback(async (key: string, value: string) => { + if (await copyText(value)) { + setCopied(key); + window.setTimeout(() => { + setCopied((current) => (current === key ? null : current)); + }, 1500); + } + }, []); + const hostLine = saved ? `${saved.host}:${saved.port}` : "No saved host"; + const connected = linkStatus === "connected"; + return ( + <> + + + {companionStatusLabel(linkStatus)} + + + + + {hostLine} + + + {saved?.altHost ? ( + + switchCompanionRoute()}> + Use other route + + + ) : null} + + {connected ? ( + Disconnect + ) : ( + + Reconnect + + )} + + + + Unpair + + + +

+ Sign in with Google so this device can reach the Mac from anywhere. + Nothing to install. +

+ void onCopy(key, value)} + loggingOut={loggingOut} + onLogout={() => { + setLoggingOut(true); + void logout().finally(() => setLoggingOut(false)); + }} + /> + + ); +} + +/** + * Host-side pairing screen (desktop Settings > Companion). Shows how to + * reach this machine from the iPad over LAN or Tailscale, plus the token. + * Rendered as read-only pairing details on a connected companion instead — + * a companion never serves its own link. + */ +export function CompanionPage() { + const linkStatus = useCompanionStatus(); + const [remote, setRemote] = useState(isRemote()); + const [status, setStatus] = useState(null); + const [pairing, setPairing] = useState(null); + const [tailnet, setTailnet] = useState(null); + const [embed, setEmbed] = useState(null); + const [peers, setPeers] = useState(null); + const [pairCode, setPairCode] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); + + useEffect( + () => + onCompanionStatusChange(() => { + setRemote(isRemote()); + }), + [], + ); + + const refresh = useCallback(async () => { + setError(null); + try { + const [nextStatus, nextPairing, nextTailnet] = await Promise.all([ + invoke("remote_status"), + invoke("remote_pairing"), + invoke("remote_tailnet"), + ]); + setStatus(nextStatus); + setPairing(nextPairing); + setTailnet(nextTailnet); + try { + setEmbed(await invoke("remote_embed_status")); + } catch { + // Mobile shells have no embedded node; the card renders offline. + setEmbed(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + if (!remote) void refresh(); + }, [refresh, remote]); + + // Live status: the node moves through starting → login → online on its + // own, and companions come and go — poll lightly while the link is up. + useEffect(() => { + if (remote || !status?.enabled) return; + let stopped = false; + const poll = async () => { + try { + const [nextEmbed, nextPeers] = await Promise.all([ + invoke("remote_embed_status"), + invoke("remote_peers"), + ]); + if (!stopped) { + setEmbed(nextEmbed); + setPeers(nextPeers); + } + } catch { + // Link went away mid-poll; the next refresh recovers. + } + }; + const timer = window.setInterval(() => void poll(), 4000); + void poll(); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [remote, status?.enabled]); + + const onToggle = useCallback( + async (route: "tailscale" | "lan") => { + setBusy(true); + setError(null); + try { + const on = route === "lan" ? lanOn(status) : tailscaleOn(status); + await invoke("remote_set_route", { + route, + enabled: !on, + }); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, + [refresh, status], + ); + + const onSystemToggle = useCallback(async () => { + setBusy(true); + setError(null); + try { + await invoke("remote_set_system_tailscale", { + enabled: status?.systemTailscale !== true, + }); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [refresh, status]); + + const refreshPairCode = useCallback(async () => { + try { + const next = await invoke<{ code: string; expiresIn: number }>( + "remote_pairing_code", + ); + setPairCode(next); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + useEffect(() => { + if (!remote && status?.enabled) void refreshPairCode(); + }, [refreshPairCode, remote, status?.enabled]); + + const onCopy = useCallback(async (key: string, value: string) => { + if (await copyText(value)) { + setCopied(key); + window.setTimeout(() => { + setCopied((current) => (current === key ? null : current)); + }, 1500); + } + }, []); + + if (loadCompanionMode() || remote) { + return ( + disconnectCompanion()} + onReconnect={() => { + reconnectCompanion(); + }} + onUnpair={() => { + forgetCompanion(); + window.location.reload(); + }} + /> + ); + } + + const port = pairing?.port ?? status?.port ?? COMPANION_PORT_DEFAULT; + const lanHost = pairing?.lanIp?.trim() || status?.lanIp?.trim() || null; + // Prefer live tailnet identity over the pairing snapshot's host. + const tailnetHost = + tailnet?.dnsName?.trim() || pairing?.tailnetHost?.trim() || null; + const lanEnabled = lanOn(status); + const tailscaleEnabled = tailscaleOn(status); + const lanBase: PairingDetails | null = + lanEnabled && lanHost && pairing + ? { host: lanHost, port, token: pairing.token } + : null; + // Tailscale route: the embedded node first, the Mac client's served + // address as fallback when the node is still joining. + const cliDetails: PairingDetails | null = + tailscaleEnabled && tailnetHost && pairing && tailnet?.running + ? { host: tailnetHost, port, token: pairing.token } + : null; + // Embedded node: pair with the node's own tailnet IP (stable per node). + const embedDetails: PairingDetails | null = + tailscaleEnabled && embed?.tailnetIp && pairing + ? { host: embed.tailnetIp, port, token: pairing.token } + : null; + const tailscaleBase = embedDetails ?? cliDetails; + const lanDetails = lanBase + ? withAlt(lanBase, tailscaleBase?.host) + : null; + const tailscaleRoute = tailscaleBase ? withAlt(tailscaleBase, lanHost) : null; + + const routes = [ + lanEnabled ? "Local network" : null, + tailscaleEnabled ? "Tailscale" : null, + ] + .filter(Boolean) + .join(" + "); + const statusDetail = status?.enabled + ? `${routes || "Companion"} · port ${status.port}${ + tailscaleEnabled + ? embed?.running + ? embed.authorized + ? " · tailnet node joined" + : " · tailnet node joining…" + : " · tailnet node off" + : "" + }` + : "Turn on a route to serve this Mac."; + + return ( + <> + {error ?

{error}

: null} + + + + {status?.enabled + ? (peers?.connected ?? 0) > 0 + ? `Connected — ${peers?.connected} ${ + peers?.connected === 1 ? "device" : "devices" + }` + : "Waiting for devices…" + : "Off"} + + + + {pairing ? ( + <> + {status?.enabled ? ( + + {pairCode ? ( + + {pairCode.code} + + ) : null} + void refreshPairCode()} + disabled={busy} + > + {pairCode ? "New code" : "Show code"} + + + ) : null} + + + + void onToggle("tailscale")} + /> + + {tailscaleEnabled ? ( +
+ void onCopy(key, value)} + copied={copied === "tailnet-ip"} + /> + {!embed?.authorized ? ( + void onCopy("login-url", embed?.loginUrl ?? "")} + /> + ) : null} +
+

+ {embed?.authorized + ? "Sign out to use a different Google account on this Mac." + : "If Google shows an error about another tailnet or an existing node, reset and try again."} +

+ +
+ {tailscaleRoute ? ( + + void onCopy("route-tailscale", buildPairUrl(tailscaleRoute)) + } + /> + ) : ( +

+ Node still joining — the route appears here once it has an + address. +

+ )} +
+
+
+ System Tailscale +
+

+ Also forward this port through the Tailscale app already + on this Mac, if you signed in there with Google. +

+
+ void onSystemToggle()} + /> +
+
+ ) : null} + + + void onToggle("lan")} + /> + + {lanEnabled ? ( +
+ {lanDetails ? ( + + void onCopy("route-lan", buildPairUrl(lanDetails)) + } + /> + ) : ( +

+ No LAN address detected — enter this Mac's IP on the + iPad manually. +

+ )} +
+ ) : null} + + +
+ + Token + +

+ Rarely needed — the 6-digit code covers pairing. +

+
+ void onCopy("token", pairing.token)} + /> +
+
+
+ + Pair this device with a different host + +
+ +
+
+ + ) : ( +

+ Loading pairing details… +

+ )} + + ); +} + + +/** + * First-run pairing: turns THIS device into a companion of another host. + * This is the only entry point on a fresh install (which otherwise boots + * into desktop mode with a local backend). On connect the app reloads into + * remote mode; the desktop host path never uses this. + */ +function PairThisDevice() { + const [url, setUrl] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("17233"); + const [token, setToken] = useState(""); + const [secure, setSecure] = useState(false); + const [altHost, setAltHost] = useState(""); + const [error, setError] = useState(null); + + const fillFromUrl = () => { + const details = parsePairUrl(url.trim()); + if (!details) { + setError("That is not a MonoCode pairing link."); + return; + } + setError(null); + setHost(details.host); + setPort(String(details.port)); + setToken(details.token); + setSecure(details.secure ?? false); + setAltHost(details.altHost ?? ""); + }; + + const connect = () => { + const portNumber = Number(port); + if (!host.trim()) { + setError("Enter the host."); + return; + } + if (!Number.isInteger(portNumber) || portNumber <= 0 || portNumber > 65535) { + setError("Port must be 1–65535."); + return; + } + if (!token.trim()) { + setError("Enter the pairing token."); + return; + } + try { + connectAndRememberCompanion({ + host: host.trim(), + port: portNumber, + token: token.trim(), + ...(secure ? { secure: true as const } : {}), + ...(altHost.trim() && altHost.trim() !== host.trim() + ? { altHost: altHost.trim() } + : {}), + }); + window.location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
+

+ Paste the pairing URL from the host, or enter the details manually. + Connecting reloads this device as a companion. +

+
+ setUrl(event.target.value)} + placeholder="monocode://pair?…" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content placeholder:text-content/30" + /> + +
+
+ setHost(event.target.value)} + placeholder="Host (IP or tailnet name)" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + aria-label="Host" + className="rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content placeholder:text-content/30" + /> + setPort(event.target.value)} + inputMode="numeric" + aria-label="Port" + className="rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content" + /> +
+ setToken(event.target.value)} + placeholder="Token" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + aria-label="Token" + className="mt-2 w-full rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content placeholder:text-content/30" + /> + + {error ?

{error}

: null} +
+ +
+
+ ); +} + +/** + * One pairing route: QR for scanning plus host/port strings for typing. + * The 6-digit code from the hero covers auth on every route. + */ +function RouteRow({ + details, + copied, + onCopy, +}: { + details: PairingDetails; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+ +
+
+

+ {details.host}:{details.port} +

+
+ + {copied ? "Copied link" : "Copy link"} + +
+

+ Scan, or type the host and the 6-digit code above. +

+
+
+ ); +} + +function CopyRow({ + label, + value, + hint, + copied, + onCopy, +}: { + label: string; + value: string; + hint?: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+
+
{label}
+

+ {value} +

+ {hint ? ( +

{hint}

+ ) : null} +
+ + {copied ? "Copied" : "Copy"} + +
+ ); +} diff --git a/src/surfaces/CompanionPairing.tsx b/src/surfaces/CompanionPairing.tsx new file mode 100644 index 00000000..3fb3ffe0 --- /dev/null +++ b/src/surfaces/CompanionPairing.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useState } from "react"; +import { + claimPairingCode, + connectAndRememberCompanion, + forgetCompanion, + getCompanionError, + getCompanionStatus, + loadPairing, + onCompanionStatusChange, + otherPairingHost, + parsePairUrl, + type PairingDetails, +} from "../lib/transport"; +import { QrScanner } from "./QrScanner"; +import { EmbedLoginSection, useLocalEmbed } from "./TailnetStatusCard"; + +/** + * First-run / disconnected screen for companion installs. Scan-first: the + * camera reads the full pairing URL (host, port, token, and connection + * type ride along, nothing to choose). Otherwise a 6-digit code plus host — + * the connection type (LAN vs Tailscale) is probed automatically, ws then + * wss. Full-URL paste stays as the last resort. + * Rendered *instead of* App by main.tsx — App itself stays untouched. + */ +export function CompanionPairing({ onConnected }: { onConnected: () => void }) { + const saved = loadPairing(); + const [mode, setMode] = useState<"scan" | "code">("scan"); + const [host, setHost] = useState(saved?.host ?? ""); + const [port, setPort] = useState(saved ? String(saved.port) : "17233"); + const [code, setCode] = useState(""); + const [url, setUrl] = useState(""); + const [status, setStatus] = useState(() => getCompanionStatus()); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + const [copied, setCopied] = useState(null); + const [loggingOut, setLoggingOut] = useState(false); + const { embed, logout } = useLocalEmbed(true); + + useEffect(() => onCompanionStatusChange(setStatus), []); + + useEffect(() => { + if (status === "connected") { + setConnecting(false); + onConnected(); + } + if (status === "failed") { + setConnecting(false); + setError( + getCompanionError() ?? + "That pairing code is no longer valid — scan the code on the Mac again.", + ); + } + }, [status, onConnected]); + + const connectWithToken = useCallback((details: PairingDetails) => { + try { + connectAndRememberCompanion(details); + } catch (err) { + setConnecting(false); + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + const onScan = useCallback( + (text: string) => { + const trimmed = text.trim(); + // Ignore the Google-login QR from the Mac Companion page. Opening it + // here sends the iPad into Safari and the pairing camera never comes + // back until the app is relaunched. + if (/login\.tailscale\.com/i.test(trimmed)) { + return; + } + const details = parsePairUrl(trimmed); + if (!details) { + setError("That code is not a MonoCode pairing link — try manual entry."); + setMode("code"); + return; + } + const tailnetHost = + details.host.endsWith(".ts.net") || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(details.host); + if (tailnetHost && embed && !embed.authorized) { + setError( + "Sign in with Google above first, then scan the Mac pairing code.", + ); + return; + } + setError(null); + setConnecting(true); + connectWithToken(details); + // Unpause the camera if neither host answers. Reconnect keeps + // trying in the background; a later open still calls onConnected. + window.setTimeout(() => { + if (getCompanionStatus() === "connected") { + setConnecting(false); + return; + } + setConnecting(false); + setError( + getCompanionError() ?? + `Could not reach ${details.host}:${details.port}. Scan the Local network code if you are on the same Wi-Fi, allow Local Network for MonoCode, and keep this Mac's companion link enabled.`, + ); + }, 16000); + }, + [connectWithToken, embed], + ); + + const onClaim = useCallback(async () => { + const portNumber = Number(port); + if (!host.trim()) { + setError("Enter the host shown under the code on the Mac."); + return; + } + if (!Number.isInteger(portNumber) || portNumber <= 0 || portNumber > 65535) { + setError("Port must be 1–65535 (usually 17233)."); + return; + } + setError(null); + setConnecting(true); + try { + const claim = await claimPairingCode( + { host: host.trim(), port: portNumber }, + code, + ); + const connectedHost = host.trim(); + const altHost = otherPairingHost(connectedHost, claim); + connectWithToken({ + host: connectedHost, + port: portNumber, + token: claim.token, + ...(altHost ? { altHost } : {}), + }); + } catch (err) { + setConnecting(false); + setError(err instanceof Error ? err.message : String(err)); + } + }, [code, connectWithToken, host, port]); + + const onUseAsDesktop = useCallback(() => { + forgetCompanion(); + window.location.reload(); + }, []); + + return ( +
+
+

Pair with MonoCode

+

+ {status === "local" + ? "Scan the code on your Mac, or enter the 6-digit pairing code." + : "Reconnecting to the paired host…"} +

+ +
+
Tailscale
+

+ Sign in with Google if you are pairing over Tailscale. After + Google, tap Connect on "Connect this device". Same + account as the Mac. +

+ { + setLoggingOut(true); + void logout().finally(() => setLoggingOut(false)); + }} + onCopy={(key, value) => { + void navigator.clipboard.writeText(value).then( + () => { + setCopied(key); + window.setTimeout(() => { + setCopied((current) => (current === key ? null : current)); + }, 1500); + }, + () => { + /* clipboard may be blocked; the URL is still visible */ + }, + ); + }} + /> +
+ +
+ {( + [ + ["scan", "Scan code"], + ["code", "Enter code"], + ] as const + ).map(([id, label]) => ( + + ))} +
+ + {mode === "scan" ? ( +
+ +
+ ) : ( +
+
+
+ + setHost(event.target.value)} + placeholder="192.168.1.20 or tailnet name" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="mt-1 w-full rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content placeholder:text-content/30" + /> +
+
+ + setPort(event.target.value)} + inputMode="numeric" + className="mt-1 w-full rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content" + /> +
+
+ + setCode(event.target.value)} + placeholder="123 456" + inputMode="numeric" + autoComplete="one-time-code" + className="mt-1 w-full rounded-md border border-content/15 bg-transparent px-3 py-2 text-center font-mono text-[20px] tracking-[0.3em] text-content placeholder:text-content/30" + /> +

+ Shown big on the Mac. Connection type is detected automatically. +

+
+ )} + + {error ? ( +

{error}

+ ) : null} + + {mode === "code" ? ( + + ) : null} + {connecting && mode === "scan" ? ( +

Connecting…

+ ) : null} + +
+ + Paste a full pairing URL instead + +
+ setUrl(event.target.value)} + placeholder="monocode://pair?…" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="min-w-0 flex-1 rounded-md border border-content/15 bg-transparent px-3 py-2 font-mono text-[12px] text-content placeholder:text-content/30" + /> + +
+
+ + +
+
+ ); +} diff --git a/src/surfaces/EmptySession.tsx b/src/surfaces/EmptySession.tsx index a3fce653..3fef24b4 100644 --- a/src/surfaces/EmptySession.tsx +++ b/src/surfaces/EmptySession.tsx @@ -5,6 +5,7 @@ import { loadGridArcadeEnabled, subscribeGridArcadeEnabled, } from "../lib/settings"; +import { IS_IPAD } from "../lib/platform"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { TerminalGridBackground } from "./TerminalGridBackground"; @@ -30,7 +31,7 @@ export function EmptySession({ cwd, composer }: Props) { ref={lockOverscroll} className="relative flex h-full min-h-0 overflow-y-auto overscroll-none" > - {arcadeEnabled ? : null} + {arcadeEnabled && !IS_IPAD ? : null} {composer ? (
diff --git a/src/surfaces/InboxView.tsx b/src/surfaces/InboxView.tsx index 313a2ffc..15a64c5f 100644 --- a/src/surfaces/InboxView.tsx +++ b/src/surfaces/InboxView.tsx @@ -75,7 +75,7 @@ import { type InboxSource, } from "../lib/inboxFilters"; import { projectName } from "../lib/paths"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_MACOS } from "../lib/platform"; import { sameProjectPath, type RecentProject } from "../lib/recents"; import { isInboxEntryUnseen, @@ -631,7 +631,7 @@ export function InboxView({ className="flex h-10 shrink-0 select-none items-center border-b border-content/10" data-tauri-drag-region="deep" > - {IS_MAC && !besideRail ?
: null} + {IS_MACOS && !besideRail ?
: null} {besideRail ? null : ( )} diff --git a/src/surfaces/NotesView.tsx b/src/surfaces/NotesView.tsx index fb0eaf73..18da6fd0 100644 --- a/src/surfaces/NotesView.tsx +++ b/src/surfaces/NotesView.tsx @@ -28,7 +28,7 @@ import { requestAddNoteToChat, type Note, } from "../lib/notes"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_MACOS } from "../lib/platform"; import { looksLikeProject } from "../lib/recents"; import { loadTabGroupColors, @@ -282,7 +282,7 @@ export function NotesView({ className="flex h-10 shrink-0 select-none items-center border-b border-content/10" data-tauri-drag-region="deep" > - {IS_MAC && !besideRail ?
: null} + {IS_MACOS && !besideRail ?
: null} {besideRail ? null : ( )} diff --git a/src/surfaces/QrScanner.tsx b/src/surfaces/QrScanner.tsx new file mode 100644 index 00000000..70e3f0b3 --- /dev/null +++ b/src/surfaces/QrScanner.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import jsQR from "jsqr"; + +type Props = { + onScan: (text: string) => void; + /** Stop the camera while a connect is in flight so the UI does not freeze. */ + paused?: boolean; +}; + +/** + * Camera QR scanner for the iPad pairing screen. Streams the rear camera + * into a canvas loop and decodes with jsQR (no network, fully on-device). + * Keeps running until unmounted: repeat decodes of the same code are + * throttled, so a failed connect lets the user simply hold the code up + * again. Any failure — no camera (simulator), denied permission — surfaces + * a message and the caller falls back to manual code entry. + */ +export function QrScanner({ onScan, paused = false }: Props) { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const onScanRef = useRef(onScan); + onScanRef.current = onScan; + const lastRef = useRef<{ value: string; at: number }>({ value: "", at: 0 }); + const [error, setError] = useState(null); + const [active, setActive] = useState(false); + const [foreground, setForeground] = useState( + () => + typeof document === "undefined" || + document.visibilityState === "visible", + ); + + useEffect(() => { + const onVis = () => { + setForeground(document.visibilityState === "visible"); + }; + document.addEventListener("visibilitychange", onVis); + window.addEventListener("pageshow", onVis); + window.addEventListener("focus", onVis); + return () => { + document.removeEventListener("visibilitychange", onVis); + window.removeEventListener("pageshow", onVis); + window.removeEventListener("focus", onVis); + }; + }, []); + + useEffect(() => { + if (paused || !foreground) { + setActive(false); + return; + } + setError(null); + let stream: MediaStream | null = null; + let raf = 0; + let stopped = false; + + const stop = () => { + stopped = true; + cancelAnimationFrame(raf); + stream?.getTracks().forEach((track) => track.stop()); + stream = null; + }; + + const scan = () => { + if (stopped) return; + const video = videoRef.current; + const canvas = canvasRef.current; + if (video && canvas && video.readyState === video.HAVE_ENOUGH_DATA) { + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (context) { + const width = video.videoWidth; + const height = video.videoHeight; + if (width > 0 && height > 0) { + canvas.width = width; + canvas.height = height; + context.drawImage(video, 0, 0, width, height); + try { + const found = jsQR( + context.getImageData(0, 0, width, height).data, + width, + height, + ); + if (found?.data) { + const now = Date.now(); + // Throttle repeats: a failed connect keeps the loop alive, + // so the same code re-fires every few seconds for retry. + if ( + found.data !== lastRef.current.value || + now - lastRef.current.at > 3000 + ) { + lastRef.current = { value: found.data, at: now }; + onScanRef.current(found.data); + } + } + } catch { + // A corrupt frame must not kill the loop. + } + } + } + } + raf = requestAnimationFrame(scan); + }; + + (async () => { + try { + if ( + typeof navigator === "undefined" || + !navigator.mediaDevices?.getUserMedia + ) { + setError("No camera available on this device."); + return; + } + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + audio: false, + }); + if (stopped) { + stop(); + return; + } + const video = videoRef.current; + if (!video) return; + video.srcObject = stream; + await video.play().catch(() => undefined); + setActive(true); + raf = requestAnimationFrame(scan); + } catch { + if (!stopped) { + setError( + "Camera is blocked — allow access in Settings, or enter the code manually.", + ); + } + } + })(); + + return stop; + }, [paused, foreground]); + + if (error) { + return

{error}

; + } + + return ( +
+
+ ); +} diff --git a/src/surfaces/SearchView.tsx b/src/surfaces/SearchView.tsx index bfb37d27..510a719b 100644 --- a/src/surfaces/SearchView.tsx +++ b/src/surfaces/SearchView.tsx @@ -36,7 +36,7 @@ import { recentOpenedFiles, } from "../lib/fileIndex"; import { prettyCwd, projectName } from "../lib/paths"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_MACOS } from "../lib/platform"; import { looksLikeProject, type RecentProject } from "../lib/recents"; import { searchProject, type OpenFileFn } from "../lib/search"; import { type Session } from "../lib/session"; @@ -323,7 +323,7 @@ export function SearchView({ className="flex h-10 shrink-0 select-none items-center border-b border-content/10" data-tauri-drag-region="deep" > - {IS_MAC && !besideRail ?
: null} + {IS_MACOS && !besideRail ?
: null} {besideRail ? null : ( )} diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 1fd326e2..032f766b 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -13,9 +13,15 @@ import { useRef, useState, useSyncExternalStore, - type ReactNode, } from "react"; import { HarnessIcon } from "../chrome/HarnessIcon"; +import { + Heading, + Row, + SecondaryButton, + Toggle, +} from "../chrome/settingsControls"; +import { CompanionPage } from "./CompanionPage"; import { InboxProviderMark } from "../chrome/InboxProviderMark"; import { RemoveProjectDialog } from "../chrome/RemoveProjectDialog"; import { WindowControls } from "../chrome/WindowControls"; @@ -82,7 +88,8 @@ import { subscribeModels, } from "../lib/models"; import { prettyCwd, projectName } from "../lib/paths"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_MACOS } from "../lib/platform"; +import { isCompanionClient } from "../lib/transport"; import { loadArchivedProjects, looksLikeProject, @@ -113,6 +120,7 @@ import { } from "../lib/linear"; import { loadTabGroupLabels, resolveTabGroupLabel } from "../lib/tabGroups"; import { + clampSettingsSection, filterKeybindings, KEYBINDINGS, loadClaudeHooks, @@ -135,7 +143,7 @@ import { type FollowUpBehavior, type SettingsSectionId, } from "../lib/settings"; -import { loadSoundsEnabled, playCue, saveSoundsEnabled } from "../lib/sounds"; +import { loadSoundsEnabled, saveSoundsEnabled } from "../lib/sounds"; import { installPendingUpdate, readAppVersion, @@ -174,6 +182,8 @@ export function SettingsView({ const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const appearance = useAppearanceSettings(); + const companionClient = isCompanionClient(); + const shownSection = clampSettingsSection(section, companionClient); useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -197,17 +207,17 @@ export function SettingsView({ className="flex h-10 shrink-0 select-none items-center border-b border-content/10" data-tauri-drag-region="deep" > - {IS_MAC && !besideRail ?
: null} + {IS_MACOS && !besideRail ?
: null}
Settings / - {settingsSectionLabel(section)} + {settingsSectionLabel(shownSection)}
- {section === "appearance" ? ( + {shownSection === "appearance" ? (
@@ -256,8 +277,10 @@ export function SettingsView({ } function GeneralPage({ + companionClient, onOpenWhatsNew, }: { + companionClient: boolean; onOpenWhatsNew: (version: string) => void; }) { const [transcriptLayout, setTranscriptLayout] = @@ -392,26 +415,30 @@ function GeneralPage({ onChange={onTranscriptAnchor} /> - - - - - - + {companionClient ? null : ( + <> + + + + + + + + )} - - - + description="Run the hooks configured in your settings.json files — PreToolUse command rewrites, blocks, notifications, and the rest — just as the Claude Code CLI would. Turn this off if a hook is misbehaving and you need the session back. Takes effect on the next turn." + > + + + )} - - + {companionClient ? null : ( + <> + + + + )} - + ); } @@ -611,8 +647,10 @@ function LinearSettings() { } function UpdateRow({ + companionClient, onOpenWhatsNew, }: { + companionClient: boolean; onOpenWhatsNew: (version: string) => void; }) { const [snapshot, setSnapshot] = useState({ @@ -644,8 +682,9 @@ function UpdateRow({ await runUpdateFlow(true, setSnapshot); }; - const status = - snapshot.phase === "available" + const status = companionClient + ? "This iPad app updates from the App Store." + : snapshot.phase === "available" ? `Version ${snapshot.availableVersion} is available.` : snapshot.phase === "downloading" ? `Downloading${snapshot.progress != null ? ` ${snapshot.progress}%` : "…"}` @@ -676,16 +715,18 @@ function UpdateRow({ > What's new - void onClick()} disabled={busy}> - {busy ? ( - - ) : hasUpdate ? ( - - ) : ( - + {companionClient ? null : ( + void onClick()} disabled={busy}> + {busy ? ( + + ) : hasUpdate ? ( + + ) : ( + + )} + {hasUpdate ? "Download" : "Check for updates"} + )} - {hasUpdate ? "Download" : "Check for updates"} -
); @@ -758,7 +799,13 @@ function useAppearanceSettings() { }; } -function AppearancePage({ appearance }: { appearance: AppearanceSettings }) { +function AppearancePage({ + companionClient, + appearance, +}: { + companionClient: boolean; + appearance: AppearanceSettings; +}) { const percent = Math.round(appearance.opacity * 100); return ( @@ -778,32 +825,36 @@ function AppearancePage({ appearance }: { appearance: AppearanceSettings }) { onChange={appearance.onThemePreference} /> - - - - - - + {companionClient ? null : ( + <> + + + + + + + + )} appearance.onTint(appearance.themeHue, value)} /> - - - + description="Extend the translucent treatment to the main pane behind sessions and editors." + > + + + )} ); } @@ -1242,43 +1295,7 @@ function PageHeader({ ); } -function Heading({ title, first = false }: { title: string; first?: boolean }) { - return ( -

- {title} -

- ); -} -function Row({ - label, - description, - children, -}: { - label: ReactNode; - description?: string; - children?: ReactNode; -}) { - return ( -
-
-
{label}
- {description ? ( -

- {description} -

- ) : null} -
-
- {children} -
-
- ); -} function Segmented({ label, @@ -1354,37 +1371,7 @@ function Slider({ ); } -function Toggle({ - label, - on, - onChange, -}: { - label: string; - on: boolean; - onChange: (on: boolean) => void; -}) { - return ( - - ); -} + function Select({ label, @@ -1413,29 +1400,4 @@ function Select({ ); } -function SecondaryButton({ - onClick, - disabled = false, - danger = false, - children, -}: { - onClick: () => void; - disabled?: boolean; - danger?: boolean; - children: ReactNode; -}) { - return ( - - ); -} + diff --git a/src/surfaces/TailnetStatusCard.test.ts b/src/surfaces/TailnetStatusCard.test.ts new file mode 100644 index 00000000..66b5d500 --- /dev/null +++ b/src/surfaces/TailnetStatusCard.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { googleSignInHref } from "../lib/tailscaleLogin"; + +describe("googleSignInHref", () => { + it("keeps the device auth URL so Connect this device is shown", () => { + const auth = "https://login.tailscale.com/a/abc123"; + expect(googleSignInHref(auth)).toBe(auth); + }); + + it("unwraps a leftover logout?next= wrapper", () => { + const auth = "https://login.tailscale.com/a/abc123"; + const wrapped = `https://login.tailscale.com/logout?next=${encodeURIComponent(auth)}`; + expect(googleSignInHref(wrapped)).toBe(auth); + }); + + it("leaves non-Tailscale URLs alone", () => { + expect(googleSignInHref("https://example.com/login")).toBe( + "https://example.com/login", + ); + }); +}); diff --git a/src/surfaces/TailnetStatusCard.tsx b/src/surfaces/TailnetStatusCard.tsx new file mode 100644 index 00000000..bba3b366 --- /dev/null +++ b/src/surfaces/TailnetStatusCard.tsx @@ -0,0 +1,382 @@ +import { useEffect, useState } from "react"; +import QRCode from "react-qr-code"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { SecondaryButton } from "../chrome/settingsControls"; +import { googleSignInHref } from "../lib/tailscaleLogin"; + +export type EmbedStatusView = { + running: boolean; + authorized: boolean; + tailnetIp?: string | null; + loginUrl?: string | null; + error?: string | null; + loginName?: string | null; + displayName?: string | null; + tailnetName?: string | null; + hostname?: string | null; +}; + +export type TailnetInfoView = { + loginName?: string | null; + displayName?: string | null; + tailnetName?: string | null; + hostname?: string | null; +}; + +type Phase = "online" | "waiting" | "offline" | "error"; + +function phaseOf(embed: EmbedStatusView | null): Phase { + if (!embed || !embed.running) return "offline"; + if (embed.error) return "error"; + if (embed.authorized) return "online"; + return "waiting"; +} + +const PHASE_META: Record = { + online: { + label: "Online", + dot: "bg-emerald-400", + glow: "shadow-[0_0_12px_2px_rgba(52,211,153,0.55)]", + }, + waiting: { + label: "Waiting for Google", + dot: "bg-amber-400", + glow: "shadow-[0_0_12px_2px_rgba(251,191,36,0.55)]", + }, + offline: { + label: "Offline", + dot: "bg-content/30", + glow: "", + }, + error: { + label: "Error", + dot: "bg-red-400", + glow: "shadow-[0_0_12px_2px_rgba(248,113,113,0.55)]", + }, +}; + +/** Official four-color Google G. */ +function GoogleMark({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +/** + * Live tailnet status card: phase, hostname, Google identity. + */ +export function TailnetStatusCard({ + embed, + info, + peersConnected, + pairUrl, + onCopy, + copied, +}: { + embed: EmbedStatusView | null; + info: TailnetInfoView | null; + peersConnected: number | null; + pairUrl: string | null; + onCopy: (key: string, value: string) => void; + copied: boolean; +}) { + const phase = phaseOf(embed); + const meta = PHASE_META[phase]; + const hostname = + embed?.hostname?.trim() || + info?.hostname?.trim() || + (phase === "online" ? "monocode" : null); + const displayName = + embed?.displayName || info?.displayName || undefined; + const loginName = embed?.loginName || info?.loginName || undefined; + const identity = displayName || loginName || hostname; + const tailnetName = + embed?.tailnetName || info?.tailnetName || undefined; + + return ( +
+
+ + {phase === "online" ? ( + + ) : null} + + + + {meta.label} + + {embed?.tailnetIp ? ( + + ) : hostname ? ( + + {hostname} + + ) : null} +
+ +
+ + + + +
+ + {phase === "online" && pairUrl ? ( +
+
+ +
+
+
+ Scan to pair the iPad +
+

+ {pairUrl} +

+
+
+ ) : null} + + {phase === "error" && embed?.error ? ( +

+ {embed.error} +

+ ) : null} +
+ ); +} + +/** + * Official-looking Google SSO control. Always visible until this device is + * authorized, including a preparing state so the button is never delayed. + */ +export function GoogleLoginBlock({ + loginUrl, + copied, + onCopy, +}: { + loginUrl: string | null; + copied: boolean; + onCopy: () => void; +}) { + const ready = Boolean(loginUrl); + const href = loginUrl ? googleSignInHref(loginUrl) : null; + return ( +
+
+ +

+ Opens Connect this device. + After Google, tap Connect — + do not stop on the Tailscale machines list. Same Google account as + the Mac. +

+ {href ? ( + + ) : null} +
+ {href ? ( +
+ +
+ ) : null} +
+ ); +} + +export async function logoutLocalEmbed(): Promise { + return tauriInvoke("remote_embed_logout"); +} + +/** Start/poll the local embedded node (Mac client or iPad). */ +export function useLocalEmbed(active: boolean): { + embed: EmbedStatusView | null; + logout: () => Promise; +} { + const [embed, setEmbed] = useState(null); + useEffect(() => { + if (!active) return; + let stopped = false; + const tick = async () => { + try { + let next = await tauriInvoke("remote_embed_status"); + if (!next.running) { + next = await tauriInvoke("remote_embed_start", { + input: {}, + }); + } + if (!stopped) setEmbed(next); + } catch { + if (!stopped) setEmbed(null); + } + }; + const timer = window.setInterval(() => void tick(), 4000); + const onVis = () => { + if (document.visibilityState === "visible") void tick(); + }; + document.addEventListener("visibilitychange", onVis); + window.addEventListener("pageshow", onVis); + window.addEventListener("focus", onVis); + void tick(); + return () => { + stopped = true; + window.clearInterval(timer); + document.removeEventListener("visibilitychange", onVis); + window.removeEventListener("pageshow", onVis); + window.removeEventListener("focus", onVis); + }; + }, [active]); + const logout = async () => { + const next = await logoutLocalEmbed(); + setEmbed(next); + }; + return { embed, logout }; +} + +export function EmbedLoginSection({ + embed, + copied, + onCopy, + onLogout, + loggingOut = false, +}: { + embed: EmbedStatusView | null; + copied: boolean; + onCopy: (key: string, value: string) => void; + onLogout?: () => void; + loggingOut?: boolean; +}) { + const authorized = Boolean(embed?.authorized); + return ( + <> + + {!authorized ? ( + onCopy("login-url", embed?.loginUrl ?? "")} + /> + ) : null} + {onLogout ? ( +
+

+ {authorized + ? "Sign out to use a different Google account on this device." + : "If Google shows an error about another tailnet or an existing node, reset and try again."} +

+ + {loggingOut + ? "Resetting…" + : authorized + ? "Sign out of Tailscale" + : "Use a different Google account"} + +
+ ) : null} + + ); +} + +function Stat({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+ {sub ? ( +
+ {sub} +
+ ) : null} +
+ ); +}