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