From 8ba5134174b6acd690ba3c79fbdacf2bdfa4bc6a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 4 Nov 2026 18:02:27 +0100 Subject: [PATCH 1/5] feat(desktop): native macOS app that runs, installs or connects to Codeman Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda, objc2) that opens Codeman in its own window. The universal app is about 5 MB (3.2 MB as a DMG) and runs on macOS 11.3 or later. - Connect screen with two tabs. This Mac finds the local Codeman and offers Open, Start or Install. Start uses launchd for a LaunchAgent install and codeman web -d otherwise. It never starts a second server while one runs, because two servers on one data dir share live sessions. Another machine connects by address (recent servers, Keychain logins) and can install Codeman there over SSH. - Installs run Codeman's install.sh in Terminal from a self-deleting .command script. The installer needs a terminal for sudo, Homebrew and ssh. A local install uses --local --service and the first free port from 3000, and the app opens Codeman once it answers. - The local Codeman's login is read from its LaunchAgent or ~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while the user's own Codeman process holds it, and is never saved. - Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge answers, both scoped to Codeman's own pages, so the page keeps working after a server restart. App cookies are cleared before every connect. - Pop-outs through window.CodemanHost, with an allowlist (webview/ never opens a window), external links in the default browser, downloads to ~/Downloads, real full screen and window state that is remembered. - The connect page refuses a WebKit older than Safari 16.4, which Codeman's terminal needs. Codeman side: viewSubagentTranscript shows a toast when the pop-up is refused instead of failing, with zh-CN strings and a test that pins them. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/.gitignore | 2 + packages/desktop/Cargo.lock | 3134 +++++++++++++++++++++ packages/desktop/Cargo.toml | 87 + packages/desktop/README.md | 272 ++ packages/desktop/assets/connect.html | 897 ++++++ packages/desktop/assets/icon.png | Bin 0 -> 52080 bytes packages/desktop/macos/Info.plist | 59 + packages/desktop/macos/entitlements.plist | 12 + packages/desktop/rustfmt.toml | 1 + packages/desktop/scripts/bundle-macos.sh | 110 + packages/desktop/src/assets.rs | 290 ++ packages/desktop/src/config.rs | 722 +++++ packages/desktop/src/credentials.rs | 344 +++ packages/desktop/src/local_server.rs | 785 ++++++ packages/desktop/src/macos.rs | 1053 +++++++ packages/desktop/src/main.rs | 1845 ++++++++++++ packages/desktop/src/policy.rs | 1572 +++++++++++ packages/desktop/src/probe.rs | 727 +++++ packages/desktop/src/setup.rs | 1522 ++++++++++ src/web/public/i18n.js | 12 + src/web/public/panels-ui.js | 11 +- test/desktop-toast-i18n.test.ts | 91 + 22 files changed, 13547 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/.gitignore create mode 100644 packages/desktop/Cargo.lock create mode 100644 packages/desktop/Cargo.toml create mode 100644 packages/desktop/README.md create mode 100644 packages/desktop/assets/connect.html create mode 100644 packages/desktop/assets/icon.png create mode 100644 packages/desktop/macos/Info.plist create mode 100644 packages/desktop/macos/entitlements.plist create mode 100644 packages/desktop/rustfmt.toml create mode 100755 packages/desktop/scripts/bundle-macos.sh create mode 100644 packages/desktop/src/assets.rs create mode 100644 packages/desktop/src/config.rs create mode 100644 packages/desktop/src/credentials.rs create mode 100644 packages/desktop/src/local_server.rs create mode 100644 packages/desktop/src/macos.rs create mode 100644 packages/desktop/src/main.rs create mode 100644 packages/desktop/src/policy.rs create mode 100644 packages/desktop/src/probe.rs create mode 100644 packages/desktop/src/setup.rs create mode 100644 test/desktop-toast-i18n.test.ts diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore new file mode 100644 index 00000000..73b9c106 --- /dev/null +++ b/packages/desktop/.gitignore @@ -0,0 +1,2 @@ +target/ +dist/ diff --git a/packages/desktop/Cargo.lock b/packages/desktop/Cargo.lock new file mode 100644 index 00000000..62564b67 --- /dev/null +++ b/packages/desktop/Cargo.lock @@ -0,0 +1,3134 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "codeman-desktop" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "block2", + "dirs 6.0.0", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-web-kit", + "security-framework", + "serde", + "serde_json", + "tao", + "ureq", + "url", + "wry", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libc", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[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", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" +dependencies = [ + "base64 0.23.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 7.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml new file mode 100644 index 00000000..8255a486 --- /dev/null +++ b/packages/desktop/Cargo.toml @@ -0,0 +1,87 @@ +[package] +name = "codeman-desktop" +version = "0.1.0" +edition = "2021" +rust-version = "1.90" +description = "Codeman Desktop: a small native window for a Codeman server" +license = "MIT" +publish = false + +[[bin]] +name = "codeman-desktop" +path = "src/main.rs" + +[dependencies] +# Window + event loop (tao), the system web view (wry: WKWebView on macOS, +# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is +# built on these three; using them directly keeps the app small and gives us +# `load_url_with_headers`, which Tauri's navigate() does not have (see README). +tao = "0.37" +wry = { version = "0.57", features = ["devtools"] } +muda = "0.21" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +base64 = "0.22" +dirs = "6" +# The reachability probe. platform-verifier = the OS trust store, the same one +# WKWebView uses, so the probe and the window agree on which certificates pass. +ureq = { version = "3", features = ["platform-verifier"] } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" +# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's +# content size (src/macos.rs). The versions wry already pulls in, so no new +# crate is added. +objc2 = { version = "0.6.5", default-features = false, features = ["std"] } +block2 = { version = "0.6.2", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2-core-foundation", + "block2", + "NSDate", + "NSGeometry", + "NSObject", + "NSOperation", + "NSRange", + "NSSet", + "NSString", + "NSURL", + "NSURLAuthenticationChallenge", + "NSURLCredential", + "NSURLProtectionSpace", + "NSURLRequest", + "NSURLResponse", + "NSURLSession", +] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2-core-foundation", + "NSAlert", + "NSApplication", + "NSButton", + "NSControl", + "NSResponder", + "NSTextField", + "NSView", + "NSWindow", +] } +objc2-web-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "block2", + "objc2-app-kit", + "WKFrameInfo", + "WKNavigationDelegate", + "WKSecurityOrigin", + "WKUIDelegate", + "WKWebView", + "WKWebsiteDataRecord", + "WKWebsiteDataStore", +] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/packages/desktop/README.md b/packages/desktop/README.md new file mode 100644 index 00000000..75eaf4fc --- /dev/null +++ b/packages/desktop/README.md @@ -0,0 +1,272 @@ +# Codeman Desktop + +A small native app that opens Codeman in its own window. Double-click it, and +it runs Codeman on this Mac (installing it first if it is not there) or +connects to Codeman on another machine (installing it there over SSH if +needed), and Codeman fills the window. `⌃⌘F` puts it in full screen. + +macOS first: the app runs on 11.3 or later, and Codeman's own page needs the +WebKit of Safari 16.4 (see Known limits). The same code is meant to build for +Windows and Linux later. + +- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build). +- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop), + [wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and + [muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2) + for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login). + No bundled browser and no Node in the app. +- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab. + +## Why it is cheap + +Codeman is already a web app, so the desktop app is a native window around the +operating system's own web view. An Electron build of the same thing would ship +a whole Chromium (around 150 MB). The native side only adds what a browser tab +cannot do. + +## What it does (v0.1) + +| | | +| --- | --- | +| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | +| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. | +| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | +| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | +| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | +| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. | +| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. | +| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. | +| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. | +| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). | +| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | +| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. | + +## Build (on a Mac) + +Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older +toolchain) and the Xcode Command Line Tools (`xcode-select --install`). + +```bash +cd packages/desktop +cargo test # unit tests +./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture +./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-.dmg +open dist/Codeman.app +``` + +The script builds with `--locked` against the committed `Cargo.lock`, targets +the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and +takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a +cargo config's `build.target-dir` work. + +Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords +in the Keychain under *Codeman Desktop*, one item per username and server. A +`desktop.json` the app cannot read or parse is moved aside, never overwritten: +to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1` +to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten +names are taken, or the file cannot be moved, the app starts with default +settings and saves nothing, which leaves the file where it is. When a server's +username changes (a change of letter case counts), or a server drops off the +recent list (it keeps 8), the Keychain item of the old username is deleted. + +## Decisions, and why + +**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates. +The deciding detail is login: Codeman's password protection is HTTP Basic, and +a WKWebView shows no login dialog of its own (wry 0.57 implements no +authentication-challenge handler). So the app sends the `Authorization` header +itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's +`navigate()` takes only a URL), and from then on answers the HTTP Basic +challenges of Codeman's own pages with the current login. Codeman keeps its +session cookies in memory, so after a server restart that answer is what keeps +the page logged in. Using the crates directly is also smaller. + +**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds +the JavaScript dialog methods and the authentication-challenge method to the +classes of wry's own delegates, so wry stays an unmodified crates.io dependency. + +**What a page may ask of the app** (all in `src/policy.rs`, unit-tested): + +- Codeman shows content it did not write on its own origin: rendered HTML files + (`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app + shell (the dashboard and a session pop-out) may use `window.CodemanHost`. + That is an allowlist on the raw path, and a path holding any `%` fails it, so + an encoded spelling or a route Codeman adds later stays out. +- An app window opens only for Codeman's own pop-out pages under the server's + base path, checked after decoding the path once, and which ones depends on + who asks. `window.CodemanHost` (the app shell only) may open sessions, + rendered HTML views, the raw, preview, attachment and folder file routes, and + `api/download`. A `window.open` or `target=_blank` link, which WebKit passes + on from any frame without saying which, gets only the file routes and + `api/download`, the pages Codeman itself opens that way; otherwise a rendered + HTML file could open copies of itself as top-level windows that pass for the + app's own. Such a window does not take keyboard focus, and these windows + have a budget of their own (3 at once, then 1 per second), apart from the + one links draw on. Any other page on the server's origin is refused rather + than sent to the browser: a top-level `/webview/` load would escape the web + tab's iframe sandbox in either place, so it never opens anywhere. + `window.open('')` is refused too, since WebKit's blank window carries none of + the app's handlers. +- wry's navigation handler sees every frame and does not say which one. So a + navigation to another site is blocked rather than opened in the browser; + otherwise a page full of external iframes could open browser tabs. A real + link click is sent out by the injected script in the main frame only. +- Only `http(s)` URLs ever reach the default browser, at most 3 at once and + then 1 per second. macOS lets a page call `window.open` without a click, so + this keeps a script from opening a browser tab for every call. +- The `Authorization` header is only sent to URLs under the server's base path. + The login answers a Basic challenge only when it is Codeman's own: the + server's scheme, host and port (never a proxy's), a URL below the base path, + and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which + relay 401s from content Codeman did not write. Only such a challenge can + send the user back to the connect screen. WebKit is handed the login without + persistence, so it keeps no copy: every challenge gets the current login, + and one from before a Switch Server is never replayed. +- Each connect starts by clearing the app's cookies (never local storage). + Codeman checks its session cookie before the `Authorization` header, so an + earlier user's cookie would otherwise win over the new login. +- Microphone and camera requests prompt per page. wry's default without a + handler is to grant them to every frame, sandboxed previews included. +- The bundled connect page keeps working until a server page commits in the + main window (it is still the live page while the server loads), and only + the main window may talk to it. +- `codeman web -d` gets the port and base path as separate arguments, never as + shell text. + +**The installer runs in Terminal, not inside the app.** `install.sh` asks for +the administrator password on a terminal (Homebrew, its sudo keep-alive), the +Xcode tools arrive as a system dialog, and ssh asks about host keys and +passwords on a terminal too, so an install without one fails on the first +fresh Mac. The app writes a short script to the per-user temporary folder +(mode 0700, created fresh, deleted by the script as it starts) and opens it +with `open -b com.apple.Terminal`, which needs no permission; scripting +Terminal with osascript would ask for Automation access. The script prints +what it runs. A This Mac install downloads the installer to a temporary file +and runs it from there, so the installer's input is the terminal. Everything in +the scripts is built and unit-tested in `src/setup.rs`; the page sends only +which install it wants, whether other devices should reach it, and the SSH +login, which is checked before it is quoted. + +**This Mac's own login comes from where Codeman keeps it.** A Codeman +installed with a password keeps it in its LaunchAgent (`install.sh`) or in +`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent` +read). The app reads the same places, so a password-protected Codeman on this +Mac opens without typing the password. The username and password always come +from the same place, since the server reads its username from its own +environment. The login goes only to `127.0.0.1` on that Codeman's port, and +only while `lsof` shows one of your own Codeman processes holding that port: +with Codeman stopped, whatever else listens there never gets it. + +**Never a second server on the same sessions.** Two Codeman servers on one +data directory and tmux socket attach to each other's live sessions, and +`codeman web -d` only notices a server it started itself or one at the same +address. So the app looks for running Codeman servers of this user (`ps`) +before it offers or runs a start, and opens the one it finds instead. + +**Closing the main window quits the app.** Codeman's sessions keep running on +the server, so nothing is lost, and reopening takes about a second. + +**One reachability probe per user action, and one to check a refused login.** +Codeman counts a request without valid credentials against its login rate +limit (10 per IP, 15 minutes; a successful login resets the count), so the app +never retries in a loop. Once the server refuses the login in a challenge, the +app sends it only once more, in the single probe that checks the refusal. If +that probe is refused or rate limited, the connect screen comes back; the +app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server +restart, each request that arrives without a cookie gets a 401 before the +login goes out, and those count until one succeeds. Many windows reconnecting +at once can push a request past 10, and that request then fails with a 429 +instead of being answered. A refusal that lands while you are in another app +does not bring Codeman forward: the connect screen loads where the window is, +and the Dock icon bounces. + +## Known limits + +- **Self-signed HTTPS is refused.** A server started with `codeman web --https` + uses a self-signed certificate, and WKWebView rejects it (wry has no trust + override). Use `http://127.0.0.1:3000` for a server on this Mac. For another + machine, serve Codeman through `tailscale serve` + (`https://..ts.net` has a real certificate) or a reverse + proxy with a real certificate. Marking the certificate as trusted in + Keychain Access can only help at `https://localhost` or `https://127.0.0.1`, + the only names Codeman's certificate carries, and may not work even there. + The connect screen says so when it happens. +- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain + knows the app that saved a password by its code signature, and an ad-hoc + signature changes with every build and update. So each new build asks once + for access to the saved passwords: choose *Always Allow*. All Keychain work + runs on one background thread, so the prompt does not freeze the window + while it waits for an answer. To keep one identity across builds, sign with + `SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access + is enough). Microphone and camera permissions are asked again for the same + reason. +- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you + made yourself opens normally; a copy that arrived by download or AirDrop is + quarantined. On macOS 15 and later, try to open it once, then click *Open + Anyway* in System Settings > Privacy & Security (it asks for an administrator + password). On macOS 14 and earlier, right-click it and choose *Open* once. + Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the + copy in Applications works too. Shipping it to other people needs a Developer + ID signature and notarization: + `SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`, + then `xcrun notarytool submit` and `xcrun stapler staple`. +- **Local Network permission (macOS 15 and later).** The first connection to an + address on the local network (a LAN IP, a `.local` name) makes macOS ask + whether Codeman may use the local network, and that first attempt can fail + while the question is open: connect again after answering. If it was denied, + turn Codeman on in System Settings > Privacy & Security > Local Network. A + server started with *Start Codeman on this Mac* counts as part of the app + here, so the same answer covers what it reaches on the local network (ssh to + LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the + app quits. +- **Subagent transcript windows do not open in the app.** Codeman writes that + view into a blank `window.open('')` window, which the app refuses (see + Decisions), and shows a toast instead. A Codeman without the null check in + `viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to + load transcript* there. Open the transcript from a browser tab. +- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman + window waits for it. A page that calls `alert()` in a loop (a rendered HTML + preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`). +- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS + 11.3 or later (downloads go through WKDownload, which arrived in 11.3), but + Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit + that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4 + or later installed. On an older WebKit the connect screen says so and does + not connect. Session pop-outs also need BroadcastChannel (Safari 15.4), + which the same requirement covers. +- **Links in rendered HTML and web tabs do not open.** A link to another site + inside a rendered HTML preview or a web tab does nothing, in the preview + overlay, and for a rendered HTML file in its pop-out window too. The app + sends links out only from Codeman's own pages, and blocks navigation to + other sites in every frame (see Decisions). +- **No login dialog for a web tab's own HTTP Basic login.** The app answers + only Codeman's own login, and WebKit shows no dialog for any other, so a + dashboard in a web tab that asks for HTTP Basic shows its 401 page. +- **Downloads from previews and web tabs are saved without asking.** Rendered + HTML files and web tabs are allowed to start downloads, and the app saves + every download to `~/Downloads` without a prompt. Asking first for those + frames is not done yet. +- **No desktop notifications yet.** WKWebView has no Web Notification API. + Native notifications are the first item on the list below. +- **No service worker.** Codeman works without it; it is only an offline cache. +- **Installing from the app is macOS only so far**, and installing on another + machine needs ssh access to it and `curl` or `wget` there. The app does not + know when a remote install is done or which address it chose: the installer + prints the address, and you connect to it under Another machine. +- **A This Mac install always sets Codeman up as a LaunchAgent** that starts + at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`; + to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`. +- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a + credential store (Credential Manager, Secret Service) and installers. The + rest of the code is platform-neutral. + +## Ideas for next + +- Native notifications and a Dock badge with the number of sessions waiting + for you (tao has `set_badge_label`; Codeman already tracks which sessions need input). +- A global hotkey that brings Codeman to the front from anywhere. +- A menu bar icon showing session states. +- Trusting a self-signed server certificate by its fingerprint. +- Asking before a download that a rendered HTML file or a web tab starts. +- A way out of a page that keeps opening dialogs (a "block further dialogs" option). +- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update. diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html new file mode 100644 index 00000000..972c7e2f --- /dev/null +++ b/packages/desktop/assets/connect.html @@ -0,0 +1,897 @@ + + + + + +Codeman + + + +
+
+ +

Codeman

+

Run Codeman on this Mac, or connect to another machine

+
+ +
+
+ + +
+ + + +
+
+ +
+
Looking for Codeman on this Mac…
+
+
+
+ +
+ + + + + +
+
+ + + +
+
+ +
+ + +
+ + +
+
+ +
+ Codeman Desktop · + Help +
+
+ + + + + diff --git a/packages/desktop/assets/icon.png b/packages/desktop/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..038cff8db32f0321a4f9534018998655794b7987 GIT binary patch literal 52080 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

wDd3IZwbpjk*0B!2%nhcg-N{e-S-BDhED;e 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 + + + + CFBundleName + Codeman + CFBundleDisplayName + Codeman + CFBundleIdentifier + com.getcodeman.desktop + CFBundleVersion + __VERSION__ + CFBundleShortVersionString + __VERSION__ + CFBundleExecutable + codeman-desktop + CFBundleIconFile + Codeman + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + + LSMinimumSystemVersion + 11.3 + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSHumanReadableCopyright + MIT License, Codeman Contributors + + NSMicrophoneUsageDescription + Codeman uses the microphone for voice dictation when you turn it on. + NSCameraUsageDescription + Codeman uses the camera for hand-gesture control when you turn it on. + + NSLocalNetworkUsageDescription + Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network. + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAllowsArbitraryLoadsInWebContent + + + + diff --git a/packages/desktop/macos/entitlements.plist b/packages/desktop/macos/entitlements.plist new file mode 100644 index 00000000..e828eb6f --- /dev/null +++ b/packages/desktop/macos/entitlements.plist @@ -0,0 +1,12 @@ + + + + + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + diff --git a/packages/desktop/rustfmt.toml b/packages/desktop/rustfmt.toml new file mode 100644 index 00000000..75306517 --- /dev/null +++ b/packages/desktop/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/packages/desktop/scripts/bundle-macos.sh b/packages/desktop/scripts/bundle-macos.sh new file mode 100755 index 00000000..ac1b5e6d --- /dev/null +++ b/packages/desktop/scripts/bundle-macos.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS. +# +# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture +# packages/desktop/scripts/bundle-macos.sh --universal --dmg +# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg +# +# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built +# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15 +# and later, try to open it once, then click Open Anyway in System Settings > +# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open. +# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy +# in /Applications. A Developer ID signature + notarization avoids all of this. +set -euo pipefail + +cd "$(dirname "$0")/.." +[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; } + +UNIVERSAL=0 +DMG=0 +for arg in "$@"; do + case "$arg" in + --universal) UNIVERSAL=1 ;; + --dmg) DMG=1 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1) +[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } + +# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist, +# so the two cannot disagree. +MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist) +[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; } +export MACOSX_DEPLOYMENT_TARGET + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Build with cargo and set BUILT to the executable cargo reports for +# codeman-desktop. Asking cargo, rather than assuming ./target, keeps +# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config +# working, and a stale binary from an older build is never bundled. +cargo_build() { + local json + json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics) + BUILT=$(printf '%s\n' "$json" | awk ' + index($0, "\"reason\":\"compiler-artifact\"") && + index($0, "\"kind\":[\"bin\"]") && + index($0, "\"name\":\"codeman-desktop\"") && + match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) } + END { print path }') + if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then + echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2 + exit 1 + fi +} + +if [ "$UNIVERSAL" = 1 ]; then + rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null + cargo_build --target aarch64-apple-darwin + ARM_BIN=$BUILT + cargo_build --target x86_64-apple-darwin + INTEL_BIN=$BUILT + BIN="$WORK/codeman-desktop-universal" + lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN" +else + cargo_build + BIN=$BUILT +fi + +APP=dist/Codeman.app +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/codeman-desktop" +sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist" +plutil -lint "$APP/Contents/Info.plist" >/dev/null +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns +ICONSET="$WORK/Codeman.iconset" +mkdir -p "$ICONSET" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns" + +if [ -n "${SIGN_IDENTITY:-}" ]; then + codesign --force --options runtime --timestamp \ + --entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP" +else + codesign --force --sign - "$APP" +fi +codesign --verify --strict "$APP" + +echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)" + +if [ "$DMG" = 1 ]; then + STAGE="$WORK/dmg" + mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" + DMG_PATH="dist/Codeman-$VERSION.dmg" + rm -f "$DMG_PATH" + hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH" + echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))" +fi diff --git a/packages/desktop/src/assets.rs b/packages/desktop/src/assets.rs new file mode 100644 index 00000000..199f1154 --- /dev/null +++ b/packages/desktop/src/assets.rs @@ -0,0 +1,290 @@ +//! Files bundled into the binary: the connect page, its icon, and the script +//! injected into every Codeman page (`window.CodemanHost`). + +use std::borrow::Cow; +use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode}; + +const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html"); +const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png"); + +/// The connect page loads nothing from outside the app. +const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:"; + +/// Serve `codemanapp://localhost/`. +pub fn respond(request: Request>) -> Response> { + let (body, mime): (&'static [u8], &str) = match request.uri().path() { + "/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"), + "/icon.png" => (ICON_PNG, "image/png"), + _ => { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Cow::Borrowed(&b"not found"[..])) + .expect("static response"); + } + }; + Response::builder() + .header(CONTENT_TYPE, mime) + .header("Content-Security-Policy", CSP) + .header("Cache-Control", "no-store") + .body(Cow::Borrowed(body)) + .expect("static response") +} + +/// Injected at document start into the MAIN frame of every page (wry's +/// default), where it does nothing unless the page is http(s). It provides the +/// native-wrapper bridge Codeman's frontend already looks for +/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see +/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app +/// windows. `openWindow` answers false, posting nothing, for a URL the native +/// side would drop (not http(s), or longer than its 4096-byte cap), so +/// Codeman shows its own error instead of marking a session detached with no +/// window behind it. Besides that it: +/// +/// - turns a click on a same-origin download link (`download=true`, or a path +/// ending in `/download`) into a real download by giving the link a +/// `download` attribute. Codeman's file tree and attachment cards link to +/// `file-raw?download=true` without one, and the server sends a type WebKit +/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman +/// window with the file, with no way back. This runs in the capture phase, +/// before the file tree's own `stopPropagation`. +/// - does the same for `window.open` of such a `download=true` URL (the +/// attachment history), returning null instead of a window. +/// - sends clicks on links to other sites to the default browser, after +/// Codeman's own handlers have had their turn (bubble phase, skipped when +/// one of them called preventDefault). Middle clicks and modifier clicks +/// count too: a bare WKWebView has no new-tab gesture of its own, so they +/// would do nothing. +/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`), +/// once per page load: the session cookie is gone, usually because the +/// server restarted, and nothing in the page asks for the login again. +/// - defines `__codemanDesktopRefused(id)` (not enumerable) for +/// [`refused_script`]. +pub const HOST_SHIM: &str = r#"(function () { + 'use strict'; + if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return; + var MAX_URL = 4096; + var post = function (msg) { + try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; } + }; + // An http(s) URL object, or null. fetch and window.open resolve against + // the document base (Codeman sets ), so those pass it. + var httpUrl = function (raw, base) { + try { + var u = new URL(String(raw), base || location.href); + return /^https?:$/.test(u.protocol) ? u : null; + } catch (e) { return null; } + }; + var sameOrigin = function (u) { return !!u && u.origin === location.origin; }; + var link = function (ev) { + var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null; + return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null; + }; + + Object.defineProperty(window, 'CodemanHost', { + value: Object.freeze({ + desktop: true, + openWindow: function (url) { + var u = httpUrl(url); + return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false; + }, + closeWindow: function () { post({ cmd: 'closeWindow' }); }, + focusWindow: function () { post({ cmd: 'focusWindow' }); } + }) + }); + + // The native side could not open a window: say so. `id` is a string only + // when CodemanHost.openWindow asked for that session's pop-out; that session + // goes back to the dashboard (a fresh channel object, so this window's own + // channel hears it too). Anything else, null included, only shows the toast. + Object.defineProperty(window, '__codemanDesktopRefused', { + value: function (id) { + try { + if (window.app && typeof window.app.showToast === 'function') { + window.app.showToast('Could not open a new window', 'error'); + } + } catch (e) {} + try { + if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') { + var ch = new BroadcastChannel('codeman-windows'); + ch.postMessage({ type: 'redocked', id: id }); + setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000); + } + } catch (e) {} + } + }); + + var markDownload = function (ev) { + if (ev.button > 1) return; + var a = link(ev); + var u = a && httpUrl(a.href); + if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) { + a.setAttribute('download', ''); + } + }; + window.addEventListener('click', markDownload, true); + window.addEventListener('auxclick', markDownload, true); + + var lastMiddle = null; + var openOutside = function (ev) { + if (ev.defaultPrevented || ev.button > 1) return; + var a = link(ev); + var u = a && httpUrl(a.href); + if (!u || sameOrigin(u)) return; + ev.preventDefault(); + // Older WebKit sends a middle click as 'click', newer as 'auxclick'; one + // that sent both must still open the link once. + if (ev.button === 1) { + if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return; + lastMiddle = { a: a, t: ev.timeStamp }; + } + post({ cmd: 'openExternal', url: u.href }); + }; + window.addEventListener('click', openOutside, false); + window.addEventListener('auxclick', openOutside, false); + + var nativeOpen = window.open; + if (typeof nativeOpen === 'function') { + window.open = function open(url) { + try { + var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null; + if (sameOrigin(u) && u.searchParams.get('download') === 'true') { + var a = document.createElement('a'); + a.href = u.href; + a.setAttribute('download', ''); + a.click(); + return null; + } + } catch (e) {} + return nativeOpen.apply(this, arguments); + }; + } + + // Returns the promise fetch itself returned, so callers see exactly what + // they saw before; the 401 check hangs off a side branch that swallows its + // own errors and never reads the body. + var expired = false; + var nativeFetch = window.fetch; + if (typeof nativeFetch === 'function') { + var check = function (input, r) { + if (expired || !r || r.status !== 401) return; + var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input); + if (!sameOrigin(httpUrl(raw, document.baseURI))) return; + expired = true; + post({ cmd: 'sessionExpired' }); + }; + window.fetch = function fetch(input) { + var p = nativeFetch.apply(this, arguments); + try { + if (!expired && p && typeof p.then === 'function') { + p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {}); + } + } catch (e) {} + return p; + }; + } +})();"#; + +/// A toast inside Codeman when a download finished (Codeman's own +/// `showToast`; nothing happens on a page without it). +pub fn download_toast_script(file_name: &str, ok: bool) -> String { + let text = if ok { + format!("Downloaded {file_name} to Downloads") + } else { + format!("Download of {file_name} failed") + }; + let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into()); + let kind = if ok { "\"success\"" } else { "\"error\"" }; + format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}") +} + +/// Runs in the window that asked for a window the native side did not open +/// (pop-out limit reached, policy refused it, window or web view failed to +/// build): the page shows an error toast. Does nothing on a page without the +/// shim. +/// +/// `session_id` is the decoded id of a session pop-out +/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request, +/// which comes from a trusted frame: that session then gets its tab back in +/// the dashboard. Pass `None` for every other refusal, above all the ones a +/// `window.open` or `target=_blank` from any frame caused, so a page cannot +/// make the dashboard drop the detached state of a session that is still +/// popped out. +pub fn refused_script(session_id: Option<&str>) -> String { + let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into()); + format!( + "try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get(path: &str) -> Response> { + respond( + Request::builder() + .uri(format!("codemanapp://localhost{path}")) + .body(Vec::new()) + .unwrap(), + ) + } + + #[test] + fn serves_page_and_icon_only() { + let page = get("/index.html"); + assert_eq!(page.status(), StatusCode::OK); + assert!(page.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("text/html")); + assert!(page.headers().contains_key("content-security-policy")); + assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop")); + assert_eq!(get("/icon.png").status(), StatusCode::OK); + assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND); + assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND); + } + + #[test] + fn refused_script_escapes_the_session_id() { + let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\")); + assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') ")); + assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#)); + assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}")); + assert!(js.ends_with(" } catch (e) {}")); + } + + #[test] + fn refused_script_without_a_session_passes_null() { + assert_eq!( + refused_script(None), + "try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}" + ); + assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")")); + } + + #[test] + fn shim_keeps_its_contract() { + for needle in [ + "Object.freeze", + "openWindow", + "closeWindow", + "focusWindow", + "__codemanDesktopRefused", + "sessionExpired", + "'auxclick'", + "MAX_URL = 4096", + "typeof id === 'string' && id !== ''", + ] { + assert!(HOST_SHIM.contains(needle), "{needle}"); + } + // The refused hook takes the session id the native side resolved; it + // never parses or decodes a URL to find one. + assert!(!HOST_SHIM.contains("decodeURIComponent")); + } + + #[test] + fn toast_escapes_file_names() { + let js = download_toast_script("a\"); alert(1); (\".txt", true); + assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#)); + } +} diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs new file mode 100644 index 00000000..d0942095 --- /dev/null +++ b/packages/desktop/src/config.rs @@ -0,0 +1,722 @@ +//! The app's own settings: recent servers, two preferences and the window +//! frame. Stored as JSON next to other per-app data +//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords +//! never go here; they live in the Keychain (see `credentials`). + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const MAX_SERVERS: usize = 8; +/// How many recovery copies of an unreadable config can sit side by side: +/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`. +const ASIDE_SLOTS: usize = 10; +const MIN_WIDTH: f64 = 480.0; +const MIN_HEIGHT: f64 = 360.0; +const MAX_EDGE: f64 = 20_000.0; + +/// Height of a standard title bar, in points. +const TITLE_BAR: f64 = 28.0; +/// How far the outer top of the window lies above `WindowFrame::y`. On macOS +/// `y` is the content area's top (tao's `with_position` puts the content rect +/// there and the title bar above it); elsewhere `y` is already the outer top. +const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 }; +/// How much of the title bar must be on a screen to grab it. +const MIN_GRIP: f64 = 80.0; +/// Room `fitted_to` leaves when it shrinks a window. Screens are full display +/// bounds, so the menu bar and the Dock are inside them; these are the margins +/// main.rs uses for the centred default frame. +const FIT_MARGIN_X: f64 = 40.0; +const FIT_MARGIN_Y: f64 = 80.0; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct Config { + /// Most recent first. + pub servers: Vec, + /// Connect to the most recent server at launch. + pub auto_connect: bool, + /// Always open in full screen (otherwise the last state is restored). + pub start_fullscreen: bool, + pub window: Option, +} + +impl Default for Config { + fn default() -> Self { + Config { + servers: Vec::new(), + auto_connect: true, + start_fullscreen: false, + window: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ServerEntry { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default)] + pub has_saved_password: bool, +} + +/// The main window's position and inner size, in logical (point) +/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS +/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above +/// it), elsewhere the window's outer top-left. Saving the outer corner on +/// macOS and restoring it as the content corner made the window creep up by +/// a title bar on every launch. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WindowFrame { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + #[serde(default)] + pub fullscreen: bool, +} + +/// A display's full bounds in logical coordinates, as tao reports them. This +/// is the whole display, not the work area: the menu bar and the Dock are +/// inside it. +#[derive(Debug, Clone, Copy)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl WindowFrame { + /// The frame clamped to sane sizes, or None when it holds junk. + pub fn sanitized(self) -> Option { + let vals = [self.x, self.y, self.width, self.height]; + if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE { + return None; + } + Some(WindowFrame { + width: self.width.clamp(MIN_WIDTH, MAX_EDGE), + height: self.height.clamp(MIN_HEIGHT, MAX_EDGE), + ..self + }) + } + + /// The frame made to fit the screens it will open on, or None when its + /// title bar is on none of them (the caller then falls back to a default + /// frame). An edge that no screen covers is pulled onto the screen that + /// holds the title bar, shrinking the window when it is too big for it: + /// a frame saved on an external display that is gone would otherwise + /// open with its right edge and resize corner far off screen. An edge + /// that lands on some screen stays put, so a window spanning two + /// displays keeps its span. `min` is the window's minimum inner size. + pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option { + let home = *self.title_screen(screens)?; + let top = self.outer_top(); + let outer_height = TITLE_BAR + self.height; + let (x, width) = fit_axis( + self.x, + self.width, + ( + covers_x(screens, self.x, top, top + outer_height), + covers_x(screens, self.x + self.width, top, top + outer_height), + ), + (home.x, home.x + home.width), + FIT_MARGIN_X, + min.0, + ); + let (top, outer_height) = fit_axis( + top, + outer_height, + ( + covers_y(screens, top, x, x + width), + covers_y(screens, top + outer_height, x, x + width), + ), + (home.y, home.y + home.height), + FIT_MARGIN_Y - TITLE_BAR, + min.1 + TITLE_BAR, + ); + Some(WindowFrame { + x, + y: top + TITLE_ABOVE_Y, + width, + height: outer_height - TITLE_BAR, + ..*self + }) + } + + fn outer_top(&self) -> f64 { + self.y - TITLE_ABOVE_Y + } + + /// The screen the title bar is on, or None when no screen has enough of + /// it to grab (a display unplugged since the last run leaves the frame off + /// screen). The middle of the title bar must be inside the screen and at + /// least `MIN_GRIP` of the bar's width must overlap it; when several + /// qualify, the one holding most of the bar wins. + fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> { + let title_y = self.outer_top() + TITLE_BAR / 2.0; + screens + .iter() + .filter(|m| title_y >= m.y && title_y <= m.y + m.height) + .map(|m| { + let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x); + (m, grip) + }) + .filter(|(_, grip)| *grip >= MIN_GRIP) + .max_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(m, _)| m) + } +} + +/// Whether some screen holds part of the vertical edge at `x` that runs from +/// `top` to `bottom`. +fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool { + screens + .iter() + .any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y) +} + +/// Whether some screen holds part of the horizontal edge at `y` that runs +/// from `left` to `right`. +fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool { + screens + .iter() + .any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x) +} + +/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for +/// `len`; `covered` says whether some screen holds each end; `home` is the +/// title bar screen's range on this axis. An uncovered end is pulled onto +/// `home`, and the window shrinks (leaving `margin`, never below `min`) when +/// it does not fit; a covered end may stay where it is, on another display. +fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) { + if covered.0 && covered.1 { + return (start, len); + } + let lo = if covered.0 { start.min(home.0) } else { home.0 }; + let hi = if covered.1 { (start + len).max(home.1) } else { home.1 }; + let len = len.min(hi - lo - margin).max(min); + // `max` last: a window still too big for the screen keeps its start (the + // left edge, or the title bar) on screen rather than its end. + let start = start.min(hi - len).max(lo); + (start, len) +} + +/// The first name an unreadable `desktop.json` can be moved to without +/// replacing an earlier recovery copy: `desktop.json.corrupt`, then +/// `desktop.json.corrupt.1` to `.9`, next to it. +/// A name is free only when nothing at all is there: a file, a directory or +/// a dangling symlink left at it is taken, and so is a name that cannot be +/// checked. None when every name is taken. +fn free_aside_name(path: &Path) -> Option { + (0..ASIDE_SLOTS) + .map(|n| match n { + 0 => path.with_extension("json.corrupt"), + n => path.with_extension(format!("json.corrupt.{n}")), + }) + .find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound)) +} + +impl Config { + /// Where the config lives, or None if the OS has no config directory. + pub fn default_path() -> Option { + dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json")) + } + + /// Read the config, and say whether saving back to `path` is safe this + /// run. A missing file is the default. A file that is there but cannot + /// be read (not UTF-8, no permission, a directory, an I/O error) or does + /// not parse is moved aside for recovery, and the defaults are used; it + /// is never silently overwritten with them. It goes to the first free + /// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so + /// a second unreadable file never replaces the copy kept from the first. + /// If every name is taken or the move fails, the answer is false: the + /// original is still at `path`, and the save at exit must not replace it. + pub fn load_from(path: &Path) -> (Config, bool) { + let text = match fs::read_to_string(path) { + Ok(text) => Some(text), + Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true), + Err(_) => None, + }; + if let Some(mut config) = text.and_then(|t| serde_json::from_str::(&t).ok()) { + config.sanitize(); + return (config, true); + } + let Some(aside) = free_aside_name(path) else { + eprintln!( + "codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run", + path.display(), + path.with_extension("json.corrupt").display(), + ASIDE_SLOTS - 1 + ); + return (Config::default(), false); + }; + match fs::rename(path, &aside) { + Ok(()) => { + eprintln!( + "codeman-desktop: {} could not be read; moved it to {} and started with defaults", + path.display(), + aside.display() + ); + (Config::default(), true) + } + Err(e) => { + eprintln!( + "codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run", + path.display() + ); + (Config::default(), false) + } + } + } + + /// Write via a temp file and rename, so a crash never leaves half a file. + pub fn save_to(&self, path: &Path) -> io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("json.tmp"); + let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?; + fs::write(&tmp, text)?; + fs::rename(&tmp, path) + } + + fn sanitize(&mut self) { + let mut seen = std::collections::HashSet::new(); + self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone())); + self.servers.truncate(MAX_SERVERS); + self.window = self.window.and_then(WindowFrame::sanitized); + } + + /// Move (or add) a server to the top of the recent list. Returns the + /// Keychain accounts, as (url, username), of the entries with a username + /// that fall off the end of the list (it keeps `MAX_SERVERS`), for the + /// caller to delete. A different username on `url` itself is not + /// reported: the caller deletes that account before it saves the new + /// one, comparing the names exactly as the Keychain account string + /// does. A delete made after the save could take the new password with + /// it on a Keychain that treats two spellings of a name as one item. + #[must_use = "the returned Keychain accounts must be deleted"] + pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> { + self.servers.retain(|s| s.url != url); + self.servers.insert( + 0, + ServerEntry { + url: url.to_string(), + username: username.map(str::to_string), + has_saved_password: saved, + }, + ); + let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS)); + evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect() + } + + pub fn forget(&mut self, url: &str) -> Option { + let idx = self.servers.iter().position(|s| s.url == url)?; + Some(self.servers.remove(idx)) + } + + pub fn find(&self, url: &str) -> Option<&ServerEntry> { + self.servers.iter().find(|s| s.url == url) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + dir + } + + fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame { + WindowFrame { + x, + y, + width, + height, + fullscreen: false, + } + } + + fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect { + Rect { x, y, width, height } + } + + const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT); + + fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool { + f.title_screen(screens).is_some() + } + + /// The outer frame (title bar included) as left, top, right, bottom. + fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) { + let top = f.y - TITLE_ABOVE_Y; + (f.x, top, f.x + f.width, top + TITLE_BAR + f.height) + } + + #[test] + fn missing_file_is_default() { + let dir = tmp_dir("missing"); + let (c, can_save) = Config::load_from(&dir.join("desktop.json")); + assert_eq!(c, Config::default()); + assert!(c.auto_connect); + assert!(can_save); + } + + #[test] + fn round_trip_and_atomic_save() { + let dir = tmp_dir("roundtrip"); + let path = dir.join("nested").join("desktop.json"); + let mut c = Config::default(); + assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty()); + assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty()); + c.start_fullscreen = true; + c.window = Some(WindowFrame { + x: 10.0, + y: 20.0, + width: 1200.0, + height: 800.0, + fullscreen: true, + }); + c.save_to(&path).unwrap(); + assert!(!path.with_extension("json.tmp").exists()); + let (back, can_save) = Config::load_from(&path); + assert!(can_save); + assert_eq!(back, c); + assert_eq!(back.servers[0].url, "https://box.ts.net/"); + let text = fs::read_to_string(&path).unwrap(); + assert!( + !text.to_lowercase().contains("password\":\""), + "no password values on disk" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_file_is_moved_aside() { + let dir = tmp_dir("corrupt"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + fs::write(&path, "{ not json").unwrap(); + let (c, can_save) = Config::load_from(&path); + assert_eq!(c, Config::default()); + assert!(can_save); + assert!(!path.exists()); + assert!(dir.join("desktop.json.corrupt").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn non_utf8_file_is_moved_aside() { + let dir = tmp_dir("non-utf8"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + // Valid JSON apart from a Latin-1 "ü", which is not UTF-8. + let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}"; + fs::write(&path, bytes).unwrap(); + let (c, can_save) = Config::load_from(&path); + assert_eq!(c, Config::default()); + assert!(can_save); + assert!(!path.exists()); + assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn unreadable_path_is_moved_aside() { + let dir = tmp_dir("unreadable"); + let path = dir.join("desktop.json"); + // A directory where the file should be: it exists but cannot be read. + fs::create_dir_all(path.join("inside")).unwrap(); + let (c, can_save) = Config::load_from(&path); + assert_eq!(c, Config::default()); + assert!(can_save); + assert!(!path.exists()); + assert!(dir.join("desktop.json.corrupt").join("inside").is_dir()); + c.save_to(&path).unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), true)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn second_corruption_keeps_the_first_copy() { + let dir = tmp_dir("corrupt-twice"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + fs::write(&path, "{ first").unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), true)); + fs::write(&path, "{ second").unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), true)); + assert!(!path.exists()); + assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first"); + assert_eq!( + fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), + "{ second" + ); + // A good file written afterwards loads normally and moves nothing. + Config::default().save_to(&path).unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), true)); + assert!(!dir.join("desktop.json.corrupt.2").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn taken_aside_names_are_skipped() { + let dir = tmp_dir("aside-taken"); + // A directory left at the first name and a file at the second: the + // third is the first free one. + fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap(); + fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap(); + let next = if cfg!(unix) { + "desktop.json.corrupt.3" + } else { + "desktop.json.corrupt.2" + }; + let path = dir.join("desktop.json"); + fs::write(&path, "{ not json").unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), true)); + assert!(!path.exists()); + assert!(dir.join("desktop.json.corrupt").is_dir()); + assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept"); + // The dangling symlink still points nowhere and still counts as taken. + #[cfg(unix)] + assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2")) + .unwrap() + .is_symlink()); + assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn no_free_aside_name_disables_saving() { + let dir = tmp_dir("aside-full"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + let names: Vec = std::iter::once(dir.join("desktop.json.corrupt")) + .chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}")))) + .collect(); + assert_eq!(names.len(), 10); + for (n, name) in names.iter().enumerate() { + fs::write(name, format!("copy {n}")).unwrap(); + } + fs::write(&path, "{ not json").unwrap(); + assert_eq!(Config::load_from(&path), (Config::default(), false)); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + for (n, name) in names.iter().enumerate() { + assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}")); + } + assert!(!dir.join("desktop.json.corrupt.10").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn failed_move_aside_disables_saving() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp_dir("stuck"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + fs::write(&path, "{ not json").unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap(); + // Root ignores the read-only directory, so the rename cannot fail. + if fs::write(dir.join("probe"), "").is_ok() { + eprintln!("skipped: the directory is still writable (running as root?)"); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + let _ = fs::remove_dir_all(&dir); + return; + } + let (c, can_save) = Config::load_from(&path); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(c, Config::default()); + assert!(!can_save); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + assert!(!dir.join("desktop.json.corrupt").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn recent_list_is_deduped_and_capped() { + let mut c = Config::default(); + for i in 0..12 { + assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty()); + } + assert!(c.remember("http://h5/", Some("me"), false).is_empty()); + assert_eq!(c.servers.len(), MAX_SERVERS); + assert_eq!(c.servers[0].url, "http://h5/"); + assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1); + assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me")); + assert!(c.find("http://h5/").is_none()); + } + + #[test] + fn remember_leaves_a_username_change_to_the_caller() { + let url = "https://box.ts.net/"; + let mut c = Config::default(); + assert!(c.remember(url, Some("Alice"), true).is_empty()); + // Every change of the username on the same server, a case-only one, + // another user, or none at all, returns nothing: the caller deletes + // the old account before it saves the new one. + for (user, saved) in [ + (Some("alice"), true), + (Some(" alice "), true), + (Some("bob"), true), + (None, false), + (None, false), + (Some("dave"), true), + ] { + assert!(c.remember(url, user, saved).is_empty(), "{user:?}"); + let entry = c.find(url).unwrap(); + assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved)); + assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1); + } + assert!(c.remember("http://other/", Some("carol"), true).is_empty()); + assert!(c.remember(url, Some("erin"), true).is_empty()); + assert_eq!(c.servers.len(), 2); + assert_eq!(c.servers[0].url, url); + assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol")); + } + + #[test] + fn remember_returns_evicted_accounts() { + let mut c = Config::default(); + for i in 0..MAX_SERVERS { + let user = (i % 2 == 0).then(|| format!("u{i}")); + assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty()); + } + // Moving a listed server to the top evicts nothing. + assert!(c.remember("http://h3/", None, false).is_empty()); + assert_eq!(c.servers.len(), MAX_SERVERS); + // A new server pushes the oldest (h0, with a username) off the end. + assert_eq!( + c.remember("http://new/", None, false), + vec![("http://h0/".to_string(), "u0".to_string())] + ); + // The next oldest is h1, which has no username: nothing to delete. + assert!(c.remember("http://newer/", Some("x"), true).is_empty()); + assert_eq!(c.servers.len(), MAX_SERVERS); + assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none()); + // A username change on a listed server returns nothing; the next new + // server evicts the oldest entry, and only that account comes back. + let mut c = Config::default(); + for i in 0..MAX_SERVERS { + assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty()); + } + assert!(c.remember("http://h7/", Some("v"), true).is_empty()); + assert_eq!( + c.remember("http://h8/", Some("w"), true), + vec![("http://h0/".to_string(), "u".to_string())] + ); + assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v")); + assert_eq!(c.servers.len(), MAX_SERVERS); + } + + #[test] + fn unknown_and_missing_fields_tolerated() { + let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap(); + assert!(c.auto_connect); + assert_eq!(c.servers[0].username, None); + } + + #[test] + fn frame_sanitizing_and_reachability() { + assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none()); + let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap(); + assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT)); + let screen = [rect(0.0, 0.0, 1512.0, 982.0)]; + let on = frame(100.0, 50.0, 1200.0, 800.0); + assert!(reachable(&on, &screen)); + assert_eq!(on.fitted_to(&screen, MIN), Some(on)); + let off = frame(3000.0, 50.0, 1200.0, 800.0); + assert!(!reachable(&off, &screen)); + assert_eq!(off.fitted_to(&screen, MIN), None); + let above = frame(100.0, -900.0, 1200.0, 800.0); + assert!(!reachable(&above, &screen)); + assert_eq!(above.fitted_to(&screen, MIN), None); + // Only a sliver of the title bar on screen is not enough to grab. + let sliver = frame(-1150.0, 50.0, 1200.0, 800.0); + assert!(!reachable(&sliver, &screen)); + assert_eq!(sliver.fitted_to(&screen, MIN), None); + // No screens at all (none reported): nothing is reachable. + assert_eq!(on.fitted_to(&[], MIN), None); + } + + #[test] + fn title_bar_is_above_y_on_macos() { + let screen = [rect(0.0, 0.0, 1512.0, 982.0)]; + // On macOS the title bar of a content top at y = 10 is at -18..10, + // mostly above the screen; elsewhere it is at 10..38. + let high = frame(100.0, 10.0, 1200.0, 800.0); + assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos")); + // Half the bar above the top edge: still grabbable, and fitting + // brings all of it onto the screen. + let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0); + assert!(reachable(&straddling, &screen)); + let fitted = straddling.fitted_to(&screen, MIN).unwrap(); + assert_eq!(outer(&fitted).1, 0.0); + assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0)); + } + + #[test] + fn frame_from_an_unplugged_ultrawide_is_fitted() { + // Saved on a 3440x1440 display at the origin, restored on a laptop. + let laptop = [rect(0.0, 0.0, 1512.0, 982.0)]; + let wide = frame(0.0, 30.0, 3300.0, 1300.0); + let fitted = wide.fitted_to(&laptop, MIN).unwrap(); + assert_eq!( + (fitted.x, fitted.y, fitted.width, fitted.height), + (0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y) + ); + let (left, top, right, bottom) = outer(&fitted); + assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0); + // Hanging off the right of the laptop by a bit: shifted, not shrunk. + let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap(); + assert_eq!( + (fitted.x, fitted.y, fitted.width, fitted.height), + (312.0, 100.0, 1200.0, 700.0) + ); + // Hanging off the left: shifted right. + let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap(); + assert_eq!((fitted.x, fitted.width), (0.0, 1200.0)); + } + + #[test] + fn frame_spanning_two_displays_is_left_alone() { + let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)]; + let span = frame(1000.0, 100.0, 1500.0, 700.0); + assert_eq!(span.fitted_to(&screens, MIN), Some(span)); + // Only the edge that lands on no display moves: the left edge stays + // on the laptop, the right edge comes back onto the external one. + let past = frame(1000.0, 100.0, 3500.0, 700.0); + let fitted = past.fitted_to(&screens, MIN).unwrap(); + assert_eq!(fitted.x, 1000.0); + assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X); + assert_eq!((fitted.y, fitted.height), (100.0, 700.0)); + } + + #[test] + fn fitted_frame_keeps_the_minimum_size() { + let tiny = [rect(0.0, 0.0, 400.0, 300.0)]; + let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap(); + assert_eq!((fitted.width, fitted.height), MIN); + // Too big for the screen even at the minimum: the left edge and the + // title bar stay on it. + let (left, top, _, _) = outer(&fitted); + assert_eq!((left, top), (0.0, 0.0)); + assert!(reachable(&fitted, &tiny)); + // The fullscreen flag rides along. + let full = WindowFrame { + fullscreen: true, + ..frame(0.0, 40.0, 1200.0, 800.0) + }; + assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen); + } +} diff --git a/packages/desktop/src/credentials.rs b/packages/desktop/src/credentials.rs new file mode 100644 index 00000000..552f560a --- /dev/null +++ b/packages/desktop/src/credentials.rs @@ -0,0 +1,344 @@ +//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item +//! per username + server). Nothing secret is ever written to the config file. +//! Windows and Linux get their stores when those builds land. +//! +//! Every Keychain call runs on ONE long-lived worker thread, never on the +//! event loop: any of them can bring up the system's access prompt (an +//! ad-hoc build's items name the previous build), and the window must keep +//! running while it is up. One serial worker rather than a thread per call, +//! because jobs must run in the order they were asked for: a delete from +//! Forget that ran after a later save of the same account would remove the +//! password that was just saved. + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; + +pub const SERVICE: &str = "Codeman Desktop"; + +/// What a job reports when the worker thread is gone. +const WORKER_GONE: &str = "The Keychain could not be reached."; + +/// The Keychain account name for a username on a server. +pub fn account(server_url: &str, username: &str) -> String { + format!("{username} @ {server_url}") +} + +/// The password store the worker runs jobs against: the Keychain on macOS, +/// a stub elsewhere, an in-memory map in the tests. +trait Backend: Send { + fn load(&mut self, account: &str) -> Option; + fn save(&mut self, account: &str, password: &str) -> Result<(), String>; + fn delete(&mut self, account: &str); +} + +enum Job { + Load { + account: String, + reply: Sender>, + }, + Save { + account: String, + password: String, + reply: Sender>, + }, + Delete { + account: String, + }, + /// Ends the worker, so the tests can check what callers see after it. + #[cfg(test)] + Stop, +} + +/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`] +/// and clone the handle: every clone feeds the same queue, which is what +/// keeps jobs in order. The worker ends when the last handle is dropped. +#[derive(Clone)] +pub struct Keychain { + jobs: Sender, +} + +impl Keychain { + /// Starts the worker. If the thread cannot be started, the handle still + /// works and every call fails soft (no password, save refused). + pub fn spawn() -> Keychain { + start(Box::new(imp::System)).0 + } + + /// The saved password for an account. Blocks until every job queued + /// before it has run and the Keychain has answered, which can mean + /// waiting on the access prompt, so call it from a worker thread. + /// `None` when nothing is saved, the item is unreadable or the worker + /// is gone. + pub fn load_blocking(&self, account: &str) -> Option { + let (reply, answer) = mpsc::channel(); + let job = Job::Load { + account: account.to_owned(), + reply, + }; + self.jobs.send(job).ok()?; + answer.recv().ok().flatten() + } + + /// Saves a password, replacing any saved before. Blocks like + /// [`Keychain::load_blocking`]; the result is the Keychain's own. + pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> { + let (reply, answer) = mpsc::channel(); + let job = Job::Save { + account: account.to_owned(), + password: password.to_owned(), + reply, + }; + if self.jobs.send(job).is_err() { + return Err(WORKER_GONE.into()); + } + answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into())) + } + + /// Queues the removal of an account's password and returns at once. + /// Jobs queued after it see the account gone. Nothing happens when the + /// worker is gone. + pub fn delete(&self, account: &str) { + let _ = self.jobs.send(Job::Delete { + account: account.to_owned(), + }); + } +} + +/// Starts a worker on `backend`. The join handle is for the tests; `None` +/// when the thread could not be started, in which case the receiver went +/// down with the closure and every send fails. +fn start(backend: Box) -> (Keychain, Option>) { + let (jobs, queue) = mpsc::channel(); + let worker = thread::Builder::new() + .name("keychain".into()) + .spawn(move || run(backend, queue)) + .ok(); + (Keychain { jobs }, worker) +} + +/// The worker loop: one job at a time, in the order they were sent, until +/// every handle is gone. A reply nobody waits for any more is dropped. +fn run(mut backend: Box, queue: Receiver) { + for job in queue { + match job { + Job::Load { account, reply } => { + let _ = reply.send(backend.load(&account)); + } + Job::Save { + account, + password, + reply, + } => { + let _ = reply.send(backend.save(&account, &password)); + } + Job::Delete { account } => backend.delete(&account), + #[cfg(test)] + Job::Stop => return, + } + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{Backend, SERVICE}; + use objc2::rc::autoreleasepool; + use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password}; + + /// The login Keychain, through security-framework. The worker lives as + /// long as the app and a thread's implicit autorelease pool drains only + /// when the thread ends, so each call gets its own pool and whatever + /// Security autoreleases is freed with the job. + pub struct System; + + impl Backend for System { + fn load(&mut self, account: &str) -> Option { + autoreleasepool(|_| { + get_generic_password(SERVICE, account) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + }) + } + + fn save(&mut self, account: &str, password: &str) -> Result<(), String> { + autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string())) + } + + fn delete(&mut self, account: &str) { + autoreleasepool(|_| { + let _ = delete_generic_password(SERVICE, account); + }) + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::Backend; + + /// No password store yet on this platform. + pub struct System; + + impl Backend for System { + fn load(&mut self, _account: &str) -> Option { + None + } + fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> { + Err("Saving passwords is not supported on this platform yet.".into()) + } + fn delete(&mut self, _account: &str) {} + } +} + +#[cfg(test)] +mod tests { + use super::{start, Backend, Job, Keychain}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + /// An in-memory store that logs every job it runs. Its delete is slow on + /// purpose: a queue that did not keep order would let the next job + /// overtake it, and the tests would see that. + #[derive(Clone, Default)] + struct Memory { + items: Arc>>, + log: Arc>>, + } + + impl Memory { + fn note(&self, entry: String) { + self.log.lock().unwrap().push(entry); + } + fn log(&self) -> Vec { + self.log.lock().unwrap().clone() + } + } + + impl Backend for Memory { + fn load(&mut self, account: &str) -> Option { + self.note(format!("load {account}")); + self.items.lock().unwrap().get(account).cloned() + } + fn save(&mut self, account: &str, password: &str) -> Result<(), String> { + self.note(format!("save {account}")); + self.items + .lock() + .unwrap() + .insert(account.to_owned(), password.to_owned()); + Ok(()) + } + fn delete(&mut self, account: &str) { + std::thread::sleep(Duration::from_millis(30)); + self.note(format!("delete {account}")); + self.items.lock().unwrap().remove(account); + } + } + + /// A store whose load fails the way a crashed worker would. + struct Crashes; + + impl Backend for Crashes { + fn load(&mut self, _account: &str) -> Option { + panic!("keychain worker crashed"); + } + fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> { + Ok(()) + } + fn delete(&mut self, _account: &str) {} + } + + fn keychain() -> (Keychain, Memory) { + let memory = Memory::default(); + let (keychain, worker) = start(Box::new(memory.clone())); + assert!(worker.is_some(), "the worker thread starts"); + (keychain, memory) + } + + #[test] + fn account_names_are_per_user_and_server() { + assert_eq!( + super::account("http://127.0.0.1:3000/", "admin"), + "admin @ http://127.0.0.1:3000/" + ); + assert_ne!( + super::account("http://a/", "admin"), + super::account("http://b/", "admin") + ); + } + + #[test] + fn a_load_reads_what_a_save_wrote() { + let (keychain, _) = keychain(); + assert_eq!(keychain.load_blocking("a"), None); + assert_eq!(keychain.save_blocking("a", "secret"), Ok(())); + assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret")); + assert_eq!(keychain.save_blocking("a", "changed"), Ok(())); + assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed")); + assert_eq!(keychain.load_blocking("b"), None); + } + + #[test] + fn a_delete_queued_before_a_save_never_removes_the_saved_password() { + let (keychain, memory) = keychain(); + assert_eq!(keychain.save_blocking("a", "old"), Ok(())); + keychain.delete("a"); + assert_eq!(keychain.save_blocking("a", "new"), Ok(())); + assert_eq!(keychain.load_blocking("a").as_deref(), Some("new")); + assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]); + } + + #[test] + fn a_delete_queued_after_a_save_removes_it_before_the_next_job() { + let (keychain, memory) = keychain(); + assert_eq!(keychain.save_blocking("a", "secret"), Ok(())); + keychain.delete("a"); + // No wait between the two: the load is queued behind the delete. + assert_eq!(keychain.load_blocking("a"), None); + assert_eq!(memory.log(), ["save a", "delete a", "load a"]); + } + + #[test] + fn jobs_from_clones_on_other_threads_share_one_queue() { + let (keychain, memory) = keychain(); + let other = keychain.clone(); + std::thread::spawn(move || { + other.save_blocking("a", "secret").unwrap(); + other.delete("a"); + }) + .join() + .unwrap(); + assert_eq!(keychain.load_blocking("a"), None); + assert_eq!(memory.log(), ["save a", "delete a", "load a"]); + } + + #[test] + fn every_call_fails_soft_once_the_worker_is_gone() { + let memory = Memory::default(); + let (keychain, worker) = start(Box::new(memory.clone())); + assert_eq!(keychain.save_blocking("a", "secret"), Ok(())); + keychain.jobs.send(Job::Stop).unwrap(); + worker.unwrap().join().unwrap(); + + assert_eq!(keychain.load_blocking("a"), None); + assert!(keychain.save_blocking("a", "secret").is_err()); + keychain.delete("a"); + keychain.clone().delete("a"); + assert_eq!(memory.log(), ["save a"]); + } + + #[cfg(panic = "unwind")] + #[test] + fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() { + let (keychain, worker) = start(Box::new(Crashes)); + assert_eq!(keychain.load_blocking("a"), None); + assert!(worker.unwrap().join().is_err(), "the worker panicked"); + assert!(keychain.save_blocking("a", "secret").is_err()); + keychain.delete("a"); + } + + #[test] + fn the_handle_is_send_and_sync() { + fn send_sync_clone() {} + send_sync_clone::(); + } +} diff --git a/packages/desktop/src/local_server.rs b/packages/desktop/src/local_server.rs new file mode 100644 index 00000000..9047f6d4 --- /dev/null +++ b/packages/desktop/src/local_server.rs @@ -0,0 +1,785 @@ +//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell. +//! +//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which +//! finds neither `codeman` nor `node`, and the server inherits whatever +//! environment it is started with: every session it spawns looks for +//! claude/codex/tmux on that PATH and reads provider keys from it. So the +//! command runs with the login shell's environment, picking up Homebrew and the +//! user's profile, and falls back to the interactive profile (`-i`) for setups +//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path +//! are passed as separate arguments (`"$@"`), never spliced into the script text. +//! +//! How depends on the shell, chosen by its basename as Codeman's own +//! `src/utils/shell-resolver.ts` does: +//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself: +//! `$SHELL -l [-i] -c SCRIPT codeman-desktop `. +//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse +//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to +//! print its environment between two markers, and `SCRIPT` then runs under +//! `/bin/sh` with exactly that environment. +//! +//! `codeman web -d` refuses to start a second server on the same data dir and +//! returns once the new one answers `/api/status`, so this is safe to call +//! only after the probe found nothing listening. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use url::Url; + +use crate::policy; + +/// For the whole start, every shell run and retry included. +const TIMEOUT: Duration = Duration::from_secs(60); + +/// How long the pipes may stay open after the shell exited. Something the +/// profile started in the background can inherit them and keep them open for +/// as long as it runs; whatever arrived by then is used. +const PIPE_GRACE: Duration = Duration::from_secs(2); + +/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`. +const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi +if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then + exec node "$HOME/.codeman/app/dist/index.js" "$@" +fi +echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2 +exit 127"#; + +/// Bracket the environment a non-POSIX login shell prints, so whatever its +/// profile writes to stdout before or after cannot be read as a variable. +const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__"; +const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__"; + +/// What a non-POSIX login shell runs to report its environment. The shell +/// itself only parses a command path and one single-quoted word with no `;`, +/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have +/// no syntax of their own to disagree on. The markers and `env` run under +/// `/bin/sh`, which inherits everything the profile exported. +fn capture_command() -> String { + format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'") +} + +fn login_shell() -> PathBuf { + std::env::var_os("SHELL") + .map(PathBuf::from) + .filter(|p| p.is_absolute() && p.exists()) + .unwrap_or_else(|| PathBuf::from("/bin/zsh")) +} + +/// How the login shell takes part in a start. +#[derive(Debug, PartialEq, Eq)] +enum Flavor { + /// Runs `SCRIPT` itself. + Posix, + /// Only reports its environment. One entry per attempt, in order: a later + /// one runs when the earlier one printed no environment or its environment + /// had no `codeman` (exit 127). + Capture(&'static [&'static [&'static str]]), +} + +fn flavor(shell: &Path) -> Flavor { + match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") { + "sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix, + // tcsh honors -l only when it is the ONLY flag, so a login shell that + // also runs a command is impossible; -i at least reads all of .tcshrc + // (csh: .cshrc), including the parts guarded by `$?prompt`. + "csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]), + _ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]), + } +} + +/// What one process printed, stdout and stderr kept apart. +struct Output { + code: Option, + stdout: String, + stderr: String, +} + +/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page. +struct Run { + code: Option, + output: String, +} + +impl From for Run { + fn from(out: Output) -> Self { + let mut output = out.stdout; + if !out.stderr.trim().is_empty() { + output.push_str(&out.stderr); + } + Run { + code: out.code, + output: last_lines(&output, 6), + } + } +} + +/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send +/// `None` once it closes. +fn pump(pipe: Option, stream: usize, tx: mpsc::Sender<(usize, Option>)>) { + std::thread::spawn(move || { + if let Some(mut pipe) = pipe { + let mut buf = [0u8; 8192]; + loop { + match pipe.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if tx.send((stream, Some(buf[..n].to_vec()))).is_err() { + return; + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + } + let _ = tx.send((stream, None)); + }); +} + +/// Run `cmd` to completion with stdin closed, or kill it at `deadline`. +fn exec(mut cmd: Command, deadline: Instant) -> Result { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?; + + // Read both pipes on threads so a chatty profile cannot fill one and stall. + let (tx, rx) = mpsc::channel(); + pump(child.stdout.take(), 0, tx.clone()); + pump(child.stderr.take(), 1, tx); + + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("Starting Codeman took longer than a minute; it was stopped.".into()); + } + Err(e) => return Err(e.to_string()), + } + }; + + let mut bufs = [Vec::new(), Vec::new()]; + let settle = (Instant::now() + PIPE_GRACE).min(deadline); + let mut open = 2; + while open > 0 { + match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) { + Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes), + Ok((_, None)) => open -= 1, + Err(_) => { + // Still held open past the grace: keep what already arrived. + for (stream, bytes) in rx.try_iter() { + if let Some(bytes) = bytes { + bufs[stream].extend_from_slice(&bytes); + } + } + break; + } + } + } + // Lossy rather than strict: one stray non-UTF-8 byte in a profile's output + // must not cost the whole environment. + let [out, err] = bufs; + Ok(Output { + code: status.code(), + stdout: String::from_utf8_lossy(&out).into_owned(), + stderr: String::from_utf8_lossy(&err).into_owned(), + }) +} + +/// The tail of what the command printed, for the connect page. +fn last_lines(text: &str, n: usize) -> String { + let lines: Vec<&str> = text + .lines() + .map(str::trim_end) + .filter(|l| !l.trim().is_empty()) + .collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +/// The variables `env` printed between the markers, or None when the output +/// does not hold both. A line that does not start with a portable variable +/// name followed by `=` continues the previous value (a value with a newline +/// in it); one that does starts the next variable. +fn parse_env(output: &str) -> Option> { + let mut lines = output.split('\n'); + // ends_with: a profile line printed without its newline sits in front. + lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?; + let mut vars: Vec<(String, String)> = Vec::new(); + for line in lines { + if line.trim() == ENV_END { + return Some(vars); + } + match env_entry(line) { + Some((name, value)) => vars.push((name.to_string(), value.to_string())), + None => { + if let Some((_, value)) = vars.last_mut() { + value.push('\n'); + value.push_str(line); + } + } + } + } + None +} + +fn env_entry(line: &str) -> Option<(&str, &str)> { + let (name, value) = line.split_once('=')?; + let mut chars = name.chars(); + let first = chars.next()?; + let portable = + (first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); + portable.then_some((name, value)) +} + +/// Ask `shell` for its environment with `flags`. The outer error ends the +/// start (the shell could not be run, or time is up); the inner one means this +/// form printed no environment and the next may be tried. +fn capture_env( + shell: &Path, + flags: &[&str], + deadline: Instant, +) -> Result, String>, String> { + let mut cmd = Command::new(shell); + cmd.args(flags).arg(capture_command()); + let out = exec(cmd, deadline)?; + Ok(parse_env(&out.stdout).ok_or_else(|| { + // stderr only: stdout can hold part of the environment, API keys included. + let said = last_lines(&out.stderr, 6); + let mut message = format!("Could not read the environment of your login shell {}", shell.display()); + if said.is_empty() { + let code = out + .code + .map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}")); + message.push_str(&format!(" (it ended with {code}).")); + } else { + message.push_str(":\n"); + message.push_str(&said); + } + message + })) +} + +/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured +/// environment, so the server gets the profile's PATH, API keys and CODEMAN_* +/// settings and nothing of launchd's bare one. +fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result { + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(script).arg("codeman-desktop").args(args); + cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v))); + exec(cmd, deadline).map(Run::from) +} + +/// `script` run by the POSIX login shell itself. +fn run_in_shell( + script: &str, + shell: &Path, + interactive: bool, + args: &[String], + deadline: Instant, +) -> Result { + let mut cmd = Command::new(shell); + cmd.arg("-l"); + if interactive { + cmd.arg("-i"); + } + cmd.arg("-c").arg(script).arg("codeman-desktop").args(args); + exec(cmd, deadline).map(Run::from) +} + +/// Run `script` the way the login shell allows (see the module docs). Exit +/// 127 means "no codeman here" and moves on to the next way of asking; the +/// interactive profile is asked only when `interactive` allows it. +fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result { + let attempts = match flavor(shell) { + Flavor::Posix => { + let result = run_in_shell(script, shell, false, args, deadline)?; + if result.code != Some(127) || !interactive { + return Ok(result); + } + // Not on the login PATH; try the interactive profile too. + return run_in_shell(script, shell, true, args, deadline); + } + Flavor::Capture(attempts) => attempts, + }; + let mut last_run = None; + let mut capture_error = None; + let interactive_flag = |flags: &&&[&str]| { + flags + .iter() + .any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i')) + }; + for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) { + match capture_env(shell, flags, deadline)? { + Ok(env) => { + let result = run_with_env(script, &env, args, deadline)?; + if result.code != Some(127) { + return Ok(result); + } + last_run = Some(result); + } + Err(e) => capture_error = Some(e), + } + } + if let Some(result) = last_run { + return Ok(result); + } + // No form printed an environment: a shell that knows neither -l nor -i + // may still run a bare -c. + match capture_env(shell, &["-c"], deadline)? { + Ok(env) => run_with_env(script, &env, args, deadline), + Err(e) => Err(capture_error.unwrap_or(e)), + } +} + +/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the +/// `install.sh` clone), 127 when it would not. Same lookup, nothing started. +const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi +if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi +exit 127"#; + +/// How long the This Mac tab waits for the login shell to answer whether +/// Codeman is installed. +const FIND_TIMEOUT: Duration = Duration::from_secs(15); + +/// `installed` with the shell given, so tests need not touch `$SHELL`. +fn installed_with(shell: &Path, interactive: bool) -> bool { + matches!( + launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive), + Ok(Run { code: Some(0), .. }) + ) +} + +/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman` +/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs +/// the login shell, so never on the event loop. `interactive` also asks the +/// interactive profile (nvm in `.zshrc`, say), whose side effects only a +/// user's own click should set off. +pub fn installed(interactive: bool) -> bool { + installed_with(&login_shell(), interactive) +} + +/// `start` with the shell given, so tests need not touch `$SHELL`. +fn start_with(shell: &Path, args: &[String]) -> Result { + let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?; + match result.code { + Some(0) => Ok(result.output), + _ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()), + _ => Err(result.output), + } +} + +/// Start a detached server for `url`. Ok carries what `codeman` printed. +pub fn start(url: &Url) -> Result { + if !policy::can_start_locally(url) { + return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into()); + } + start_with(&login_shell(), &policy::start_args(url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One spawning test at a time. A test writes a fake executable and then + /// runs it; a process forked by a parallel test in between can still hold + /// the write descriptor, and the exec then fails with ETXTBSY. + fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn tail_of_output() { + assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc"); + assert_eq!(last_lines("", 3), ""); + } + + #[test] + fn refuses_non_local_or_https() { + assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err()); + assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err()); + } + + /// The script passes its arguments through untouched and reports a missing + /// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found). + #[test] + fn script_reports_missing_codeman() { + let _serial = serial(); + let out = Command::new("/bin/sh") + .arg("-c") + .arg(SCRIPT) + .arg("codeman-desktop") + .args(["web", "-d", "--port", "3000"]) + .env("PATH", "/nonexistent") + .env("HOME", "/nonexistent") + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(127)); + assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command")); + } + + /// Arguments are data: a hostile-looking value is passed as one argv entry + /// to a fake `codeman`, never run by the shell. + #[test] + fn arguments_are_not_shell_text() { + let _serial = serial(); + let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fake = dir.join("codeman"); + std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let out = Command::new("/bin/sh") + .arg("-c") + .arg(SCRIPT) + .arg("codeman-desktop") + .args(["web", "--base-url", "/x; touch /tmp/pwned"]) + .env("PATH", format!("{}:/usr/bin:/bin", dir.display())) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "[web][--base-url][/x; touch /tmp/pwned]" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> { + vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn env_capture_parser() { + let out = format!( + "Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\ + PATH=/opt/homebrew/bin:/usr/bin:/bin\n\ + MULTI=line one\nline two\n three = 3\n\ + EMPTY=\n\ + TRAIL=a \n\n\ + _=/usr/bin/env\n\ + {ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n" + ); + assert_eq!( + parse_env(&out), + Some(pairs(&[ + ("PATH", "/opt/homebrew/bin:/usr/bin:/bin"), + ("MULTI", "line one\nline two\n three = 3"), + ("EMPTY", ""), + ("TRAIL", "a \n"), + ("_", "/usr/bin/env"), + ])) + ); + // A value may itself contain `=`; only the first one splits. + assert_eq!( + parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")), + Some(pairs(&[("OPTS", "a=b")])) + ); + assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![])); + assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None); + assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None); + assert_eq!(parse_env("fish: Unknown command: then\n"), None); + } + + #[test] + fn shell_flavor_by_basename() { + for posix in [ + "/bin/zsh", + "/bin/bash", + "/bin/sh", + "/bin/dash", + "/bin/ksh", + "/opt/homebrew/bin/bash", + ] { + assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}"); + } + assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]])); + assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]])); + for other in [ + "/opt/homebrew/bin/fish", + "/usr/local/bin/nu", + "/opt/homebrew/bin/xonsh", + "/usr/local/bin/pwsh", + ] { + assert_eq!( + flavor(Path::new(other)), + Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]), + "{other}" + ); + } + } + + /// The login shell's part avoids everything only POSIX shells read. + #[test] + fn capture_command_is_shell_neutral() { + let _serial = serial(); + let cmd = capture_command(); + for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] { + assert!(!cmd.contains(bad), "{bad} in {cmd}"); + } + assert_eq!(cmd.matches('\'').count(), 2); + assert!(cmd.starts_with("/bin/sh -c '")); + // /bin/sh, as the capture runs it, prints a parsable environment. + let out = Command::new("/bin/sh") + .arg("-c") + .arg(&cmd) + .env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines") + .output() + .unwrap(); + let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers"); + assert!(env + .iter() + .any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines")); + assert!(env.iter().any(|(k, _)| k == "PATH")); + } + + /// A scratch directory with a fake `codeman` in `bin/` that prints its + /// arguments, the profile's marker variable and the names of everything in + /// its environment. + struct Scratch(PathBuf); + + impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + std::fs::create_dir_all(dir.join("home")).unwrap(); + let scratch = Scratch(dir); + scratch.exe( + "bin/codeman", + "#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\ + printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n", + ); + scratch + } + + /// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path. + fn exe(&self, rel: &str, body: &str) -> PathBuf { + let path = self.0.join(rel); + std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + fn argv_log(&self) -> String { + std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default() + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"]; + + fn hostile_args() -> Vec { + HOSTILE_ARGS.iter().map(|s| s.to_string()).collect() + } + + /// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish + /// does, logs its flags, prints profile noise around whatever it runs, and + /// its "profile" replaces the environment. On its interactive form only + /// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`. + const FAKE_FISH: &str = r#"#!/bin/sh +args="" +for a in "$@"; do + case "$a" in + *then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;; + -*) args="$args$a ";; + esac + cmd="$a" +done +echo "$args" >> "DIR/argv.log" +bin="DIR/bin" +case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac +echo "Welcome to fish, the friendly interactive shell" +/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd" +echo "bye from the profile" +"#; + + /// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the + /// environment the shell printed, and with nothing else. + #[test] + fn non_posix_shell_only_reports_its_environment() { + let _serial = serial(); + let s = Scratch::new("fish"); + let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no")); + let out = start_with(&fish, &hostile_args()).expect("started"); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]"); + assert_eq!(lines[1], "profile: from the profile"); + assert_eq!(s.argv_log(), "-l -c \n"); + + // env_clear: a variable this process has, but the profile did not + // export, must not reach codeman. + let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect(); + assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST")); + let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"]; + let leaked: Vec = std::env::vars_os() + .filter_map(|(k, _)| k.into_string().ok()) + .filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF")) + .filter(|k| names.contains(&k.as_str())) + .collect(); + assert!(leaked.is_empty(), "leaked from the app: {leaked:?}"); + } + + /// No codeman on the login PATH (exit 127): the environment is captured + /// again from the interactive profile, and that run finds it. + #[test] + fn non_posix_shell_retries_with_the_interactive_profile() { + let _serial = serial(); + let s = Scratch::new("fish-retry"); + let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes")); + let out = start_with(&fish, &hostile_args()).expect("started"); + assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}"); + assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n"); + } + + /// tcsh takes -l only as its only flag, so it is asked with -ic alone. + #[test] + fn tcsh_gets_ic_and_never_l() { + let _serial = serial(); + let s = Scratch::new("tcsh"); + let tcsh = s.exe( + "tcsh", + r#"#!/bin/sh +echo "$*" >> "DIR/argv.log" +if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi +/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2" +"#, + ); + let out = start_with(&tcsh, &hostile_args()).expect("started"); + assert!(out.contains("profile: tcsh"), "{out}"); + assert_eq!(s.argv_log().lines().count(), 1); + assert!(s.argv_log().starts_with("-ic /bin/sh -c '")); + } + + /// A shell that takes neither -l nor -i still gets a bare -c. + #[test] + fn shell_without_login_flags_falls_back_to_plain_c() { + let _serial = serial(); + let s = Scratch::new("nolflag"); + let shell = s.exe( + "elvish", + r#"#!/bin/sh +echo "$1" >> "DIR/argv.log" +case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac +/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2" +"#, + ); + let out = start_with(&shell, &hostile_args()).expect("started"); + assert!(out.contains("profile: plain"), "{out}"); + assert_eq!(s.argv_log(), "-l\n-l\n-c\n"); + } + + /// A shell that never prints its environment is reported by name with what + /// it said on stderr, never with stdout (which can carry secrets). + #[test] + fn capture_failure_names_the_shell() { + let _serial = serial(); + let s = Scratch::new("broken"); + let shell = s.exe( + "weirdsh", + "#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n", + ); + let err = start_with(&shell, &hostile_args()).unwrap_err(); + assert!(err.contains("Could not read the environment"), "{err}"); + assert!(err.contains("weirdsh: cannot start"), "{err}"); + assert!(!err.contains("sk-secret"), "{err}"); + } + + /// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop + /// args`, then `-l -i -c` when the login PATH had no codeman. + #[test] + fn posix_shell_runs_script_itself_and_retries_interactive() { + let _serial = serial(); + let s = Scratch::new("zsh"); + let zsh = s.exe( + "zsh", + r#"#!/bin/sh +[ "$1" = -l ] || exit 90 +shift +PATH=/usr/bin:/bin +if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi +[ "$1" = -c ] || exit 91 +shift +echo "$2" >> "DIR/argv.log" +HOME="DIR/home" +export PATH HOME +exec /bin/sh -c "$@" +"#, + ); + let out = start_with(&zsh, &hostile_args()).expect("started"); + assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}"); + // $0 of the script, once per run: login, then interactive. + assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n"); + } + + /// The installed check asks the same shells the same way and runs nothing: + /// found on the interactive PATH here, after the login PATH had none. + #[test] + fn installed_check_finds_codeman_without_running_it() { + let _serial = serial(); + let s = Scratch::new("find-zsh"); + let zsh = s.exe( + "zsh", + r#"#!/bin/sh +[ "$1" = -l ] || exit 90 +shift +PATH=/usr/bin:/bin +if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi +[ "$1" = -c ] || exit 91 +shift +echo "$2" >> "DIR/argv.log" +HOME="DIR/home" +export PATH HOME +exec /bin/sh -c "$@" +"#, + ); + // A codeman that would leave a mark if it ran. + s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n"); + // Without the interactive profile: one login-shell look, not found. + assert!(!installed_with(&zsh, false)); + assert_eq!(s.argv_log(), "codeman-desktop\n"); + assert!(installed_with(&zsh, true)); + assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n"); + assert!(!s.0.join("ran").exists(), "the installed check ran codeman"); + } + + /// Nothing on any PATH and no ~/.codeman/app: not installed. Through a + /// non-POSIX shell too, which only reports its environment. + #[test] + fn installed_check_reports_missing_codeman() { + let _serial = serial(); + let s = Scratch::new("find-fish"); + std::fs::remove_file(s.0.join("bin/codeman")).unwrap(); + let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no")); + assert!(!installed_with(&fish, true)); + // The login-only look asks fish once, with -l and never -i. + let before = s.argv_log(); + assert!(!installed_with(&fish, false)); + assert_eq!(&s.argv_log()[before.len()..], "-l -c \n"); + + // The install.sh clone counts once node is there. + let app = s.0.join("home/.codeman/app/dist"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(app.join("index.js"), "").unwrap(); + s.exe("bin/node", "#!/bin/sh\nexit 0\n"); + assert!(installed_with(&fish, true)); + assert!(installed_with(&fish, false)); + } +} diff --git a/packages/desktop/src/macos.rs b/packages/desktop/src/macos.rs new file mode 100644 index 00000000..5f339ab9 --- /dev/null +++ b/packages/desktop/src/macos.rs @@ -0,0 +1,1053 @@ +//! Two things a browser does that wry 0.57.1 leaves out on macOS: JavaScript +//! `alert`/`confirm`/`prompt` dialogs, and answering HTTP Basic challenges. +//! +//! wry's `WKUIDelegate` implements only the file panel, media permissions and +//! `window.open`. Without the three JavaScript panel methods WebKit treats every +//! `confirm()` as Cancel and every `prompt()` as null, and drops `alert()`. +//! Codeman guards Update, Delete case, Kill all, restoring a config backup and +//! closing an edited file preview with `confirm()`, so all of those silently did +//! nothing. wry's `WKNavigationDelegate` has no challenge method either. Codeman +//! keeps its login cookies in memory, so after a server restart every request +//! the loaded page makes gets 401, where a browser would have replayed the Basic +//! login it remembers. +//! +//! A challenge is answered only when it is Codeman's own: the protection space +//! is the server's scheme, host and port, and the URL that got the 401 (the +//! challenge's `failureResponse`) lies below the server's base path and is not +//! a proxied dashboard (`webview/`) or a rendered HTML file (`html-view/`). +//! Those relay a 401 from content Codeman did not write, which must neither +//! receive the password nor count as the server refusing it. The login is +//! handed over with `NSURLCredentialPersistenceNone`, so WebKit keeps no copy +//! of it: every challenge comes back here and gets the login of that moment, +//! never one an earlier connect left behind. +//! +//! Two smaller helpers live here too: `clear_cookies`, which empties the web +//! views' cookie jar before a login so an earlier user's session cookie cannot +//! win over the new login, and `content_size`, the window's content area. +//! +//! `install` adds the missing methods to the classes of the delegates wry made, +//! once per class and never over a method the class already has, and then sets +//! the same delegates on the web view again. WebKit asks a delegate which +//! optional methods it implements when the delegate is set and keeps the answer, +//! so a method added after that is never called. +//! +//! All of it is fail-safe. A missing delegate is logged and skipped. Every path +//! that cannot do its job answers with what WebKit does when the method is +//! missing: an alert counts as dismissed, a confirm as Cancel, a prompt as null, +//! and a challenge gets WebKit's default handling. The one deliberate exception +//! is a challenge from our server after it refused the saved login: that is +//! cancelled, because sending the same login again would cost one failed login +//! (and rate-limit budget) for every request the page retries. +//! +//! Each completion handler is called exactly once, after the work, outside the +//! code that could fail. The release build aborts on panic, so this code avoids +//! panicking at all: no unwrap, `try_with`/`try_borrow` on the thread-locals, +//! every object WebKit hands over is read as an `Option`, and page text is never +//! converted to a Rust string (an unpaired UTF-16 surrogate has no UTF-8 form). + +use url::Url; + +/// The login the challenge handler answers with: the server the user connected +/// to (its URL, base path included) and the credentials given for it. The +/// scheme, host and effective port (443 for a plain `https://` URL) a challenge +/// must come from are read from `server`. +#[derive(Clone)] +pub struct BasicCredentials { + pub server: Url, + pub username: String, + pub password: String, +} + +/// What one authentication challenge gets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChallengeAnswer { + /// Not ours (another method, a proxy, another server, no saved login): + /// WebKit's own handling, the same as before this module existed. + Default, + /// Our server asks for the first time: send the saved login. + UseCredential, + /// Our server refused the saved login: stop, and tell the app. + CancelAndReport, + /// Refused and already reported: do not send the same login again, or each + /// request the page retries would count as another failed login. + Cancel, +} + +/// The parts of a challenge `decide` looks at. +struct Challenge<'a> { + basic: bool, + proxy: bool, + scheme: &'a str, + host: &'a str, + port: i64, + previous_failures: i64, + /// The URL whose response asked for the login (the challenge's + /// `failureResponse`); `None` when WebKit gave none or it did not parse. + failed: Option<&'a Url>, +} + +/// A challenge is ours only when its protection space is the saved server and +/// the URL that got the 401 is in `policy::challenge_in_scope`. Anything else, +/// even after the server refused the login, gets WebKit's default handling, so +/// a web tab's dashboard asking for its own login can neither receive the +/// password nor send the app back to the connect screen. +fn decide(challenge: &Challenge<'_>, saved: Option<&BasicCredentials>, rejected: bool) -> ChallengeAnswer { + if !challenge.basic || challenge.proxy { + return ChallengeAnswer::Default; + } + let ours = saved.is_some_and(|saved| { + credentials_match(saved, challenge.scheme, challenge.host, challenge.port) + && challenge + .failed + .is_some_and(|failed| crate::policy::challenge_in_scope(failed, &saved.server)) + }); + if !ours { + return ChallengeAnswer::Default; + } + match (challenge.previous_failures > 0, rejected) { + (_, true) => ChallengeAnswer::Cancel, + (true, false) => ChallengeAnswer::CancelAndReport, + (false, false) => ChallengeAnswer::UseCredential, + } +} + +/// Whether a challenge's protection space is the server the login was given +/// for: same scheme, host (case-insensitive, IPv6 brackets ignored) and port +/// (the server URL's effective one). +fn credentials_match(saved: &BasicCredentials, scheme: &str, host: &str, port: i64) -> bool { + let (Some(saved_host), Some(saved_port)) = (saved.server.host_str(), saved.server.port_or_known_default()) else { + return false; + }; + let Some(port) = effective_port(scheme, port) else { + return false; + }; + !saved_host.is_empty() + && saved.server.scheme().eq_ignore_ascii_case(scheme) + && bare_host(saved_host).eq_ignore_ascii_case(bare_host(host)) + && i64::from(saved_port) == port +} + +/// A protection space states the port it was reached on; 0 is read as the +/// scheme's default in case a challenge ever leaves it out. +fn effective_port(scheme: &str, port: i64) -> Option { + if port > 0 { + Some(port) + } else if scheme.eq_ignore_ascii_case("http") { + Some(80) + } else if scheme.eq_ignore_ascii_case("https") { + Some(443) + } else { + None + } +} + +fn bare_host(host: &str) -> &str { + host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host) +} + +/// How a dialog names the page that opened it ("localhost:3000"), from a +/// security origin's host and port (0 = the scheme's default, left out). +fn origin_label(host: &str, port: i64) -> Option { + let host = host.trim(); + if host.is_empty() { + return None; + } + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + Some(if port > 0 { format!("{host}:{port}") } else { host }) +} + +/// The bold first line of a dialog, like Safari's, so a frame cannot pass its +/// text off as the app's own. `embedded` is a sandboxed frame (an HTML file +/// preview, an untrusted web tab): its origin is opaque, so `origin` is only +/// the server it was loaded from, and it must not read as Codeman speaking. +fn dialog_title(origin: &str, embedded: bool) -> String { + if embedded { + format!("An embedded page on {origin} says:") + } else { + format!("The page at {origin} says:") + } +} + +/// Makes this web view show JavaScript dialogs and answer HTTP Basic +/// challenges. Call it after every `build()`; repeating it is harmless. +#[cfg(target_os = "macos")] +pub fn install(webview: &wry::WebView) { + imp::install(webview); +} + +/// The login HTTP Basic challenges from that server are answered with +/// (`None`: answer none), from the next challenge on: WebKit keeps no copy of +/// an earlier one. Also re-arms the refusal report. Main thread only. +#[cfg(target_os = "macos")] +pub fn set_credentials(creds: Option) { + imp::set_credentials(creds); +} + +/// Called on the main thread, once per `set_credentials`, when the server +/// refused the saved login. It runs inside a WebKit callback, so it should +/// only post an event, never touch windows directly. +#[cfg(target_os = "macos")] +pub fn set_auth_rejected_handler(handler: Box) { + imp::set_auth_rejected_handler(handler); +} + +/// Removes every cookie from the app's default website data store, which all +/// of its web views use, then calls `done`. Only cookies: local storage, where +/// Codeman keeps per-device settings, stays. Main thread only. +/// +/// `done` runs exactly once, on the main thread, as its own operation on the +/// main queue: never before this returns and never inside a WebKit callback. +/// It also runs when the cookies could not be removed (fail-open), so the +/// caller always gets to load. Called off the main thread, this removes +/// nothing and calls `done` right away on the calling thread. If WebKit never +/// answers at all, `done` never runs: the caller's own deadline covers that. +#[cfg(target_os = "macos")] +pub fn clear_cookies(done: Box) { + imp::clear_cookies(done); +} + +/// The window's content area in points (`contentRectForFrameRect` of its +/// frame). Unlike the web view's bounds, it does not shrink while a docked Web +/// Inspector takes part of the window. `None` off the main thread or for a +/// size that is not a positive finite number. +#[cfg(target_os = "macos")] +pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> { + imp::content_size(window) +} + +#[cfg(target_os = "macos")] +mod imp { + use std::cell::{Cell, RefCell}; + use std::ffi::{c_char, CStr}; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::ptr; + use std::rc::Rc; + + use block2::{Block, RcBlock}; + use objc2::ffi::class_addMethod; + use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::{AnyClass, AnyObject, Bool, Imp, Sel}; + use objc2::{msg_send, sel, ClassType, Message}; + use objc2_app_kit::{NSAlert, NSAlertFirstButtonReturn, NSTextField, NSView, NSWindow}; + use objc2_foundation::{ + MainThreadMarker, NSDate, NSInteger, NSOperationQueue, NSPoint, NSRect, NSSet, NSSize, NSString, + NSURLAuthenticationChallenge, NSURLAuthenticationMethodHTTPBasic, NSURLCredential, NSURLCredentialPersistence, + NSURLProtectionSpace, NSURLRequest, NSURLResponse, NSURLSessionAuthChallengeDisposition, NSURL, + }; + use objc2_web_kit::{WKFrameInfo, WKSecurityOrigin, WKWebsiteDataStore, WKWebsiteDataTypeCookies}; + use tao::platform::macos::WindowExtMacOS; + use url::Url; + use wry::WebViewExtMacOS; + + use super::{decide, dialog_title, origin_label, BasicCredentials, Challenge, ChallengeAnswer}; + + /// Page text in a dialog is cut at this many UTF-16 units, so a huge + /// message cannot push the alert's buttons off the screen. + const MAX_DIALOG_UTF16: usize = 2000; + + thread_local! { + /// The login challenges are answered with; `None` answers none. + static SAVED: RefCell> = const { RefCell::new(None) }; + /// The server refused `SAVED` and the app was told; `set_credentials` clears it. + static REJECTED: Cell = const { Cell::new(false) }; + /// Told once when the server refuses `SAVED`. + static ON_REJECTED: RefCell>> = const { RefCell::new(None) }; + } + + // The added methods, with every object argument as a raw pointer: WebKit + // passes nonnull objects, but a null one must not become an invalid `&`. + type AlertFn = extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut NSString, + *mut WKFrameInfo, + *mut Block, + ); + type ConfirmFn = extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut NSString, + *mut WKFrameInfo, + *mut Block, + ); + type PromptFn = extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut NSString, + *mut NSString, + *mut WKFrameInfo, + *mut Block, + ); + type ChallengeHandler = Block; + type ChallengeFn = extern "C-unwind" fn( + *mut AnyObject, + Sel, + *mut AnyObject, + *mut NSURLAuthenticationChallenge, + *mut ChallengeHandler, + ); + + pub(super) fn install(webview: &wry::WebView) { + if MainThreadMarker::new().is_none() { + eprintln!("codeman-desktop: macos::install called off the main thread; skipped"); + return; + } + let wk = WebViewExtMacOS::webview(webview); + + // SAFETY: reading and setting the delegates of a live web view on the + // main thread. `delegate` keeps the object alive across the swap (the + // web view holds its delegates weakly; wry holds them strongly too). + match unsafe { wk.UIDelegate() } { + Some(delegate) => { + let class = AsRef::::as_ref(&*delegate).class(); + // SAFETY (all three transmutes): fn pointers of the same ABI; + // each signature matches its selector and type encoding. + add_method( + class, + sel!(webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:), + unsafe { std::mem::transmute::(run_alert) }, + c"v@:@@@@?", + ); + add_method( + class, + sel!(webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:), + unsafe { std::mem::transmute::(run_confirm) }, + c"v@:@@@@?", + ); + add_method( + class, + sel!(webView:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:), + unsafe { std::mem::transmute::(run_prompt) }, + c"v@:@@@@@?", + ); + unsafe { + wk.setUIDelegate(None); + wk.setUIDelegate(Some(&*delegate)); + } + } + None => eprintln!("codeman-desktop: web view has no UI delegate; JavaScript dialogs stay off"), + } + + // SAFETY: as above. + match unsafe { wk.navigationDelegate() } { + Some(delegate) => { + let class = AsRef::::as_ref(&*delegate).class(); + add_method( + class, + sel!(webView:didReceiveAuthenticationChallenge:completionHandler:), + // SAFETY: as above. + unsafe { std::mem::transmute::(did_receive_challenge) }, + c"v@:@@@?", + ); + unsafe { + wk.setNavigationDelegate(None); + wk.setNavigationDelegate(Some(&*delegate)); + } + } + None => eprintln!("codeman-desktop: web view has no navigation delegate; Basic logins are not answered"), + } + } + + pub(super) fn set_credentials(creds: Option) { + if MainThreadMarker::new().is_none() { + eprintln!("codeman-desktop: macos::set_credentials called off the main thread; ignored"); + return; + } + let stored = SAVED.try_with(|saved| saved.try_borrow_mut().map(|mut saved| *saved = creds).is_ok()); + if stored != Ok(true) { + eprintln!("codeman-desktop: could not store the login for Basic challenges"); + } + let _ = REJECTED.try_with(|rejected| rejected.set(false)); + } + + pub(super) fn set_auth_rejected_handler(handler: Box) { + if MainThreadMarker::new().is_none() { + eprintln!("codeman-desktop: macos::set_auth_rejected_handler called off the main thread; ignored"); + return; + } + let handler: Rc = Rc::from(handler); + let stored = ON_REJECTED.try_with(|slot| slot.try_borrow_mut().map(|mut slot| *slot = Some(handler)).is_ok()); + if stored != Ok(true) { + eprintln!("codeman-desktop: could not store the login-refused handler"); + } + } + + pub(super) fn clear_cookies(done: Box) { + if MainThreadMarker::new().is_none() { + eprintln!("codeman-desktop: macos::clear_cookies called off the main thread; cookies kept"); + // `done` was made on this thread, so it runs here instead of being + // sent to another one. + run_done(done); + return; + } + // Every way out of here, a panic included, either hands `pending` to + // WebKit's completion handler or drops it, and both pass `done` on. + let pending = Pending(Cell::new(Some(done))); + let _ = catch_unwind(AssertUnwindSafe(|| autoreleasepool(|_| remove_cookies(pending)))); + } + + fn remove_cookies(pending: Pending) { + // SAFETY: class methods called on the main thread (checked by the + // caller), with nil handled by the `Option` return types. + let store: Option> = + unsafe { msg_send![WKWebsiteDataStore::class(), defaultDataStore] }; + let since: Option> = unsafe { msg_send![NSDate::class(), distantPast] }; + let (Some(store), Some(since)) = (store, since) else { + eprintln!("codeman-desktop: no website data store; cookies kept"); + return; + }; + // SAFETY: an immutable string constant exported by WebKit. + let types = NSSet::from_slice(&[unsafe { WKWebsiteDataTypeCookies }]); + let completion = RcBlock::new(move || pending.finish()); + // SAFETY: main thread; WebKit copies the block, keeps it until the + // cookies are gone and then calls it. Dropping our reference after the + // call is fine. + unsafe { store.removeDataOfTypes_modifiedSince_completionHandler(&types, &since, &completion) }; + } + + /// `done` until it is passed on: by the completion handler, or, when + /// WebKit releases the handler without calling it or a step before it + /// fails, by the drop. `Cell::take` makes that exactly once. + struct Pending(Cell>>); + + impl Pending { + fn finish(&self) { + if let Some(done) = self.0.take() { + run_on_main_queue(done); + } + } + } + + impl Drop for Pending { + fn drop(&mut self) { + self.finish(); + } + } + + /// Runs `done` as its own operation on the main queue, so on the main + /// thread whatever thread WebKit calls back on, and never inside + /// `clear_cookies` or a WebKit callback. + fn run_on_main_queue(done: Box) { + let once = Cell::new(Some(done)); + let block = RcBlock::new(move || { + if let Some(done) = once.take() { + run_done(done); + } + }); + // SAFETY: the main queue runs the block on the main thread, the thread + // `done` was made on (`clear_cookies` checked), so `done` is never + // called anywhere else. + unsafe { NSOperationQueue::mainQueue().addOperationWithBlock(&block) }; + } + + fn run_done(done: Box) { + if catch_unwind(AssertUnwindSafe(done)).is_err() { + eprintln!("codeman-desktop: the cookies-cleared callback panicked"); + } + } + + pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> { + // NSWindow is main-thread only. + MainThreadMarker::new()?; + // SAFETY: tao hands out a pointer to its live NSWindow (objc2-app-kit + // 0.3.2, the crate used here), null-checked by `as_ref`; the reference + // ends with this call, while `window` is still borrowed. + let ns_window = unsafe { window.ns_window().cast::().as_ref() }?; + let size = ns_window.contentRectForFrameRect(ns_window.frame()).size; + let usable = |v: f64| v.is_finite() && v > 0.0; + (usable(size.width) && usable(size.height)).then_some((size.width, size.height)) + } + + /// Adds `sel` to `class` unless an instance already responds to it (ours + /// from an earlier `install`, or a later wry that implements it itself). + fn add_method(class: &AnyClass, sel: Sel, imp: Imp, types: &CStr) { + if class.responds_to(sel) { + return; + } + // SAFETY: `imp` takes exactly the arguments `types` describes, and the + // class does not have the method yet, so nothing is replaced. + let added = unsafe { class_addMethod(ptr::from_ref(class).cast_mut(), sel, imp, types.as_ptr()) }; + if !added.as_bool() { + eprintln!( + "codeman-desktop: could not add {} to {}", + sel.name().to_string_lossy(), + class.name().to_string_lossy() + ); + } + } + + extern "C-unwind" fn run_alert( + _this: *mut AnyObject, + _cmd: Sel, + _web_view: *mut AnyObject, + message: *mut NSString, + frame: *mut WKFrameInfo, + handler: *mut Block, + ) { + // SAFETY: WebKit's block and objects live for the duration of this call. + let Some(handler) = (unsafe { handler.as_ref() }) else { + return; + }; + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + show_panel(Panel::Alert, message.as_ref(), None, frame.as_ref()) + })); + handler.call(()); + } + + extern "C-unwind" fn run_confirm( + _this: *mut AnyObject, + _cmd: Sel, + _web_view: *mut AnyObject, + message: *mut NSString, + frame: *mut WKFrameInfo, + handler: *mut Block, + ) { + // SAFETY: as in `run_alert`. + let Some(handler) = (unsafe { handler.as_ref() }) else { + return; + }; + let closed = catch_unwind(AssertUnwindSafe(|| unsafe { + show_panel(Panel::Confirm, message.as_ref(), None, frame.as_ref()) + })); + let ok = matches!(closed, Ok(Some(Closed { ok: true, .. }))); + handler.call((Bool::new(ok),)); + } + + extern "C-unwind" fn run_prompt( + _this: *mut AnyObject, + _cmd: Sel, + _web_view: *mut AnyObject, + prompt: *mut NSString, + default_text: *mut NSString, + frame: *mut WKFrameInfo, + handler: *mut Block, + ) { + // SAFETY: as in `run_alert`. + let Some(handler) = (unsafe { handler.as_ref() }) else { + return; + }; + let closed = catch_unwind(AssertUnwindSafe(|| unsafe { + show_panel(Panel::Prompt, prompt.as_ref(), default_text.as_ref(), frame.as_ref()) + })); + let text = match closed { + Ok(Some(Closed { ok: true, text })) => text, + _ => None, + }; + // `text` outlives the call, so the pointer stays valid while WebKit copies it. + handler.call((text + .as_ref() + .map_or(ptr::null_mut(), |t| Retained::as_ptr(t).cast_mut()),)); + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum Panel { + Alert, + Confirm, + Prompt, + } + + /// How the user closed a dialog; `text` is the prompt field's content. + struct Closed { + ok: bool, + text: Option>, + } + + /// Shows one JavaScript dialog as an app-modal NSAlert. `None` when it + /// could not be shown. Synchronous on purpose: the completion handler is + /// called right after, so it cannot be lost with a closing window. + fn show_panel( + panel: Panel, + message: Option<&NSString>, + default_text: Option<&NSString>, + frame: Option<&WKFrameInfo>, + ) -> Option { + let mtm = MainThreadMarker::new()?; + let alert = NSAlert::new(mtm); + let body = match message { + Some(message) => clipped(message), + None => NSString::from_str(""), + }; + match frame.and_then(frame_origin) { + Some((origin, embedded)) => { + alert.setMessageText(&NSString::from_str(&dialog_title(&origin, embedded))); + alert.setInformativeText(&body); + } + None => alert.setMessageText(&body), + } + let _ = alert.addButtonWithTitle(&NSString::from_str("OK")); + if panel != Panel::Alert { + let _ = alert.addButtonWithTitle(&NSString::from_str("Cancel")); + } + let field = (panel == Panel::Prompt).then(|| { + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(300.0, 24.0)); + let field = NSTextField::initWithFrame(mtm.alloc::(), frame); + if let Some(text) = default_text { + field.setStringValue(text); + } + field + }); + if let Some(field) = &field { + let view: &NSView = field; + alert.setAccessoryView(Some(view)); + // Lay out now so the field can take the focus instead of the OK button. + alert.layout(); + // SAFETY: a plain property read; nil is handled. + let window: Option> = unsafe { msg_send![&*alert, window] }; + if let Some(window) = window { + window.setInitialFirstResponder(Some(view)); + } + } + let ok = alert.runModal() == NSAlertFirstButtonReturn; + Some(Closed { + ok, + text: field.map(|field| field.stringValue()), + }) + } + + /// The page's text, cut at a whole character after `MAX_DIALOG_UTF16` + /// units. Stays an NSString, so it is shown exactly as the page sent it. + fn clipped(text: &NSString) -> Retained { + if text.length() <= MAX_DIALOG_UTF16 { + return text.retain(); + } + let cut = text.rangeOfComposedCharacterSequenceAtIndex(MAX_DIALOG_UTF16).location; + text.substringToIndex(cut) + .stringByAppendingString(&NSString::from_str("…")) + } + + /// The host (and port) of the frame that opened a dialog: its security + /// origin, else its request URL's host. The flag is set when the origin is + /// opaque (a sandboxed frame), where only the URL names a host. + fn frame_origin(frame: &WKFrameInfo) -> Option<(String, bool)> { + autoreleasepool(|_| { + // SAFETY (every read here): property reads on objects WebKit handed + // us, with nil handled by the `Option` return types. + let origin: Option> = unsafe { msg_send![frame, securityOrigin] }; + // An origin without a host is opaque; when unsure, say "embedded" + // rather than let a frame speak as the page. + let opaque = origin.is_some(); + if let Some(origin) = origin { + let host: Option> = unsafe { msg_send![&*origin, host] }; + let port: NSInteger = unsafe { msg_send![&*origin, port] }; + if let Some(label) = host + .as_deref() + .and_then(rust_string) + .and_then(|h| origin_label(&h, port as i64)) + { + return Some((label, false)); + } + } + let request: Option> = unsafe { msg_send![frame, request] }; + let request = request?; + let url: Option> = unsafe { msg_send![&*request, URL] }; + let url = url?; + let host: Option> = unsafe { msg_send![&*url, host] }; + let host = host?; + Some((origin_label(&rust_string(&host)?, 0)?, opaque)) + }) + } + + /// Copies an NSString into Rust, or `None` when it has no UTF-8 form: + /// `UTF8String` returns NULL for an unpaired surrogate, and objc2's own + /// conversion would then build a slice from a null pointer. Call it inside + /// an autorelease pool (the bytes may be autoreleased). + fn rust_string(text: &NSString) -> Option { + // SAFETY: `UTF8String` returns NULL or a NUL-terminated buffer that + // lives until the pool drains; it is copied before that. + let bytes: *const c_char = unsafe { msg_send![text, UTF8String] }; + if bytes.is_null() { + return None; + } + unsafe { CStr::from_ptr(bytes) }.to_str().ok().map(str::to_owned) + } + + extern "C-unwind" fn did_receive_challenge( + _this: *mut AnyObject, + _cmd: Sel, + _web_view: *mut AnyObject, + challenge: *mut NSURLAuthenticationChallenge, + handler: *mut ChallengeHandler, + ) { + // SAFETY: as in `run_alert`. + let Some(handler) = (unsafe { handler.as_ref() }) else { + return; + }; + let reply = catch_unwind(AssertUnwindSafe(|| reply_to(unsafe { challenge.as_ref() }))).unwrap_or_default(); + let credential = reply + .credential + .as_ref() + .map_or(ptr::null_mut(), |c| Retained::as_ptr(c).cast_mut()); + handler.call((reply.disposition, credential)); + // Told after WebKit has its answer, so the app's handler cannot delay it. + if reply.report { + notify_rejected(); + } + } + + struct Reply { + disposition: NSURLSessionAuthChallengeDisposition, + credential: Option>, + report: bool, + } + + impl Default for Reply { + fn default() -> Self { + Reply { + disposition: NSURLSessionAuthChallengeDisposition::PerformDefaultHandling, + credential: None, + report: false, + } + } + } + + fn reply_to(challenge: Option<&NSURLAuthenticationChallenge>) -> Reply { + let Some(challenge) = challenge else { + return Reply::default(); + }; + autoreleasepool(|_| { + // SAFETY (every read here): property reads on the challenge WebKit + // handed us, with nil handled by the `Option` return types. + let space: Option> = unsafe { msg_send![challenge, protectionSpace] }; + let Some(space) = space else { + return Reply::default(); + }; + let method: Option> = unsafe { msg_send![&*space, authenticationMethod] }; + // SAFETY: an immutable string constant exported by Foundation. + let basic = + method.is_some_and(|method| method.isEqualToString(unsafe { NSURLAuthenticationMethodHTTPBasic })); + if !basic { + // Server trust above all: the certificate decision stays WebKit's. + return Reply::default(); + } + let proxy: bool = unsafe { msg_send![&*space, isProxy] }; + let host: Option> = unsafe { msg_send![&*space, host] }; + let scheme: Option> = unsafe { msg_send![&*space, protocol] }; + let port: NSInteger = unsafe { msg_send![&*space, port] }; + let previous_failures: NSInteger = unsafe { msg_send![challenge, previousFailureCount] }; + let (Some(host), Some(scheme)) = ( + host.as_deref().and_then(rust_string), + scheme.as_deref().and_then(rust_string), + ) else { + return Reply::default(); + }; + + let failed = failure_url(challenge); + + let saved = SAVED + .try_with(|saved| saved.try_borrow().ok().and_then(|saved| saved.clone())) + .ok() + .flatten(); + let rejected = REJECTED.try_with(Cell::get).unwrap_or(false); + let asked = Challenge { + basic, + proxy, + scheme: &scheme, + host: &host, + port: port as i64, + previous_failures: previous_failures as i64, + failed: failed.as_ref(), + }; + match (decide(&asked, saved.as_ref(), rejected), saved) { + (ChallengeAnswer::UseCredential, Some(saved)) => { + let user = NSString::from_str(&saved.username); + let password = NSString::from_str(&saved.password); + // None: WebKit uses it for this request only and stores + // nothing, so the next challenge comes back here and gets + // the login current then. With ForSession WebKit kept it, + // sent it on its own to later requests below that URL, and + // went on doing so after Switch Server or another login. + // SAFETY: a class factory method with nil handled. + let credential: Option> = unsafe { + msg_send![ + NSURLCredential::class(), + credentialWithUser: &*user, + password: &*password, + persistence: NSURLCredentialPersistence::None + ] + }; + match credential { + Some(credential) => Reply { + disposition: NSURLSessionAuthChallengeDisposition::UseCredential, + credential: Some(credential), + report: false, + }, + None => Reply::default(), + } + } + (ChallengeAnswer::CancelAndReport, _) => { + let _ = REJECTED.try_with(|rejected| rejected.set(true)); + eprintln!("codeman-desktop: {host}:{port} refused the saved login; not sending it again"); + Reply { + disposition: NSURLSessionAuthChallengeDisposition::CancelAuthenticationChallenge, + credential: None, + report: true, + } + } + (ChallengeAnswer::Cancel, _) => Reply { + disposition: NSURLSessionAuthChallengeDisposition::CancelAuthenticationChallenge, + credential: None, + report: false, + }, + (ChallengeAnswer::Default | ChallengeAnswer::UseCredential, _) => Reply::default(), + } + }) + } + + /// The URL whose 401 raised the challenge, from its `failureResponse`. + /// `None` when WebKit gave no response or URL, or it does not parse. Call + /// it inside an autorelease pool (`rust_string`). + fn failure_url(challenge: &NSURLAuthenticationChallenge) -> Option { + // SAFETY (every read here): property reads on objects WebKit handed + // us, with nil handled by the `Option` return types. + let response: Option> = unsafe { msg_send![challenge, failureResponse] }; + let url: Option> = unsafe { msg_send![&*response?, URL] }; + let text: Option> = unsafe { msg_send![&*url?, absoluteString] }; + Url::parse(&rust_string(&*text?)?).ok() + } + + fn notify_rejected() { + // Cloned out first: the handler may call `set_credentials` or + // `set_auth_rejected_handler`, which would find the cell borrowed. + let handler = ON_REJECTED + .try_with(|slot| slot.try_borrow().ok().and_then(|slot| slot.clone())) + .ok() + .flatten(); + if let Some(handler) = handler { + if catch_unwind(AssertUnwindSafe(|| handler())).is_err() { + eprintln!("codeman-desktop: the login-refused handler panicked"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn url(text: &str) -> Url { + Url::parse(text).expect("test URL") + } + + fn saved(server: &str) -> BasicCredentials { + BasicCredentials { + server: url(server), + username: "admin".into(), + password: "pw".into(), + } + } + + fn challenge<'a>( + scheme: &'a str, + host: &'a str, + port: i64, + previous_failures: i64, + failed: Option<&'a Url>, + ) -> Challenge<'a> { + Challenge { + basic: true, + proxy: false, + scheme, + host, + port, + previous_failures, + failed, + } + } + + /// What a run of challenges gets, with the refusal latched the way + /// `reply_to` latches it (on CancelAndReport only). + fn answers(login: &BasicCredentials, asked: &[Challenge<'_>]) -> Vec { + let mut rejected = false; + asked + .iter() + .map(|c| { + let answer = decide(c, Some(login), rejected); + rejected |= answer == ChallengeAnswer::CancelAndReport; + answer + }) + .collect() + } + + #[test] + fn first_basic_challenge_from_our_server_uses_the_login() { + let login = saved("https://codeman.example/"); + let failed = url("https://codeman.example/api/sessions"); + let c = challenge("https", "Codeman.Example", 443, 0, Some(&failed)); + assert_eq!(decide(&c, Some(&login), false), ChallengeAnswer::UseCredential); + } + + #[test] + fn a_refused_login_is_reported_once_then_never_resent() { + let login = saved("http://localhost:3000/"); + let failed = url("http://localhost:3000/api/status"); + let asked = [ + challenge("http", "localhost", 3000, 0, Some(&failed)), + challenge("http", "localhost", 3000, 1, Some(&failed)), + challenge("http", "localhost", 3000, 1, Some(&failed)), + challenge("http", "localhost", 3000, 0, Some(&failed)), + ]; + assert_eq!( + answers(&login, &asked), + [ + ChallengeAnswer::UseCredential, + ChallengeAnswer::CancelAndReport, + ChallengeAnswer::Cancel, + ChallengeAnswer::Cancel, + ] + ); + } + + #[test] + fn a_web_tab_or_rendered_html_asking_for_a_login_is_never_ours() { + let login = saved("http://localhost:3000/"); + let ours = url("http://localhost:3000/api/status"); + for target in [ + "http://localhost:3000/webview/cap/", + "http://localhost:3000/webview/cap/api/v1/query", + "http://localhost:3000/html-view/cap/report.html", + "http://localhost:3000/%77ebview/cap/", + ] { + let failed = url(target); + // The dashboard answers 401 twice, as it would after a real + // UseCredential: both get WebKit's own handling and nothing is + // latched, so the next challenge from Codeman still gets the login. + let asked = [ + challenge("http", "localhost", 3000, 0, Some(&failed)), + challenge("http", "localhost", 3000, 1, Some(&failed)), + challenge("http", "localhost", 3000, 0, Some(&ours)), + ]; + assert_eq!( + answers(&login, &asked), + [ + ChallengeAnswer::Default, + ChallengeAnswer::Default, + ChallengeAnswer::UseCredential, + ], + "{target}" + ); + // Not even cancelled once the server has refused the login. + assert_eq!( + decide(&asked[1], Some(&login), true), + ChallengeAnswer::Default, + "{target}" + ); + } + } + + #[test] + fn only_urls_below_the_base_path_are_ours() { + let login = saved("http://h:3000/codeman/"); + let inside = url("http://h:3000/codeman/api/status"); + let sibling = url("http://h:3000/grafana/"); + let prefix = url("http://h:3000/codemanx/"); + let root = url("http://h:3000/"); + assert_eq!( + decide(&challenge("http", "h", 3000, 0, Some(&inside)), Some(&login), false), + ChallengeAnswer::UseCredential + ); + for failed in [&sibling, &prefix, &root] { + assert_eq!( + decide(&challenge("http", "h", 3000, 1, Some(failed)), Some(&login), false), + ChallengeAnswer::Default, + "{failed}" + ); + } + } + + #[test] + fn a_challenge_without_a_failure_url_is_not_ours() { + let login = saved("http://localhost:3000/"); + assert_eq!( + decide(&challenge("http", "localhost", 3000, 0, None), Some(&login), false), + ChallengeAnswer::Default + ); + assert_eq!( + decide(&challenge("http", "localhost", 3000, 1, None), Some(&login), true), + ChallengeAnswer::Default + ); + } + + #[test] + fn anything_else_gets_default_handling() { + let login = saved("http://localhost:3000/"); + let failed = url("http://localhost:3000/api/status"); + let ok = Some(&failed); + assert_eq!( + decide(&challenge("http", "localhost", 3000, 0, ok), None, false), + ChallengeAnswer::Default + ); + assert_eq!( + decide(&challenge("https", "localhost", 3000, 0, ok), Some(&login), false), + ChallengeAnswer::Default + ); + assert_eq!( + decide(&challenge("http", "localhost", 3001, 0, ok), Some(&login), false), + ChallengeAnswer::Default + ); + assert_eq!( + decide(&challenge("http", "127.0.0.1", 3000, 0, ok), Some(&login), false), + ChallengeAnswer::Default + ); + // A failure URL on another server, under a matching protection space. + let elsewhere = url("http://localhost:3001/api/status"); + assert_eq!( + decide( + &challenge("http", "localhost", 3000, 0, Some(&elsewhere)), + Some(&login), + false + ), + ChallengeAnswer::Default + ); + let not_basic = Challenge { + basic: false, + ..challenge("http", "localhost", 3000, 0, ok) + }; + assert_eq!(decide(¬_basic, Some(&login), false), ChallengeAnswer::Default); + let proxy = Challenge { + proxy: true, + ..challenge("http", "localhost", 3000, 0, ok) + }; + assert_eq!(decide(&proxy, Some(&login), false), ChallengeAnswer::Default); + // Another server is never cancelled, even after ours refused the login. + let other = url("http://other:3000/api/status"); + assert_eq!( + decide(&challenge("http", "other", 3000, 1, Some(&other)), Some(&login), true), + ChallengeAnswer::Default + ); + } + + #[test] + fn ports_and_hosts_compare_like_urls() { + assert!(credentials_match(&saved("https://h/"), "https", "h", 0)); + assert!(credentials_match(&saved("https://h/"), "https", "h", 443)); + assert!(credentials_match(&saved("http://h/"), "HTTP", "H", 80)); + assert!(credentials_match(&saved("http://[::1]:3000/"), "http", "::1", 3000)); + assert!(credentials_match(&saved("http://[::1]:3000/"), "http", "[::1]", 3000)); + assert!(!credentials_match(&saved("http://h/"), "ftp", "h", 0)); + assert!(!credentials_match(&saved("http://h:8080/"), "http", "h", 80)); + assert!(!credentials_match(&saved("mailto:admin@h"), "http", "h", 80)); + } + + #[test] + fn dialogs_name_the_page() { + assert_eq!(origin_label("localhost", 3000).as_deref(), Some("localhost:3000")); + assert_eq!(origin_label("example.com", 0).as_deref(), Some("example.com")); + assert_eq!(origin_label("::1", 3000).as_deref(), Some("[::1]:3000")); + assert_eq!(origin_label(" ", 3000), None); + assert_eq!( + dialog_title("localhost:3000", false), + "The page at localhost:3000 says:" + ); + assert_eq!( + dialog_title("localhost:3000", true), + "An embedded page on localhost:3000 says:" + ); + } +} diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs new file mode 100644 index 00000000..0025f47a --- /dev/null +++ b/packages/desktop/src/main.rs @@ -0,0 +1,1845 @@ +//! Codeman Desktop: a native window around a Codeman server. +//! +//! Codeman is a web app, so the app is a thin shell: the OS web view (WKWebView +//! on macOS) shows the server's own page, and the native side adds what a +//! browser tab cannot: a connect screen that remembers servers, runs or +//! installs Codeman on this machine (`setup`, `local_server`) and installs it +//! on another one over SSH, real full screen, native menus, pop-outs as app +//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads, +//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP +//! Basic challenges wry leaves out (`macos`). +//! +//! Every URL and message decision is in `policy` (pure, unit-tested); this file +//! only wires those decisions to windows. The main window moves through +//! `policy::Phase`: the connect page (Connect), a server load that has not +//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel), and a +//! committed server page (Loaded). The phase is shared with the web view +//! handlers, which run on this same thread; no `RefCell` borrow of it is ever +//! held across a call into wry or tao. + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod assets; +mod config; +mod credentials; +mod local_server; +#[cfg(target_os = "macos")] +mod macos; +mod policy; +mod probe; +mod setup; + +use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::process::Command; +use std::rc::Rc; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{AboutMetadata, CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use serde_json::json; +use tao::dpi::{LogicalPosition, LogicalSize}; +use tao::event::{Event, StartCause, WindowEvent}; +use tao::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget}; +use tao::window::{Fullscreen, Theme, UserAttentionType, Window, WindowBuilder, WindowId}; +use url::Url; +use wry::http::header::AUTHORIZATION; +use wry::http::{HeaderMap, HeaderValue}; +use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebViewBuilder}; + +use config::{Config, Rect, WindowFrame}; +use credentials::Keychain; +use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Via}; +use probe::ProbeOutcome; + +const APP_NAME: &str = "Codeman"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0); +const POPOUT_SIZE: (f64, f64) = (960.0, 680.0); +const MIN_SIZE: (f64, f64) = (480.0, 360.0); +const MAX_POPOUTS: usize = 24; +const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); +/// How long a server load may take to commit before the app gives up on it +/// and brings the connect page back. +const LOAD_TIMEOUT: Duration = Duration::from_secs(15); +/// A window reloads itself with the login at most once in this long after its +/// page reported that its session is gone, so a login the server keeps +/// refusing can never turn into a reload loop. +const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60); +/// When the app first looks whether the main window made it into the full +/// screen it asked for at launch, how often it asks again, and how many times. +/// tao retries a failed entry only for a window BUILT in full screen, which +/// this one is not (create_main_window), and entering fails while macOS is +/// still switching away from another app's full-screen Space. +const FULLSCREEN_FIRST_CHECK: Duration = Duration::from_millis(700); +const FULLSCREEN_RETRY_EVERY: Duration = Duration::from_millis(500); +const FULLSCREEN_ATTEMPTS: u8 = 6; + +const SETTINGS_UNREADABLE: &str = "Settings could not be read, so changes will not be saved this time."; +const LOAD_TIMED_OUT: &str = "Codeman did not open. Check that the server is still running, then connect again."; +const LOAD_CANCELLED: &str = "Opening was cancelled."; +const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter the password again."; +/// A recheck that got 429. The recheck always sends the login, and a +/// single-user Codeman accepts a matching login before its rate limit, so this +/// almost always means the login is wrong; a multi-user Codeman can also refuse +/// a correct one from a busy address, so the text does not say which. +const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \ +enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes."; +const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again."; +const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; + +mod menu_id { + pub const SWITCH: &str = "switch-server"; + pub const RELOAD: &str = "reload"; + pub const START_FULLSCREEN: &str = "start-fullscreen"; + pub const DEVTOOLS: &str = "devtools"; + pub const HELP_DOCS: &str = "help-docs"; + pub const HELP_GITHUB: &str = "help-github"; +} + +enum UserEvent { + Menu(MenuEvent), + Ipc { + window: WindowId, + msg: Ipc, + }, + ProbeDone(Box), + ServerStartDone { + generation: u64, + url: Url, + result: Result, + }, + /// A `window.open` or `target=_blank` asked for one of the file or + /// download pages Codeman opens that way (policy::new_window, Via::Script). + /// Any frame can ask like this, so the window opens without taking the + /// keyboard, only while the script window budget allows, and nothing is + /// reported back when it does not open. + OpenAppWindow(Url), + /// A page opened another site (window.open); it goes to the browser if the + /// open budget allows. + OpenExternal(Url), + /// The cookies were cleared before a server load (begin_loading). + CookiesCleared { + generation: u64, + url: Url, + }, + /// What the server said to the login the challenge handler had sent when + /// it reported a refusal (recheck_login). + LoginChecked { + generation: u64, + outcome: ProbeOutcome, + }, + Title { + window: WindowId, + title: String, + }, + /// A main-frame navigation committed (wry's PageLoadEvent::Started, from + /// WebKit's didCommitNavigation): `url` is the window's document now. + PageCommitted { + window: WindowId, + url: String, + }, + DownloadStarted { + window: WindowId, + url: String, + /// The file name wry chose in ~/Downloads (de-duplicated). wry passes + /// no path when the download ends, so this is the toast's only source. + file_name: Option, + }, + DownloadDone { + window: WindowId, + url: String, + path: Option, + ok: bool, + }, + /// The challenge handler sent its login and got challenged again (macOS). + /// Not trusted on its own: recheck_login asks the server first. + AuthRejected, + /// A background step for the connect page finished (This Mac status, an + /// install opened in Terminal, the install command copied): the message + /// goes to the page if it is still the one choosing a server. + ToConnectPage(serde_json::Value), +} + +struct ProbeDone { + generation: u64, + url: Url, + result: ProbeResult, + /// The Authorization header the probe used (None: no credentials). + auth: Option, + /// The username behind `auth`. + username: Option, + /// The password behind `auth`: typed, from the Keychain, or the local + /// Codeman's own login. + password: Option, + /// Whether the Keychain holds `password` for `username` now, after the + /// worker's own save or delete (update_keychain). + saved: bool, + /// The usernames whose Keychain items for this server the worker deleted. + deleted: Vec, +} + +enum ProbeResult { + Probed(ProbeOutcome), + /// The config says a password is saved for this user, but the Keychain + /// gave none (access denied, or the item is gone). No probe was sent: an + /// unauthenticated one would only come back as "needs login". + SavedPasswordUnreadable, +} + +enum Kind { + Main, + Popout { + /// The URL the pop-out was opened with, as handed to wry + /// (`policy::webkit_safe_url`), which is also the form WebKit reports + /// for a download. + opened_url: String, + hidden_for_download: bool, + }, +} + +struct AppWindow { + // Declared first so it is dropped before its window. + webview: WebView, + window: Window, + kind: Kind, + /// The window's last committed URL. Never read from wry's `url()`: that + /// unwraps WKWebView.URL, which is nil until a load commits, and the app + /// would abort. + committed: Option, + /// When the window last reloaded itself because its session expired. + session_reload_at: Option, +} + +struct AppMenu { + bar: Menu, + window_menu: Submenu, + help_menu: Submenu, + start_fullscreen: CheckMenuItem, +} + +enum Load<'a> { + ConnectPage { manual: bool }, + Server { url: &'a str, auth: Option<&'a str> }, +} + +struct App { + proxy: EventLoopProxy, + config: Config, + /// None: no config directory, or the file could not be read or moved + /// aside, in which case it must not be overwritten. + config_path: Option, + config_dirty: bool, + last_save: Instant, + /// Where the main window is in reaching a server. Shared with the web view + /// handlers, which run on this same thread. + phase: Rc>, + /// While Loading: when to give up on the load. + loading_deadline: Option, + /// Basic-auth header for (re)loading server pages; the server answers a + /// load with its session cookie, which carries the requests after it. + auth: Option, + /// The username and password behind `auth`, which the challenge handler + /// answers with (set_login). + login: Option<(String, String)>, + /// Counts the logins handed to the challenge handler, so a recheck that + /// comes back after Switch Server or another connect does nothing. + login_generation: u64, + /// The login generation a recheck is running for, if any. + login_recheck: Option, + windows: HashMap, + main_id: WindowId, + probe_generation: u64, + start_generation: u64, + /// The generation of the Start Codeman run still going, if any. + starting_server: Option, + /// Shown by the next connect page that asks for its init. + notice: Option, + /// What hands pages to the default browser draws on. + open_budget: OpenBudget, + /// What the app windows a script opens draw on (UserEvent::OpenAppWindow), + /// apart from `open_budget`, so a frame cannot use up the user's links. + script_window_budget: OpenBudget, + /// File names of downloads under way, per window and URL, oldest first. + downloads: HashMap<(WindowId, String), VecDeque>, + menu: AppMenu, + /// The one Keychain worker; every Keychain job goes through its queue. + keychain: Keychain, + /// While the main window may still have to be put into the full screen it + /// asked for at launch: when to look next, and how many retries were made. + fullscreen_retry: Option<(Instant, u8)>, +} + +fn main() { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let proxy = event_loop.create_proxy(); + + { + let menu_proxy = Mutex::new(proxy.clone()); + MenuEvent::set_event_handler(Some(move |event| { + if let Ok(p) = menu_proxy.lock() { + let _ = p.send_event(UserEvent::Menu(event)); + } + })); + } + + #[cfg(target_os = "macos")] + { + // Runs inside a WebKit callback: only post an event. + let auth_proxy = proxy.clone(); + macos::set_auth_rejected_handler(Box::new(move || { + let _ = auth_proxy.send_event(UserEvent::AuthRejected); + })); + } + + let config_path = Config::default_path(); + let (config, can_save) = match config_path.as_deref() { + Some(path) => Config::load_from(path), + None => (Config::default(), true), + }; + let config_path = config_path.filter(|_| can_save); + let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string()); + let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}"))); + let phase = Rc::new(RefCell::new(Phase::Connect)); + // Started once: the probe workers and the event loop share this queue, + // and a second one would not keep its jobs in order with the first. + let keychain = Keychain::spawn(); + + let (window, wants_fullscreen) = + create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}"))); + let webview = build_webview(&window, true, Load::ConnectPage { manual: false }, &proxy, &phase) + .unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}"))); + #[cfg(target_os = "macos")] + macos::install(&webview); + let main_id = window.id(); + + let mut windows = HashMap::new(); + windows.insert( + main_id, + AppWindow { + webview, + window, + kind: Kind::Main, + committed: None, + session_reload_at: None, + }, + ); + + let mut app = App { + proxy, + config, + config_path, + config_dirty: false, + last_save: Instant::now(), + phase, + loading_deadline: None, + auth: None, + login: None, + login_generation: 0, + login_recheck: None, + windows, + main_id, + probe_generation: 0, + start_generation: 0, + starting_server: None, + notice, + open_budget: OpenBudget::new(), + script_window_budget: OpenBudget::new(), + downloads: HashMap::new(), + menu, + keychain, + fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)), + }; + + event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow)); +} + +fn fatal(message: &str) -> ! { + eprintln!("codeman-desktop: {message}"); + std::process::exit(1); +} + +fn build_menu(config: &Config) -> muda::Result { + let bar = Menu::new(); + let about = PredefinedMenuItem::about( + Some("About Codeman"), + Some(AboutMetadata { + name: Some(APP_NAME.into()), + version: Some(VERSION.into()), + comments: Some("A native window for your Codeman server.".into()), + website: Some("https://github.com/Ark0N/Codeman".into()), + ..Default::default() + }), + ); + let switch = MenuItem::with_id( + menu_id::SWITCH, + "Switch Server\u{2026}", + true, + Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)), + ); + let app_menu = Submenu::with_items( + APP_NAME, + true, + &[ + &about, + &PredefinedMenuItem::separator(), + &switch, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(None), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::quit(None), + ], + )?; + // Without these items Cmd+C / Cmd+V do nothing inside a WKWebView. + let edit = Submenu::with_items( + "Edit", + true, + &[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + ], + )?; + let start_fullscreen = CheckMenuItem::with_id( + menu_id::START_FULLSCREEN, + "Always Open in Full Screen", + true, + config.start_fullscreen, + None, + ); + let view = Submenu::with_items( + "View", + true, + &[ + &MenuItem::with_id( + menu_id::RELOAD, + "Reload", + true, + Some(Accelerator::new(Modifiers::META, Code::KeyR)), + ), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::fullscreen(None), + &start_fullscreen, + &PredefinedMenuItem::separator(), + &MenuItem::with_id( + menu_id::DEVTOOLS, + "Developer Tools", + true, + Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)), + ), + ], + )?; + let window_menu = Submenu::with_items( + "Window", + true, + &[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::close_window(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::bring_all_to_front(None), + ], + )?; + let help_menu = Submenu::with_items( + "Help", + true, + &[ + &MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None), + &MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None), + ], + )?; + bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?; + Ok(AppMenu { + bar, + window_menu, + help_menu, + start_fullscreen, + }) +} + +fn monitors(target: &EventLoopWindowTarget) -> Vec { + target + .available_monitors() + .map(|m| { + let sf = m.scale_factor(); + let p = m.position().to_logical::(sf); + let s = m.size().to_logical::(sf); + Rect { + x: p.x, + y: p.y, + width: s.width, + height: s.height, + } + }) + .collect() +} + +/// The main window, and whether it was asked into full screen (App's +/// `fullscreen_retry` then makes sure it gets there). +fn create_main_window( + target: &EventLoopWindowTarget, + config: &Config, +) -> Result<(Window, bool), tao::error::OsError> { + let mut builder = WindowBuilder::new() + .with_title(APP_NAME) + .with_theme(Some(Theme::Dark)) + .with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1)); + let screens = monitors(target); + match config.window.and_then(|f| f.fitted_to(&screens, MIN_SIZE)) { + Some(f) => { + builder = builder + .with_inner_size(LogicalSize::new(f.width, f.height)) + .with_position(LogicalPosition::new(f.x, f.y)); + } + None => { + builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1)); + if let Some(m) = target.primary_monitor() { + let sf = m.scale_factor(); + let p = m.position().to_logical::(sf); + let s = m.size().to_logical::(sf); + let w = DEFAULT_SIZE.0.min(s.width - 40.0); + let h = DEFAULT_SIZE.1.min(s.height - 80.0); + builder = builder + .with_inner_size(LogicalSize::new(w.max(MIN_SIZE.0), h.max(MIN_SIZE.1))) + .with_position(LogicalPosition::new( + p.x + (s.width - w) / 2.0, + p.y + (s.height - h) / 2.0, + )); + } + } + } + let window = builder.build(target)?; + // Full screen is entered after the window exists at its windowed frame, + // not set on the builder: a window built straight into full screen gets + // the whole screen as its frame, and that is the frame AppKit puts back + // when the user leaves full screen. + let fullscreen = config.start_fullscreen || config.window.is_some_and(|f| f.fullscreen); + if fullscreen { + window.set_fullscreen(Some(Fullscreen::Borderless(None))); + } + Ok((window, fullscreen)) +} + +fn auth_headers(auth: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Ok(v) = HeaderValue::from_str(auth) { + headers.insert(AUTHORIZATION, v); + } + headers +} + +/// Hand an http(s) URL to the default browser. +fn open_external(url: &Url) { + #[cfg(target_os = "macos")] + let child = Command::new("/usr/bin/open").arg(url.as_str()).spawn(); + #[cfg(target_os = "windows")] + let child = Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", url.as_str()]) + .spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let child = Command::new("xdg-open").arg(url.as_str()).spawn(); + if let Ok(mut child) = child { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } +} + +/// Give the native challenge handler the login for the server at `url` (its +/// base path included: the handler answers only below it), or take it away +/// (None). It answers HTTP Basic challenges from that server only, so the page +/// recovers by itself when the server restarted and forgot its session. Also +/// re-arms the handler's refusal report. +fn set_challenge_login(login: Option<(&Url, &str, &str)>) { + #[cfg(target_os = "macos")] + macos::set_credentials(login.map(|(url, username, password)| macos::BasicCredentials { + server: url.clone(), + username: username.to_string(), + password: password.to_string(), + })); + #[cfg(not(target_os = "macos"))] + let _ = login; +} + +/// Empty the web views' cookie jar, then post CookiesCleared for this load. +/// On macOS the clear is asynchronous and `done` runs later on the main +/// thread; elsewhere the event is posted at once. +fn clear_cookies_then_load(proxy: &EventLoopProxy, generation: u64, url: Url) { + let proxy = proxy.clone(); + let done = move || { + let _ = proxy.send_event(UserEvent::CookiesCleared { generation, url }); + }; + #[cfg(target_os = "macos")] + macos::clear_cookies(Box::new(done)); + #[cfg(not(target_os = "macos"))] + done(); +} + +/// The phase a commit of `committed` in the main window moves to: Loaded once +/// a page from the server that is loading is the document. +fn loaded_on_commit(phase: &Phase, committed: &str) -> Option { + let Phase::Loading(server) = phase else { + return None; + }; + let url = Url::parse(committed).ok()?; + policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone())) +} + +fn build_webview( + window: &Window, + is_main: bool, + load: Load<'_>, + proxy: &EventLoopProxy, + phase: &Rc>, +) -> wry::Result { + let id = window.id(); + let mut builder = WebViewBuilder::new() + .with_initialization_script(assets::HOST_SHIM) + .with_devtools(true) + .with_accept_first_mouse(true) + .with_back_forward_navigation_gestures(false) + // false = let WebKit take the drop, so Codeman's own HTML5 drop + // (prompt uploads) and drops keep working. + .with_drag_drop_handler(|_| false) + // Ask the user per page instead of wry's default of granting the + // microphone and camera to every frame, sandboxed previews included. + .with_permission_handler(|_| PermissionResponse::Default); + + if is_main { + builder = builder.with_custom_protocol(policy::APP_SCHEME.into(), |_id, request| assets::respond(request)); + } + + // The handlers below read the phase with try_borrow and fail closed: the + // app never holds a borrow across a call into wry, so it cannot fail, but + // a panic here would abort the app. + let nav_phase = phase.clone(); + builder = builder.with_navigation_handler(move |target| { + nav_phase + .try_borrow() + .is_ok_and(|phase| policy::navigation(&target, &phase) == Navigation::Allow) + }); + + // Every answer to WebKit is Deny: the window WebKit would build carries + // none of the app's handlers. What the page asked for is done through the + // event loop. A refusal is not reported back: this path takes requests + // from any frame (wry does not say which), and a report would run in the + // dashboard. Codeman's own pop-outs, the ones it wants to hear about, come + // through CodemanHost.openWindow (on_host_cmd). + let nw_phase = phase.clone(); + let nw_proxy = proxy.clone(); + builder = builder.with_new_window_req_handler(move |target, _features| { + let decision = match nw_phase.try_borrow() { + Ok(phase) => policy::new_window(&target, &phase, Via::Script), + Err(_) => NewWindow::Deny, + }; + let event = match decision { + NewWindow::AppWindow(url) => Some(UserEvent::OpenAppWindow(url)), + NewWindow::External(url) => Some(UserEvent::OpenExternal(url)), + NewWindow::Deny => None, + }; + if let Some(event) = event { + let _ = nw_proxy.send_event(event); + } + NewWindowResponse::Deny + }); + + let ipc_phase = phase.clone(); + let ipc_proxy = proxy.clone(); + builder = builder.with_ipc_handler(move |request| { + let frame_url = request.uri().to_string(); + let msg = match ipc_phase.try_borrow() { + Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_main), + Err(_) => None, + }; + if let Some(msg) = msg { + let _ = ipc_proxy.send_event(UserEvent::Ipc { window: id, msg }); + } + }); + + let load_phase = phase.clone(); + let load_proxy = proxy.clone(); + builder = builder.with_on_page_load_handler(move |event, url| { + if !matches!(event, PageLoadEvent::Started) { + return; + } + // Started is didCommitNavigation. The main window's phase moves to + // Loaded right here, so the gates change in the same instant the + // server page becomes the document. App repeats the step when the + // event arrives, in case the borrow was not free. + if is_main { + if let Ok(mut phase) = load_phase.try_borrow_mut() { + if let Some(next) = loaded_on_commit(&phase, &url) { + *phase = next; + } + } + } + let _ = load_proxy.send_event(UserEvent::PageCommitted { window: id, url }); + }); + + let title_proxy = proxy.clone(); + builder = builder.with_document_title_changed_handler(move |title| { + let _ = title_proxy.send_event(UserEvent::Title { window: id, title }); + }); + + let dl_proxy = proxy.clone(); + builder = builder.with_download_started_handler(move |url, destination| { + // wry already picked ~/Downloads/, de-duplicated; it + // stays as it is. Only its name is passed on, for the toast. + let file_name = destination.file_name().map(|n| n.to_string_lossy().into_owned()); + let _ = dl_proxy.send_event(UserEvent::DownloadStarted { + window: id, + url, + file_name, + }); + true + }); + let done_proxy = proxy.clone(); + builder = builder.with_download_completed_handler(move |url, path, ok| { + let _ = done_proxy.send_event(UserEvent::DownloadDone { + window: id, + url, + path, + ok, + }); + }); + + builder = match load { + Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE), + Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)), + Load::Server { url, auth: Some(auth) } => builder.with_url_and_headers(url, auth_headers(auth)), + Load::Server { url, auth: None } => builder.with_url(url), + }; + builder.build(window) +} + +impl App { + fn handle( + &mut self, + event: Event, + target: &EventLoopWindowTarget, + control_flow: &mut ControlFlow, + ) { + match event { + Event::NewEvents(StartCause::Init) => { + #[cfg(target_os = "macos")] + { + self.menu.bar.init_for_nsapp(); + self.menu.window_menu.set_as_windows_menu_for_nsapp(); + self.menu.help_menu.set_as_help_menu_for_nsapp(); + } + } + Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow), + Event::UserEvent(event) => self.on_user_event(event, target), + // A click on the Dock icon while no window is visible, which is + // the main window minimized: tao only reports it, so bring the + // window back here. + Event::Reopen { + has_visible_windows: false, + .. + } => self.show_main_window(), + Event::MainEventsCleared => self.on_events_cleared(control_flow), + Event::LoopDestroyed => { + self.record_main_frame(); + self.save_config(); + } + _ => {} + } + } + + /// The end of a loop iteration: run what is due, then sleep until the next + /// deadline. tao reads the control flow after this iteration's last event, + /// so it is only decided here, never reset per event. + fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) { + let now = Instant::now(); + if self.config_dirty && now >= self.last_save + SAVE_DEBOUNCE { + self.save_config(); + } + let loading = matches!(*self.phase.borrow(), Phase::Loading(_)); + if !loading { + self.loading_deadline = None; + } else if self.loading_deadline.is_some_and(|due| now >= due) { + self.show_connect_page(Some(LOAD_TIMED_OUT.into()), false); + } + if self.fullscreen_retry.is_some_and(|(due, _)| now >= due) { + self.retry_fullscreen(now); + } + if matches!(*control_flow, ControlFlow::ExitWithCode(_)) { + return; + } + let save_due = self.config_dirty.then(|| self.last_save + SAVE_DEBOUNCE); + let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due); + *control_flow = match [save_due, self.loading_deadline, fullscreen_due] + .into_iter() + .flatten() + .min() + { + Some(due) => ControlFlow::WaitUntil(due), + None => ControlFlow::Wait, + }; + } + + /// The main window asked for full screen at launch: if it is not there + /// yet, ask again, a few times at most. tao reports a window as full + /// screen from the moment it is asked and puts that back only when the + /// entry fails, so `fullscreen()` being None means it failed. Stops once + /// the window is in full screen or minimized, so a user who leaves full + /// screen later is never pulled back. + fn retry_fullscreen(&mut self, now: Instant) { + let Some((_, attempts)) = self.fullscreen_retry else { + return; + }; + self.fullscreen_retry = None; + let Some(main) = self.windows.get(&self.main_id) else { + return; + }; + if main.window.fullscreen().is_some() || main.window.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS { + return; + } + main.window.set_fullscreen(Some(Fullscreen::Borderless(None))); + self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1)); + } + + fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) { + match event { + WindowEvent::CloseRequested => { + if id == self.main_id { + // Closing the main window quits; the server and its + // sessions keep running, so nothing is lost. + self.record_main_frame(); + self.save_config(); + *control_flow = ControlFlow::Exit; + } else { + self.remove_window(id); + } + } + WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(), + _ => {} + } + } + + fn on_user_event(&mut self, event: UserEvent, target: &EventLoopWindowTarget) { + match event { + UserEvent::Menu(e) => self.on_menu(e.id.0.as_str()), + UserEvent::Ipc { window, msg } => match msg { + Ipc::Host(cmd) => self.on_host_cmd(window, cmd, target), + Ipc::Connect(cmd) => self.on_connect_cmd(cmd), + }, + UserEvent::ProbeDone(done) => self.on_probe_done(*done), + UserEvent::ToConnectPage(msg) => { + if matches!(*self.phase.borrow(), Phase::Connect) { + self.to_connect_page(msg); + } + } + UserEvent::ServerStartDone { + generation, + url, + result, + } => { + self.starting_server = None; + if generation != self.start_generation { + return; // cancelled + } + let (ok, output) = match result { + Ok(o) => (true, o), + Err(e) => (false, e), + }; + self.to_connect_page(json!({ + "type": "serverStartResult", + "ok": ok, + "output": output, + "url": url.as_str(), + })); + } + UserEvent::OpenAppWindow(url) => { + if self.script_window_budget.allow(Instant::now()) { + // Built without focus: it comes to the front, but the + // keyboard stays with the window the user is typing in. + self.build_popout(target, &url, false); + } else { + eprintln!("codeman-desktop: too many windows opened by a page at once; dropped one"); + } + } + UserEvent::OpenExternal(url) => { + self.open_external_budgeted(&url); + } + UserEvent::CookiesCleared { generation, url } => { + // Only the load this clear was for, and only while it is + // still wanted: a Cancel, Switch Server, timeout or another + // connect in between moved the generation or the phase on. + let current = generation == self.probe_generation + && matches!(&*self.phase.borrow(), Phase::Loading(loading) if *loading == url); + if current { + self.load_in(self.main_id, &policy::webkit_safe_url(&url), true); + } + } + UserEvent::LoginChecked { generation, outcome } => self.on_login_checked(generation, outcome), + UserEvent::Title { window, title } => { + if let Some(w) = self.windows.get(&window) { + let title = title.trim(); + w.window.set_title(if title.is_empty() { APP_NAME } else { title }); + } + } + UserEvent::PageCommitted { window, url } => { + if window == self.main_id { + let next = loaded_on_commit(&self.phase.borrow(), &url); + if let Some(next) = next { + *self.phase.borrow_mut() = next; + } + } + if let Some(w) = self.windows.get_mut(&window) { + w.committed = Url::parse(&url).ok(); + } + } + UserEvent::DownloadStarted { window, url, file_name } => { + // A pop-out opened only to fetch a file would sit empty: hide + // it now, drop it when the download ends (dropping it sooner + // could cancel the download with its web view). + if let Some(w) = self.windows.get_mut(&window) { + if let Kind::Popout { + opened_url, + hidden_for_download, + } = &mut w.kind + { + if *opened_url == url { + *hidden_for_download = true; + w.window.set_visible(false); + } + } + self.downloads + .entry((window, url)) + .or_default() + .push_back(file_name.unwrap_or_else(|| "the file".into())); + } + } + UserEvent::DownloadDone { window, url, path, ok } => { + let key = (window, url); + let started = self.downloads.get_mut(&key).and_then(VecDeque::pop_front); + if self.downloads.get(&key).is_some_and(VecDeque::is_empty) { + self.downloads.remove(&key); + } + let name = started + .or_else(|| { + path.as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "the file".into()); + let server = self.phase.borrow().loaded().cloned(); + // The toast goes where the download was started when that + // window still shows Codeman's own app (a session pop-out has + // it too), else to the main window. Chosen before the hidden + // pop-out below is dropped. + let toast_in = server.as_ref().map(|server| { + let shows_app = self.windows.get(&window).is_some_and(|w| { + !matches!( + w.kind, + Kind::Popout { + hidden_for_download: true, + .. + } + ) && w + .committed + .as_ref() + .is_some_and(|url| policy::trusted_frame(url, server)) + }); + if shows_app { + window + } else { + self.main_id + } + }); + if matches!( + self.windows.get(&window).map(|w| &w.kind), + Some(Kind::Popout { + hidden_for_download: true, + .. + }) + ) { + self.remove_window(window); + } + if let Some(w) = toast_in.and_then(|id| self.windows.get(&id)) { + let _ = w.webview.evaluate_script(&assets::download_toast_script(&name, ok)); + } + } + UserEvent::AuthRejected => self.recheck_login(), + } + } + + /// The challenge handler reported that the server refused its login. That + /// is not taken on trust: a 401 the handler reads as Codeman's can still + /// come from something Codeman relays (a dashboard asset a web tab fetches + /// by a root-absolute URL). So the login is checked once against the + /// server's own status API, off the event loop, and on_login_checked + /// decides. + fn recheck_login(&mut self) { + let Some(server) = self.phase.borrow().server().cloned() else { + return; // the connect page: nothing to recheck + }; + let Some(auth) = self.auth.clone() else { + return; + }; + let generation = self.login_generation; + if self.login_recheck == Some(generation) { + return; + } + self.login_recheck = Some(generation); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let outcome = probe::probe(&server, Some(&auth)); + let _ = proxy.send_event(UserEvent::LoginChecked { generation, outcome }); + }); + } + + /// The server's answer to recheck_login. A refused login brings the + /// connect page back (without taking focus from another app). So does a + /// 429: the page's own requests then get 429 too, which carries no + /// challenge, so nothing else would ever notice, and re-arming would send + /// the refused login again once the limit lapses. Any other answer, a + /// network error included, leaves the page as it is and hands the handler + /// the same login again, which also re-arms its report, so a later real + /// refusal is noticed. + fn on_login_checked(&mut self, generation: u64, outcome: ProbeOutcome) { + if self.login_recheck == Some(generation) { + self.login_recheck = None; + } + if generation != self.login_generation { + return; // another login, or none, since the recheck started + } + let Some(server) = self.phase.borrow().server().cloned() else { + return; + }; + match outcome { + ProbeOutcome::BadCredentials | ProbeOutcome::NeedsLogin => { + self.show_connect_page(Some(AUTH_REJECTED.into()), false); + } + ProbeOutcome::RateLimited { .. } => { + self.show_connect_page(Some(AUTH_RATE_LIMITED.into()), false); + } + _ => { + if let Some((username, password)) = &self.login { + set_challenge_login(Some((&server, username, password))); + } + } + } + } + + fn on_menu(&mut self, id: &str) { + match id { + menu_id::SWITCH => self.show_connect_page(None, true), + menu_id::RELOAD => self.reload_focused(), + menu_id::START_FULLSCREEN => { + let value = self.menu.start_fullscreen.is_checked(); + self.config.start_fullscreen = value; + self.config_dirty = true; + self.to_connect_page(json!({ "type": "prefs", "startFullscreen": value })); + } + menu_id::DEVTOOLS => { + if let Some(w) = self.focused_window_id().and_then(|id| self.windows.get(&id)) { + w.webview.open_devtools(); + } + } + // The Help items are the user's own click, so they skip the budget. + menu_id::HELP_DOCS => { + open_external(&Url::parse("https://github.com/Ark0N/Codeman/wiki").expect("static url")) + } + menu_id::HELP_GITHUB => open_external(&Url::parse("https://github.com/Ark0N/Codeman").expect("static url")), + _ => {} + } + } + + fn on_host_cmd(&mut self, window: WindowId, cmd: HostCmd, target: &EventLoopWindowTarget) { + match cmd { + HostCmd::OpenWindow(raw) => { + // The page's openWindow already answered true, so every way + // this can fail (refused, the pop-out limit, a window that + // did not build, the browser budget) is reported back to it. + // This request comes only from Codeman's own app shell + // (parse_ipc), so a session it asked for gets its tab back. + let session = self.phase.borrow().loaded().and_then(|server| { + Url::parse(&raw) + .ok() + .and_then(|url| policy::session_id_of(&url, server)) + }); + let decision = policy::new_window(&raw, &self.phase.borrow(), Via::Host); + let opened = match decision { + NewWindow::AppWindow(url) => self.build_popout(target, &url, true), + NewWindow::External(url) => self.open_external_budgeted(&url), + NewWindow::Deny => false, + }; + if !opened { + self.tell_refused(window, session.as_deref()); + } + } + HostCmd::CloseWindow => self.remove_window(window), + HostCmd::FocusWindow => { + if let Some(w) = self.windows.get(&window) { + w.window.set_visible(true); + w.window.set_minimized(false); + w.window.set_focus(); + } + } + HostCmd::OpenExternal(raw) => { + if let Some(url) = policy::external_url(&raw) { + self.open_external_budgeted(&url); + } + } + HostCmd::SessionExpired => self.on_session_expired(window), + } + } + + fn on_connect_cmd(&mut self, cmd: ConnectCmd) { + // The connect page is still live while a server loads, but only + // Cancel and the settings make sense then. + let choosing = matches!(*self.phase.borrow(), Phase::Connect); + let loading = matches!(*self.phase.borrow(), Phase::Loading(_)); + match cmd { + ConnectCmd::Ready => self.send_init(), + ConnectCmd::Connect { + url, + username, + password, + remember, + } => { + if choosing { + self.start_probe(url, username, password, remember); + } + } + ConnectCmd::Cancel => { + self.probe_generation += 1; + self.start_generation += 1; + // Only a load that has not committed yet: once the server page + // is up, a Cancel that was still on its way changes nothing. + if loading { + self.show_connect_page(Some(LOAD_CANCELLED.into()), true); + } + } + ConnectCmd::StartServer { url } => { + if choosing { + self.start_server(url); + } + } + ConnectCmd::Forget { url } => { + if let Some(entry) = self.config.forget(&url) { + if let Some(user) = entry.username { + self.keychain.delete(&credentials::account(&url, &user)); + } + self.config_dirty = true; + } + self.to_connect_page(json!({ "type": "servers", "servers": self.config.servers })); + } + ConnectCmd::SetAutoConnect(v) => { + self.config.auto_connect = v; + self.config_dirty = true; + } + ConnectCmd::SetStartFullscreen(v) => { + self.config.start_fullscreen = v; + self.menu.start_fullscreen.set_checked(v); + self.config_dirty = true; + } + ConnectCmd::OpenExternal(raw) => { + if let Some(url) = policy::external_url(&raw) { + self.open_external_budgeted(&url); + } + } + ConnectCmd::LocalStatus { quick, deep } => { + if choosing { + let depth = match (quick, deep) { + (true, _) => setup::Depth::Quick, + (false, false) => setup::Depth::Login, + (false, true) => setup::Depth::Interactive, + }; + self.in_background(move || json!({ "type": "localStatus", "status": setup::inspect(depth) })); + } + } + ConnectCmd::InstallLocal { network } => { + if choosing { + self.in_background(move || { + let port = setup::pick_install_port(); + let script = setup::local_install_script(setup::LocalInstall { port, network }); + match setup::open_in_terminal(setup::LOCAL_SCRIPT_NAME, &script) { + Ok(()) => json!({ "type": "installStarted", "port": port }), + Err(message) => json!({ "type": "installFailed", "message": message }), + } + }); + } + } + ConnectCmd::InstallRemote { target } => { + if choosing { + match setup::SshTarget::parse(&target) { + Err(message) => self.to_connect_page(json!({ + "type": "installFailed", + "remote": true, + "message": message, + })), + Ok(target) => self.in_background(move || { + let script = setup::remote_install_script(&target); + match setup::open_in_terminal(setup::REMOTE_SCRIPT_NAME, &script) { + Ok(()) => json!({ + "type": "remoteInstallStarted", + "host": target.host, + "url": target.suggested_url(), + }), + Err(message) => json!({ "type": "installFailed", "remote": true, "message": message }), + } + }), + } + } + } + ConnectCmd::CopyInstallCommand => { + self.in_background(|| match setup::copy_to_clipboard(setup::INSTALL_ONE_LINER) { + Ok(()) => json!({ "type": "copied" }), + Err(message) => json!({ "type": "installFailed", "remote": true, "message": message }), + }); + } + } + } + + /// Run `work` off the event loop (it may run a login shell, plutil, + /// Terminal or a probe) and hand what it returns to the connect page. + fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) { + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let _ = proxy.send_event(UserEvent::ToConnectPage(work())); + }); + } + + fn send_init(&mut self) { + let notice = self.notice.take(); + self.to_connect_page(json!({ + "type": "init", + "servers": self.config.servers, + "autoConnect": self.config.auto_connect, + "startFullscreen": self.config.start_fullscreen, + "version": VERSION, + "starting": self.start_running(), + "notice": notice, + })); + } + + /// Whether a Start Codeman run is going whose result the page will get. + fn start_running(&self) -> bool { + self.starting_server == Some(self.start_generation) + } + + fn start_probe(&mut self, raw: String, username: Option, password: Option, remember: bool) { + let url = match policy::normalize_server_url(&raw) { + Ok(u) => u, + Err(message) => { + self.to_connect_page(json!({ "type": "error", "message": message })); + return; + } + }; + let key = url.to_string(); + let typed_user = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty()); + let typed_password = password.filter(|p| !p.is_empty()); + let entry = self.config.find(&key); + // The user the config names for this server now. Its Keychain item + // goes once the probe accepts another one (update_keychain). + let previous_user = entry.and_then(|e| e.username.clone()); + let user = typed_user.or_else(|| previous_user.clone()); + // The config says the Keychain holds this user's password. + let expect_saved = typed_password.is_none() + && user.is_some() + && entry.is_some_and(|e| e.has_saved_password && e.username == user); + + self.probe_generation += 1; + let generation = self.probe_generation; + let proxy = self.proxy.clone(); + let keychain = self.keychain.clone(); + // Every Keychain call for the connect happens here, never on the + // event loop: any of them can bring up the system's access prompt, + // which must not hold up the window. + std::thread::spawn(move || { + let saved = match typed_password { + Some(_) => None, + None => user + .as_ref() + .and_then(|u| keychain.load_blocking(&credentials::account(&key, u))), + }; + let (user, password, source) = match typed_password { + Some(p) => (user, Some(p), PasswordSource::Typed), + None => match saved { + Some(p) => (user, Some(p), PasswordSource::Keychain), + // The Codeman on this Mac keeps its login where Codeman's + // own clients find it (setup::local_login: its launchd + // job, then ~/.codeman/.env), and only answers it at its + // own loopback address. Used when nothing was typed and + // the Keychain has nothing for this server, for the same + // username only, and never saved anywhere. + None if !expect_saved => match setup::local_login(&url) { + Some((u, p)) if user.as_deref().is_none_or(|current| current == u) => { + (Some(u), Some(p), PasswordSource::LocalServer) + } + _ => (user, None, PasswordSource::Absent), + }, + None => (user, None, PasswordSource::Absent), + }, + }; + // Codeman's default username when only a password was given. + let username = password + .as_ref() + .map(|_| user.clone().unwrap_or_else(|| "admin".into())); + let auth = match (&username, &password) { + (Some(u), Some(p)) => Some(policy::basic_auth_header(u, p)), + _ => None, + }; + let (result, update) = if expect_saved && password.is_none() { + (ProbeResult::SavedPasswordUnreadable, KeychainUpdate::default()) + } else { + let outcome = probe::probe(&url, auth.as_deref()); + let login = ProbedLogin { + server: &key, + previous_user: previous_user.as_deref(), + username: username.as_deref(), + password: password.as_deref(), + source, + remember, + }; + let update = update_keychain(&keychain, &outcome, &login); + (ProbeResult::Probed(outcome), update) + }; + let _ = proxy.send_event(UserEvent::ProbeDone(Box::new(ProbeDone { + generation, + url, + result, + auth, + username, + password, + saved: update.saved, + deleted: update.deleted, + }))); + }); + } + + fn on_probe_done(&mut self, done: ProbeDone) { + let key = done.url.to_string(); + let choosing = matches!(*self.phase.borrow(), Phase::Connect); + if done.generation != self.probe_generation || !choosing { + // Cancelled, superseded, or a server is already loading. What the + // worker did to the Keychain stands all the same, so an entry + // whose password it deleted stops claiming one. + self.note_deleted_passwords(&key, &done.deleted); + return; + } + match done.result { + ProbeResult::SavedPasswordUnreadable => { + // needsPassword: the page shows the login fields, which the + // This Mac tab does not have. + self.to_connect_page(json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true })); + } + ProbeResult::Probed(ProbeOutcome::Ok { version }) => { + let orphans = self.config.remember(&key, done.username.as_deref(), done.saved); + self.delete_accounts(&orphans); + self.config_dirty = true; + self.to_connect_page(json!({ "type": "opening", "version": version })); + self.begin_loading(done.url, done.auth, done.username, done.password); + } + ProbeResult::Probed(ProbeOutcome::PasswordChangeRequired) => { + // The worker already deleted the one-time password and any + // older one saved for the account (update_keychain). + let orphans = self.config.remember(&key, done.username.as_deref(), false); + self.delete_accounts(&orphans); + self.config_dirty = true; + self.to_connect_page(json!({ "type": "opening", "version": null, "passwordChange": true })); + self.begin_loading(done.url, done.auth, done.username, done.password); + } + ProbeResult::Probed(outcome) => { + let can_start = outcome == ProbeOutcome::Refused && policy::can_start_locally(&done.url); + self.to_connect_page(json!({ + "type": "probeResult", + "url": done.url.as_str(), + "outcome": outcome, + "canStart": can_start, + })); + } + } + } + + /// Point the main window at the server. The cookies go first: a session + /// cookie an earlier login left (another user, a Forget, a password + /// changed since) would win over this login's Authorization header, since + /// Codeman checks the cookie first, and the first load mints a fresh one + /// from the header anyway. The load itself starts once the clear is done + /// (UserEvent::CookiesCleared). The connect page stays the live document + /// until the server page commits; on_events_cleared gives up after + /// LOAD_TIMEOUT, which counts from here, so it covers the clear too. + fn begin_loading(&mut self, url: Url, auth: Option, username: Option, password: Option) { + // Set before the load: its first response may already be a challenge. + self.set_login(username.zip(password).map(|(u, p)| (&url, u, p))); + self.auth = auth; + // Before the load: the navigation handler allows the server's origin + // only once the phase names it. + *self.phase.borrow_mut() = Phase::Loading(url.clone()); + self.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT); + clear_cookies_then_load(&self.proxy, self.probe_generation, url); + } + + /// Hand the challenge handler a new login, as (server, username, + /// password), or none, and start a new login generation, so a recheck of + /// the one before it is ignored when it comes back. + fn set_login(&mut self, login: Option<(&Url, String, String)>) { + set_challenge_login(login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str()))); + self.login = login.map(|(_, u, p)| (u, p)); + self.login_generation += 1; + } + + fn start_server(&mut self, raw: String) { + if let Some(running) = self.starting_server { + // A start is still going, maybe one the page cancelled: its result + // goes to the page that asked again. + self.start_generation = running; + self.to_connect_page(json!({ "type": "serverStarting" })); + return; + } + let url = match policy::normalize_server_url(&raw) { + Ok(u) if policy::can_start_locally(&u) => u, + Ok(_) => { + self.to_connect_page(json!({ "type": "error", "message": START_REFUSED })); + return; + } + Err(message) => { + self.to_connect_page(json!({ "type": "error", "message": message })); + return; + } + }; + self.start_generation += 1; + let generation = self.start_generation; + self.starting_server = Some(generation); + self.to_connect_page(json!({ "type": "serverStarting" })); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let result = setup::start_local(&url); + let _ = proxy.send_event(UserEvent::ServerStartDone { + generation, + url, + result, + }); + }); + } + + /// Leave whatever the main window shows for the connect page: Switch + /// Server, a load that failed or was cancelled, or a refused login. + /// `notice` is shown by that page once it asks for its init. + /// + /// `front` is for what the user just did (Switch Server, Reload, Cancel): + /// the main window comes forward and takes the keyboard. A timeout or a + /// refused login can happen while the user works in another app, so then + /// the window stays where it is and the Dock icon bounces once instead, + /// unless the window is already the one in use. + fn show_connect_page(&mut self, notice: Option, front: bool) { + self.probe_generation += 1; + let popouts: Vec = self.windows.keys().copied().filter(|id| *id != self.main_id).collect(); + for id in popouts { + self.remove_window(id); + } + // Before the load: the navigation handler allows the connect page + // only while the phase says it is live. + *self.phase.borrow_mut() = Phase::Connect; + self.loading_deadline = None; + self.auth = None; + self.set_login(None); + self.notice = notice; + if let Some(main) = self.windows.get_mut(&self.main_id) { + main.session_reload_at = None; + let _ = main.webview.load_url(&format!("{}?manual=1", policy::CONNECT_PAGE)); + main.window.set_title(APP_NAME); + if front { + main.window.set_visible(true); + main.window.set_focus(); + } else if !main.window.is_focused() || main.window.is_minimized() { + main.window + .request_user_attention(Some(UserAttentionType::Informational)); + } + } + } + + fn show_main_window(&self) { + if let Some(main) = self.windows.get(&self.main_id) { + main.window.set_minimized(false); + main.window.set_visible(true); + main.window.set_focus(); + } + } + + /// The URL a server window reloads, handed to wry as is, and whether the + /// login goes with it. Main window: its committed page if that is + /// Codeman's own app shell, else the server root (a raw file that + /// replaced the window, for example). Pop-out: its committed page, or the + /// URL it was opened with when nothing committed yet. The login goes only + /// below the server's base path. + fn reload_target(&self, id: WindowId) -> Option<(String, bool)> { + let server = self.phase.borrow().loaded().cloned()?; + let w = self.windows.get(&id)?; + let target = match &w.kind { + Kind::Main => match &w.committed { + Some(current) if policy::trusted_frame(current, &server) => current.clone(), + _ => server.clone(), + }, + Kind::Popout { opened_url, .. } => w.committed.clone().or_else(|| Url::parse(opened_url).ok())?, + }; + let with_auth = policy::within_base(&target, &server); + Some((policy::webkit_safe_url(&target), with_auth)) + } + + /// Load `url` in window `id`, with the login when `with_auth` and there is + /// one. + fn load_in(&self, id: WindowId, url: &str, with_auth: bool) { + let Some(w) = self.windows.get(&id) else { + return; + }; + let result = match self.auth.as_deref().filter(|_| with_auth) { + Some(auth) => w.webview.load_url_with_headers(url, auth_headers(auth)), + None => w.webview.load_url(url), + }; + if let Err(e) = result { + eprintln!("codeman-desktop: could not load a page: {e}"); + } + } + + fn reload_focused(&mut self) { + let Some(id) = self.focused_window_id() else { + return; + }; + let loaded = self.phase.borrow().loaded().is_some(); + if id == self.main_id && !loaded { + // The connect page, or a server load that has not committed: + // start over on the connect page. + self.show_connect_page(None, true); + return; + } + if let Some((url, with_auth)) = self.reload_target(id) { + self.load_in(id, &url, with_auth); + } + } + + /// The page in `window` got a 401 from its own server (the HOST_SHIM + /// fallback for when the challenge handler did not recover it): reload it + /// once with the login, never again within SESSION_RELOAD_COOLDOWN. + fn on_session_expired(&mut self, window: WindowId) { + if self.auth.is_none() { + return; + } + let now = Instant::now(); + let recent = self.windows.get(&window).is_some_and(|w| { + w.session_reload_at + .is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN) + }); + if recent { + return; + } + let Some((url, true)) = self.reload_target(window) else { + return; + }; + if let Some(w) = self.windows.get_mut(&window) { + w.session_reload_at = Some(now); + } + self.load_in(window, &url, true); + } + + /// Open one of Codeman's pop-out pages in an app window. False when it + /// did not open: the pop-out limit, no server loaded, a URL that is not a + /// pop-out page, or a window or web view that failed to build. `focused`: + /// the window takes the keyboard; otherwise it comes to the front without + /// it. + fn build_popout(&mut self, target: &EventLoopWindowTarget, url: &Url, focused: bool) -> bool { + let Some(server) = self.phase.borrow().loaded().cloned() else { + return false; + }; + // The callers only pass what policy::new_window made an AppWindow; + // checked again here, where the window is built, so no other path + // can ever open a proxied dashboard or another page top-level. + if !policy::is_popout_target(url, &server) { + return false; + } + let popouts = self + .windows + .values() + .filter(|w| matches!(w.kind, Kind::Popout { .. })) + .count(); + if popouts >= MAX_POPOUTS { + return false; + } + let opened_url = policy::webkit_safe_url(url); + let auth = self.auth.clone().filter(|_| policy::within_base(url, &server)); + let window = match WindowBuilder::new() + .with_title(APP_NAME) + .with_theme(Some(Theme::Dark)) + .with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1)) + .with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1)) + .with_focused(focused) + .build(target) + { + Ok(window) => window, + Err(e) => { + eprintln!("codeman-desktop: could not open a window: {e}"); + return false; + } + }; + let load = Load::Server { + url: &opened_url, + auth: auth.as_deref(), + }; + let webview = match build_webview(&window, false, load, &self.proxy, &self.phase) { + Ok(webview) => webview, + Err(e) => { + eprintln!("codeman-desktop: could not create a web view: {e}"); + return false; + } + }; + #[cfg(target_os = "macos")] + macos::install(&webview); + self.windows.insert( + window.id(), + AppWindow { + webview, + window, + kind: Kind::Popout { + opened_url, + hidden_for_download: false, + }, + committed: None, + session_reload_at: None, + }, + ); + true + } + + /// Tell the page in `window` that the window it asked for through + /// CodemanHost.openWindow was not opened (assets::refused_script), with + /// the session id when that was a session pop-out, so the dashboard takes + /// the session's tab back. Only for that request, which parse_ipc takes + /// from Codeman's own app shell alone. + fn tell_refused(&self, window: WindowId, session_id: Option<&str>) { + if let Some(w) = self.windows.get(&window) { + let _ = w.webview.evaluate_script(&assets::refused_script(session_id)); + } + } + + /// Hand a URL to the default browser if the open budget allows; a page + /// can call window.open without a click, so openings over budget are + /// dropped. + fn open_external_budgeted(&mut self, url: &Url) -> bool { + if !self.open_budget.allow(Instant::now()) { + eprintln!( + "codeman-desktop: too many outside links at once; dropped one to {}", + url.host_str().unwrap_or("?") + ); + return false; + } + open_external(url); + true + } + + /// Close a pop-out (never the main window) and forget its downloads. + fn remove_window(&mut self, id: WindowId) { + if id == self.main_id { + return; + } + self.windows.remove(&id); + self.downloads.retain(|(window, _), _| *window != id); + } + + /// The app window that is key. None while a window that is not ours is + /// (the About panel, a detached inspector): Reload and Developer Tools + /// then do nothing rather than act on a window behind it. + fn focused_window_id(&self) -> Option { + self.windows + .iter() + .find(|(_, w)| w.window.is_focused()) + .map(|(id, _)| *id) + } + + /// Message the connect page (only while it is the main window's live + /// document). + fn to_connect_page(&self, msg: serde_json::Value) { + if !self.phase.borrow().connect_page_live() { + return; + } + if let Some(main) = self.windows.get(&self.main_id) { + let _ = main.webview.evaluate_script(&format!( + "window.__codemanDesktop && window.__codemanDesktop.onNative({msg})" + )); + } + } + + fn record_main_frame(&mut self) { + let Some(main) = self.windows.get(&self.main_id) else { + return; + }; + let w = &main.window; + let fullscreen = w.fullscreen().is_some(); + let mut frame = self.config.window.unwrap_or(WindowFrame { + x: 0.0, + y: 0.0, + width: DEFAULT_SIZE.0, + height: DEFAULT_SIZE.1, + fullscreen, + }); + // In full screen or minimized the live geometry is not the windowed + // frame; the saved one is kept for the next launch. + if !fullscreen && !w.is_minimized() { + let sf = w.scale_factor(); + // On macOS tao's with_position places the CONTENT area's top-left, + // so that is the corner saved; elsewhere it is the outer one. + #[cfg(target_os = "macos")] + let position = w.inner_position(); + #[cfg(not(target_os = "macos"))] + let position = w.outer_position(); + if let Ok(pos) = position { + let p = pos.to_logical::(sf); + frame.x = p.x; + frame.y = p.y; + } + // Not tao's inner_size(): wry replaced the window's content view, + // and tao reads the size of its own view, which stays at the + // creation size. On macOS not the web view's bounds either: a + // docked Web Inspector shrinks the web view, not the window. The + // content size is in points already. + #[cfg(target_os = "macos")] + let size = macos::content_size(w); + #[cfg(not(target_os = "macos"))] + let size = None; + let size = size.or_else(|| { + let bounds = main.webview.bounds().ok()?; + let s = bounds.size.to_logical::(sf); + Some((s.width, s.height)) + }); + if let Some((width, height)) = size { + frame.width = width; + frame.height = height; + } + } + // While the launch's full screen may still be retried, a window not + // in full screen yet keeps the flag that was saved (or asked for). + frame.fullscreen = if self.fullscreen_retry.is_some() && !fullscreen { + self.config.window.is_none_or(|f| f.fullscreen) + } else { + fullscreen + }; + if self.config.window != Some(frame) { + self.config.window = Some(frame); + self.config_dirty = true; + } + } + + fn save_config(&mut self) { + self.last_save = Instant::now(); + self.config_dirty = false; + if let Some(path) = &self.config_path { + if let Err(e) = self.config.save_to(path) { + eprintln!("codeman-desktop: could not save {}: {e}", path.display()); + } + } + } + + /// Queue the deletion of the Keychain items of accounts the config no + /// longer lists, as (server url, username). + fn delete_accounts(&self, accounts: &[(String, String)]) { + for (url, user) in accounts { + self.keychain.delete(&credentials::account(url, user)); + } + } + + /// The probe worker deleted the Keychain items of `users` for the server + /// `url`: an entry that names one of them no longer has a password saved. + fn note_deleted_passwords(&mut self, url: &str, users: &[String]) { + for entry in self.config.servers.iter_mut().filter(|s| s.url == url) { + if entry.has_saved_password && entry.username.as_ref().is_some_and(|u| users.contains(u)) { + entry.has_saved_password = false; + self.config_dirty = true; + } + } + } +} + +/// A login the probe just tried, and what the user asked for it. +struct ProbedLogin<'a> { + /// The server, as the config and the Keychain accounts key it. + server: &'a str, + /// The username the config named for this server before the connect. + previous_user: Option<&'a str>, + username: Option<&'a str>, + password: Option<&'a str>, + /// Where the password came from. + source: PasswordSource, + remember: bool, +} + +/// Where a connect's password came from, which decides what the Keychain +/// does with it (update_keychain). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PasswordSource { + /// Typed on the connect page this time: saved if Remember is on. + Typed, + /// From the Keychain: there already. + Keychain, + /// The local Codeman's own login (setup::local_login): it stays where it + /// is, and the Keychain never gets a copy. + LocalServer, + /// No password. + Absent, +} + +/// What update_keychain did: whether the Keychain now holds the login's +/// password, and whose items it deleted. +#[derive(Default)] +struct KeychainUpdate { + saved: bool, + deleted: Vec, +} + +/// Bring the Keychain in line with a probe the server answered, on the probe +/// worker. Only an accepted login changes anything (Ok, or a password the +/// server wants changed), since only then does the config get the new entry. +/// +/// When that entry names another user than the config did (compared exactly, +/// as the account string is), the old user's item is deleted FIRST, before any +/// save: the Keychain worker runs jobs in order, so a Keychain that treats two +/// spellings of a name as one item cannot lose the new password to that +/// delete. Then the typed password is saved when Remember is on and deleted +/// when it is off, and a password the server wants changed is never kept. +fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate { + let mut update = KeychainUpdate::default(); + if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) { + return update; + } + let mut delete = |user: &str| { + keychain.delete(&credentials::account(login.server, user)); + update.deleted.push(user.to_string()); + }; + if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) { + delete(old); + } + let saved = match (outcome, login.username, login.password) { + (ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => { + if login.remember { + keychain + .save_blocking(&credentials::account(login.server, user), password) + .is_ok() + } else { + delete(user); + false + } + } + // A password that came from the Keychain is saved there already. + (ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain, + (ProbeOutcome::PasswordChangeRequired, Some(user), _) => { + delete(user); + false + } + _ => false, + }; + update.saved = saved; + update +} diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs new file mode 100644 index 00000000..7680cc33 --- /dev/null +++ b/packages/desktop/src/policy.rs @@ -0,0 +1,1572 @@ +//! Every decision the window makes about URLs and messages, as pure functions. +//! +//! Nothing here touches the UI, the network or the disk, so the whole module is +//! unit-tested on its own. That matters more than usual here: the frames inside +//! the window can show content Codeman did not write (a rendered HTML file, a +//! proxied dashboard), and these functions are what keeps that content from +//! steering the app. + +use std::net::Ipv4Addr; +use std::time::Instant; + +use base64::Engine as _; +use serde::Deserialize; +use url::{Host, Position, Url}; + +/// The custom scheme the bundled connect page is served from. +pub const APP_SCHEME: &str = "codemanapp"; +/// The connect page itself (wry serves custom schemes as `://localhost/`). +pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html"; + +/// Caps on what a page may hand the native side. +const MAX_URL_LEN: usize = 4096; +const MAX_FIELD_LEN: usize = 1024; + +/// Where the main window is in reaching a server. The gates below take it +/// instead of "is a server set", because what may talk to the native side +/// changes when a server page COMMITS, not when the user picks a server: until +/// the load commits, the connect page is still the live document, and it has +/// to stay usable if the load never arrives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Phase { + /// The connect page shows and no server is chosen. + Connect, + /// The server's URL was handed to the main web view but has not committed + /// yet; the connect page is still the live document. + Loading(Url), + /// A server page committed in the main window. + Loaded(Url), +} + +impl Phase { + /// The chosen server, loading or loaded. + pub fn server(&self) -> Option<&Url> { + match self { + Phase::Connect => None, + Phase::Loading(url) | Phase::Loaded(url) => Some(url), + } + } + + /// The server whose page is the main window's document. + pub fn loaded(&self) -> Option<&Url> { + match self { + Phase::Loaded(url) => Some(url), + _ => None, + } + } + + /// Whether the connect page is still the main window's document. + pub fn connect_page_live(&self) -> bool { + matches!(self, Phase::Connect | Phase::Loading(_)) + } +} + +/// Turn what the user typed into a server URL: scheme added when missing +/// (`https` for a tailnet `.ts.net` name, `http` otherwise), query and fragment +/// dropped, and the path ending in `/` so `api/status` joins below a base path. +pub fn normalize_server_url(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("Enter the address of your Codeman server.".into()); + } + if s.len() > MAX_URL_LEN { + return Err("That address is too long.".into()); + } + let with_scheme = if s.contains("://") { + s.to_string() + } else { + let host = s.split(['/', ':']).next().unwrap_or(""); + let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") { + "https" + } else { + "http" + }; + format!("{scheme}://{s}") + }; + let mut url = Url::parse(&with_scheme).map_err(|e| format!("That address is not valid ({e})."))?; + match url.scheme() { + "http" | "https" => {} + other => return Err(format!("Use an http:// or https:// address, not {other}://.")), + } + if url.host_str().is_none_or(str::is_empty) { + return Err("That address has no host name.".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Put the username and password in their own fields, not in the address.".into()); + } + url.set_query(None); + url.set_fragment(None); + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(url) +} + +/// Same scheme, host and port (the browser's notion of an origin). +pub fn same_origin(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() && a.host_str() == b.host_str() && a.port_or_known_default() == b.port_or_known_default() +} + +/// Whether `url` lies below the server's base path on the server's origin. The +/// Basic `Authorization` header goes only to such URLs, so another app sharing +/// the origin (`/grafana/` next to `/codeman/`) never receives the password. +/// `server.path()` always ends in `/` (normalize_server_url), so `/codemanx/` +/// is not below `/codeman/`. +pub fn within_base(url: &Url, server: &Url) -> bool { + same_origin(url, server) && url.path().starts_with(server.path()) +} + +/// A server on this machine: `localhost`, `127.0.0.0/8` or `::1`. +pub fn is_loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +/// Whether the app may offer to start Codeman for this address: plain HTTP to +/// `localhost` or `127.0.0.1`. `codeman web -d` binds 127.0.0.1 (start_args +/// passes no `--host`, because Codeman's hooks call the server at 127.0.0.1), +/// so for another loopback address (`::1`, `127.0.0.2`) the start would +/// succeed and the window still could not reach it. An `https://` loopback +/// server would carry Codeman's self-signed certificate, which the web view +/// refuses, so starting one would only lead to a second failure. +pub fn can_start_locally(url: &Url) -> bool { + url.scheme() == "http" + && is_loopback(url) + && match url.host() { + Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST, + _ => false, + } +} + +/// The `codeman web` arguments that start a server answering at `url`. +pub fn start_args(url: &Url) -> Vec { + let port = url.port_or_known_default().unwrap_or(80); + let mut args = vec![ + "web".to_string(), + "-d".to_string(), + "--port".to_string(), + port.to_string(), + ]; + let path = url.path().trim_end_matches('/'); + if !path.is_empty() { + args.push("--base-url".into()); + args.push(path.to_string()); + } + args +} + +/// Whether a frame runs Codeman's own app shell, the only page allowed to use +/// `window.CodemanHost`: the dashboard (the base path itself or `index.html`) +/// and a session pop-out (`session/`). An allowlist, so rendered HTML +/// files (`html-view/`), proxied dashboards (`webview/`) and any route Codeman +/// adds later stay out. The raw path must hold no `%` at all: the server routes +/// the DECODED path while this compares the raw one, so `/%68tml-view/` +/// reaches the html-view route while reading as some other path here. +pub fn trusted_frame(frame: &Url, server: &Url) -> bool { + if !same_origin(frame, server) || frame.path().contains('%') { + return false; + } + match frame.path().strip_prefix(server.path()) { + Some("" | "index.html") => true, + Some(rest) => rest + .strip_prefix("session/") + .is_some_and(|id| !id.is_empty() && !id.contains('/')), + None => false, + } +} + +/// Whether a same-origin URL is one of the pages Codeman's frontend opens in a +/// window of its own, and so may become an app window: a session pop-out +/// (`session/`), a rendered HTML file (`html-view/...`, served with a +/// sandbox CSP), a detached file preview or attachment (`api/sessions//` +/// `file-raw`, `file-preview`, `attachments//raw|preview`, +/// `folders//files/...`) and a download (`api/download`). A script's +/// `window.open` gets only the last two kinds (new_window, Via::Script). +/// +/// Everything else on the origin is refused, a proxied dashboard under +/// `webview/` above all. A browser keeps the opener frame's sandbox on a popup; +/// a fresh web view does not, so a dashboard opened here would run top-level as +/// Codeman's origin, cookie and all. The server's router matches the decoded +/// path, so the part below the base is decoded once, and anything a second +/// decoding or a path normalizer could still turn into another route (a `%` +/// left over, `.` or `..` segments, a backslash, NUL, userinfo) fails closed. +pub fn is_popout_target(url: &Url, server: &Url) -> bool { + popout_kind(url, server).is_some() +} + +/// The session id of a session pop-out (`session/`), or None for any other +/// URL, a pop-out target of another kind included. Read through +/// is_popout_target's own classification, so it is Some exactly when that +/// accepts the URL as a session page. The id is decoded once, the form the +/// server's router hands the session route: `session/a%62c` and +/// `%73ession/abc` both give `abc`. Codeman opens pop-outs as +/// `session/`, so this is the id its dashboard knows. +pub fn session_id_of(url: &Url, server: &Url) -> Option { + match popout_kind(url, server)? { + Popout::Session(id) => Some(id), + Popout::HtmlView | Popout::File => None, + } +} + +/// The kinds of page is_popout_target accepts. new_window tells them apart +/// by who asked (Via), session_id_of reads the session id. +#[derive(Debug, PartialEq, Eq)] +enum Popout { + /// `session/`, the id decoded once. + Session(String), + /// `html-view/...`: a rendered HTML file. + HtmlView, + /// A detached file preview, an attachment, a file in a granted folder, or + /// a download (`api/download`). + File, +} + +/// is_popout_target, saying which kind of page it found. +fn popout_kind(url: &Url, server: &Url) -> Option { + if !same_origin(url, server) || !url.username().is_empty() || url.password().is_some() { + return None; + } + let rest = below_base_decoded(url, server)?; + if rest.contains(['%', '\\', '\0']) { + return None; + } + let segments: Vec<&str> = rest.split('/').collect(); + if segments.iter().any(|s| *s == "." || *s == "..") { + return None; + } + if let Some(tail) = rest.strip_prefix("html-view/") { + return (!tail.is_empty()).then_some(Popout::HtmlView); + } + match segments.as_slice() { + ["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())), + ["api", "download"] => Some(Popout::File), + ["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File), + ["api", "sessions", id, "attachments", attachment, "raw" | "preview"] + if !id.is_empty() && !attachment.is_empty() => + { + Some(Popout::File) + } + ["api", "sessions", id, "folders", folder, "files", name, ..] + if !id.is_empty() && !folder.is_empty() && !name.is_empty() => + { + Some(Popout::File) + } + _ => None, + } +} + +/// The part of `url`'s path below the server's base path, percent-decoded once +/// (the way the server's router sees it). None when the raw path is not below +/// the base, or does not decode. The base is matched on the RAW path, as the +/// server's own base-path strip does, so `/%63odeman/` is not below `/codeman/`. +fn below_base_decoded(url: &Url, server: &Url) -> Option { + percent_decode_once(url.path().strip_prefix(server.path())?) +} + +/// Whether a Basic challenge whose 401 came from `failed` (the failure +/// response's URL) is Codeman's own login, the only one the app answers with +/// the saved password and the only one whose refusal sends the user back to +/// the connect screen. It has to lie below the base path on the server's +/// origin (within_base), which keeps another app on the same origin from ever +/// seeing the password, and it must not be on one of the two routes where a +/// 401 says nothing about the saved login: +/// +/// - `webview/` relays a proxied dashboard's status and headers, +/// `WWW-Authenticate` included, so a Basic-protected dashboard would +/// otherwise be answered with Codeman's login and, when it refuses that, +/// read as Codeman refusing it. +/// - `html-view/` serves an agent-written page that runs in an opaque origin +/// with its capability as its only credential, so a 401 there is a dead +/// capability, never a reason to send the password. +/// +/// The part below the base is decoded once with the decoder is_popout_target +/// uses, as the server's router sees it, so `%77ebview/` counts as `webview/`; +/// a path that does not decode is not ours (fail closed: WebKit's default +/// handling, never the password). What this cannot see: a dashboard asset +/// requested by a ROOT-ABSOLUTE URL (`fetch('/api/data')`), which Codeman +/// relays to the dashboard by its `Referer`, carries no `webview/` in its path, +/// so its 401 reads as in scope here. +pub fn challenge_in_scope(failed: &Url, server: &Url) -> bool { + if !within_base(failed, server) { + return false; + } + below_base_decoded(failed, server) + .is_some_and(|rest| !rest.starts_with("webview/") && !rest.starts_with("html-view/")) +} + +/// Decode `%XX` escapes once. None for a `%` that starts no escape, or for +/// bytes that are not UTF-8 (fail closed: no Codeman route needs either). +fn percent_decode_once(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hi = hex_value(*bytes.get(i + 1)?)?; + let lo = hex_value(*bytes.get(i + 2)?)?; + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +fn hex_value(b: u8) -> Option { + (b as char).to_digit(16).map(|d| d as u8) +} + +/// `url` as a string NSURL accepts on every macOS the app supports. Before +/// macOS 14, `URLWithString:` parses by the old RFCs and returns nil for +/// characters the WHATWG serializer leaves raw (`| ^ [ ]` in a path, `{ }` and +/// more in a query), and wry unwraps that nil, which aborts the app. Those are +/// percent-encoded in the path, query and fragment only, never in the scheme, +/// host or port (an IPv6 literal keeps its brackets). Existing `%XX` escapes +/// stay as they are, and a `%` that starts none becomes `%25`. The first `#` +/// starts the fragment; a second one becomes `%23`. +pub fn webkit_safe_url(url: &Url) -> String { + let head = &url[..Position::BeforePath]; + let tail = &url[Position::BeforePath..]; + let bytes = tail.as_bytes(); + let mut out = String::with_capacity(url.as_str().len() + 16); + out.push_str(head); + let mut in_fragment = false; + for (i, c) in tail.char_indices() { + let encode = match c { + '#' if !in_fragment => { + in_fragment = true; + false + } + '%' => !(bytes.len() > i + 2 && bytes[i + 1].is_ascii_hexdigit() && bytes[i + 2].is_ascii_hexdigit()), + ' ' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}' | '[' | ']' | '#' => true, + _ => false, + }; + if encode { + // Every character in the set above is ASCII. + let b = c as u8; + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + out.push('%'); + out.push(HEX[usize::from(b >> 4)] as char); + out.push(HEX[usize::from(b & 0x0f)] as char); + } else { + out.push(c); + } + } + out +} + +/// How often the app hands a page to the default browser without the user +/// asking each time: a burst of 3, then one a second. macOS lets a page call +/// `window.open` without a click, so without a budget one script could fill +/// the browser with tabs. Openings over budget are dropped, never queued. +/// main.rs keeps a separate one of these for the app windows a script opens +/// (new_window with Via::Script), so a frame looping `window.open` on a file +/// route cannot stack windows either, and cannot use up this budget, which the +/// user's own link clicks draw on. +#[derive(Debug, Clone)] +pub struct OpenBudget { + tokens: f64, + last: Option, +} + +const OPEN_BURST: f64 = 3.0; +const OPEN_REFILL_PER_SEC: f64 = 1.0; + +impl OpenBudget { + pub fn new() -> Self { + OpenBudget { + tokens: OPEN_BURST, + last: None, + } + } + + /// Spend one opening at `now` if the budget has one. The caller passes the + /// clock, so this stays pure; a `now` earlier than the last call refills + /// nothing. + pub fn allow(&mut self, now: Instant) -> bool { + if let Some(last) = self.last { + let elapsed = now.saturating_duration_since(last).as_secs_f64(); + self.tokens = (self.tokens + elapsed * OPEN_REFILL_PER_SEC).min(OPEN_BURST); + } + if self.last.is_none_or(|last| now > last) { + self.last = Some(now); + } + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + +impl Default for OpenBudget { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Navigation { + Allow, + Block, +} + +/// Whether a navigation may proceed. The handler sees every frame's +/// navigations (wry does not say which frame), so this has to be safe for an +/// iframe too: anything off the server's origin is blocked, never opened in +/// the browser, or a page full of external iframes would spray browser tabs. +/// Real link clicks to other sites are routed out by the main-frame click +/// handler in the host shim instead. +/// +/// The server's origin is allowed from the moment its load starts (the load +/// itself is such a navigation). The connect page is allowed until a server +/// page commits, so the app can abandon a load that never arrives and show the +/// connect page again; once a server page is the document, nothing on it can +/// pull the connect page into a frame. +pub fn navigation(target: &str, phase: &Phase) -> Navigation { + let Ok(url) = Url::parse(target) else { + return Navigation::Block; + }; + match url.scheme() { + "about" | "blob" | "data" => Navigation::Allow, + s if s == APP_SCHEME => { + if phase.connect_page_live() { + Navigation::Allow + } else { + Navigation::Block + } + } + "http" | "https" => match phase.server() { + Some(srv) if same_origin(&url, srv) => Navigation::Allow, + _ => Navigation::Block, + }, + _ => Navigation::Block, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum NewWindow { + /// One of Codeman's own pop-out pages that this path may open (see + /// new_window): a full app window with every handler attached. + AppWindow(Url), + /// Another site: the default browser. + External(Url), + /// `window.open` returns null. Covers `window.open('')`/`about:blank`: the + /// window WebKit would build for those carries none of the app's handlers, + /// so a sandboxed frame could point it anywhere. + Deny, +} + +/// Who asked for a new window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Via { + /// `window.open` or a `target=_blank` link, which WebKit hands the + /// new-window handler from ANY frame without saying which: a rendered HTML + /// file or a proxied dashboard can ask this way as easily as the dashboard. + Script, + /// `CodemanHost.openWindow`, which parse_ipc accepts only from a frame + /// running Codeman's own app shell (trusted_frame). + Host, +} + +/// What to do with a request for a new window. Only a committed server page +/// opens anything (the connect page uses IPC for its links). Another origin +/// goes to the default browser. On the server's origin only pop-out targets +/// become app windows, and which ones depends on who asked: +/// +/// - Via::Host: every pop-out target (is_popout_target). +/// - Via::Script: only the file and download routes, the pages Codeman itself +/// opens with `window.open`. Codeman opens session pop-outs and rendered +/// HTML files through `CodemanHost.openWindow` when it runs in the app, so a +/// script asking for `session/` or `html-view/` is some other frame, and +/// gets nothing: a rendered HTML file could otherwise open copies of itself +/// as top-level windows, each passing for one of the app's own. +/// +/// Every other same-origin URL is denied, never sent to the browser, where a +/// top-level `webview//` load would escape the iframe sandbox just the +/// same. +pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow { + if target.len() > MAX_URL_LEN { + return NewWindow::Deny; + } + let Ok(url) = Url::parse(target) else { + return NewWindow::Deny; + }; + if !matches!(url.scheme(), "http" | "https") { + return NewWindow::Deny; + } + let Some(srv) = phase.loaded() else { + return NewWindow::Deny; + }; + if !same_origin(&url, srv) { + return NewWindow::External(url); + } + match (popout_kind(&url, srv), via) { + (Some(Popout::File), _) | (Some(_), Via::Host) => NewWindow::AppWindow(url), + _ => NewWindow::Deny, + } +} + +/// `Authorization` header value for HTTP Basic auth. +pub fn basic_auth_header(username: &str, password: &str) -> String { + let raw = format!("{username}:{password}"); + format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(raw)) +} + +/// Calls the server's own page makes through `window.CodemanHost`. +#[derive(Debug, PartialEq, Eq)] +pub enum HostCmd { + OpenWindow(String), + CloseWindow, + FocusWindow, + /// A main-frame click on a link to another site. + OpenExternal(String), + /// The page got a 401 from its own server: the session cookie is gone (a + /// server restart) and the page cannot recover on its own. + SessionExpired, +} + +/// Calls the bundled connect page makes. +#[derive(Debug, PartialEq, Eq)] +pub enum ConnectCmd { + Ready, + Connect { + url: String, + username: Option, + password: Option, + remember: bool, + }, + Cancel, + StartServer { + url: String, + }, + Forget { + url: String, + }, + SetAutoConnect(bool), + SetStartFullscreen(bool), + OpenExternal(String), + /// Look at the Codeman on this Mac (setup::inspect): `quick` skips the + /// login shell, `deep` also asks its interactive profile. + LocalStatus { + quick: bool, + deep: bool, + }, + /// Run the installer for this Mac in Terminal. + InstallLocal { + network: bool, + }, + /// Run the installer on another machine over SSH, in Terminal. `target` + /// is what the user typed; setup::SshTarget::parse checks it. + InstallRemote { + target: String, + }, + /// Put the install one-liner on the clipboard. + CopyInstallCommand, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Ipc { + Host(HostCmd), + Connect(ConnectCmd), +} + +#[derive(Deserialize)] +#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)] +enum RawConnect { + Ready, + Connect { + url: String, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + #[serde(default)] + remember: bool, + }, + Cancel, + StartServer { + url: String, + }, + Forget { + url: String, + }, + SetAutoConnect { + value: bool, + }, + SetStartFullscreen { + value: bool, + }, + OpenExternal { + url: String, + }, + LocalStatus { + #[serde(default)] + quick: bool, + #[serde(default)] + deep: bool, + }, + InstallLocal { + #[serde(default)] + network: bool, + }, + InstallRemote { + target: String, + }, + CopyInstallCommand, +} + +#[derive(Deserialize)] +#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)] +enum RawHost { + OpenWindow { url: String }, + CloseWindow, + FocusWindow, + OpenExternal { url: String }, + SessionExpired, +} + +fn bounded(s: Option, max: usize) -> Option> { + match s { + Some(v) if v.len() > max => None, + other => Some(other), + } +} + +/// The bundled connect page's frame (assets.rs serves it at `/` and +/// `/index.html`; the query, such as `?manual=1`, does not matter). +fn is_connect_page(frame: &Url) -> bool { + frame.scheme() == APP_SCHEME && frame.host_str() == Some("localhost") && matches!(frame.path(), "/" | "/index.html") +} + +/// Parse and gate a message from a page. `frame_url` is the URL of the frame +/// that sent it (wry reports it from WebKit's frame info, so a page cannot +/// forge it), `is_main_window` whether it came from the main window. +/// +/// - The connect page is listened to only in the main window and only while it +/// is still the live document (Connect, or Loading before the server page +/// commits). +/// - A server page is listened to only once it committed, and only from a +/// frame running Codeman's own app shell (trusted_frame). +pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option { + if body.len() > 16 * 1024 { + return None; + } + let frame = Url::parse(frame_url).ok()?; + if frame.scheme() == APP_SCHEME { + if !is_main_window || !phase.connect_page_live() || !is_connect_page(&frame) { + return None; + } + let cmd = match serde_json::from_str::(body).ok()? { + RawConnect::Ready => ConnectCmd::Ready, + RawConnect::Connect { + url, + username, + password, + remember, + } => { + if url.len() > MAX_URL_LEN { + return None; + } + ConnectCmd::Connect { + url, + username: bounded(username, MAX_FIELD_LEN)?, + password: bounded(password, MAX_FIELD_LEN)?, + remember, + } + } + RawConnect::Cancel => ConnectCmd::Cancel, + RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url }, + RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url }, + RawConnect::SetAutoConnect { value } => ConnectCmd::SetAutoConnect(value), + RawConnect::SetStartFullscreen { value } => ConnectCmd::SetStartFullscreen(value), + RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url), + RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep }, + RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network }, + RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => { + ConnectCmd::InstallRemote { target } + } + RawConnect::CopyInstallCommand => ConnectCmd::CopyInstallCommand, + _ => return None, + }; + return Some(Ipc::Connect(cmd)); + } + let srv = phase.loaded()?; + if !trusted_frame(&frame, srv) { + return None; + } + let cmd = match serde_json::from_str::(body).ok()? { + RawHost::OpenWindow { url } if url.len() <= MAX_URL_LEN => HostCmd::OpenWindow(url), + RawHost::CloseWindow => HostCmd::CloseWindow, + RawHost::FocusWindow => HostCmd::FocusWindow, + RawHost::OpenExternal { url } if url.len() <= MAX_URL_LEN => HostCmd::OpenExternal(url), + RawHost::SessionExpired => HostCmd::SessionExpired, + _ => return None, + }; + Some(Ipc::Host(cmd)) +} + +/// Only http(s) URLs ever reach the default browser (never `file:`, a custom +/// app scheme, or anything `open` would hand to another program). +pub fn external_url(target: &str) -> Option { + let url = Url::parse(target).ok()?; + matches!(url.scheme(), "http" | "https").then_some(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn u(s: &str) -> Url { + Url::parse(s).unwrap() + } + + #[test] + fn normalize_adds_scheme_and_trailing_slash() { + assert_eq!( + normalize_server_url("127.0.0.1:3000").unwrap().as_str(), + "http://127.0.0.1:3000/" + ); + assert_eq!( + normalize_server_url(" localhost:3000 ").unwrap().as_str(), + "http://localhost:3000/" + ); + assert_eq!( + normalize_server_url("box.tailf80371.ts.net").unwrap().as_str(), + "https://box.tailf80371.ts.net/" + ); + assert_eq!( + normalize_server_url("https://host/codeman").unwrap().as_str(), + "https://host/codeman/" + ); + assert_eq!( + normalize_server_url("http://host:3000/?x=1#y").unwrap().as_str(), + "http://host:3000/" + ); + assert_eq!( + normalize_server_url("[::1]:3000").unwrap().as_str(), + "http://[::1]:3000/" + ); + } + + #[test] + fn normalize_rejects_bad_input() { + assert!(normalize_server_url("").is_err()); + assert!(normalize_server_url("ftp://host").is_err()); + assert!(normalize_server_url("file:///etc/passwd").is_err()); + assert!(normalize_server_url("http://admin:pw@host:3000").is_err()); + assert!(normalize_server_url(&format!("http://{}", "a".repeat(5000))).is_err()); + } + + #[test] + fn origin_and_loopback() { + assert!(same_origin( + &u("http://127.0.0.1:3000/"), + &u("http://127.0.0.1:3000/api/x") + )); + assert!(!same_origin(&u("http://127.0.0.1:3000/"), &u("http://127.0.0.1:3001/"))); + assert!(!same_origin( + &u("http://127.0.0.1:3000/"), + &u("https://127.0.0.1:3000/") + )); + assert!(same_origin(&u("https://host/"), &u("https://host:443/"))); + assert!(is_loopback(&u("http://localhost:3000/"))); + assert!(is_loopback(&u("http://127.0.0.5:3000/"))); + assert!(is_loopback(&u("http://[::1]:3000/"))); + assert!(!is_loopback(&u("http://192.168.1.2:3000/"))); + assert!(!is_loopback(&u("http://evil.localhost:3000/"))); + } + + #[test] + fn start_locally_only_where_the_daemon_binds() { + assert!(can_start_locally(&u("http://127.0.0.1:3000/"))); + assert!(can_start_locally(&u("http://localhost:3000/"))); + assert!(can_start_locally(&u("http://LOCALHOST:3000/codeman/"))); + // Loopback, but `codeman web -d` binds 127.0.0.1 only. + assert!(!can_start_locally(&u("http://127.0.0.2:3000/"))); + assert!(!can_start_locally(&u("http://[::1]:3000/"))); + assert!(!can_start_locally(&u("https://127.0.0.1:3000/"))); + assert!(!can_start_locally(&u("https://localhost:3000/"))); + assert!(!can_start_locally(&u("http://box.local:3000/"))); + assert!(!can_start_locally(&u("http://evil.localhost:3000/"))); + } + + #[test] + fn start_args_carry_port_and_base_path() { + assert_eq!( + start_args(&u("http://127.0.0.1:3000/")), + vec!["web", "-d", "--port", "3000"] + ); + assert_eq!(start_args(&u("http://localhost/")), vec!["web", "-d", "--port", "80"]); + assert_eq!( + start_args(&u("http://127.0.0.1:4100/codeman/")), + vec!["web", "-d", "--port", "4100", "--base-url", "/codeman"] + ); + } + + #[test] + fn phase_accessors() { + let srv = u("http://127.0.0.1:3000/"); + assert_eq!(Phase::Connect.server(), None); + assert_eq!(Phase::Connect.loaded(), None); + assert!(Phase::Connect.connect_page_live()); + assert_eq!(Phase::Loading(srv.clone()).server(), Some(&srv)); + assert_eq!(Phase::Loading(srv.clone()).loaded(), None); + assert!(Phase::Loading(srv.clone()).connect_page_live()); + assert_eq!(Phase::Loaded(srv.clone()).server(), Some(&srv)); + assert_eq!(Phase::Loaded(srv.clone()).loaded(), Some(&srv)); + assert!(!Phase::Loaded(srv).connect_page_live()); + } + + #[test] + fn navigation_policy() { + let srv = u("http://127.0.0.1:3000/"); + let loaded = Phase::Loaded(srv.clone()); + assert_eq!( + navigation("http://127.0.0.1:3000/session/abc", &loaded), + Navigation::Allow + ); + assert_eq!(navigation("about:blank", &loaded), Navigation::Allow); + assert_eq!(navigation("about:srcdoc", &loaded), Navigation::Allow); + assert_eq!(navigation("blob:http://127.0.0.1:3000/x", &loaded), Navigation::Allow); + assert_eq!(navigation("https://example.com/", &loaded), Navigation::Block); + assert_eq!(navigation("http://127.0.0.1:3001/", &loaded), Navigation::Block); + assert_eq!(navigation("file:///etc/passwd", &loaded), Navigation::Block); + assert_eq!(navigation("not a url", &loaded), Navigation::Block); + // The connect page until a server page committed. + assert_eq!(navigation(CONNECT_PAGE, &Phase::Connect), Navigation::Allow); + assert_eq!(navigation(CONNECT_PAGE, &loaded), Navigation::Block); + // Disconnected: no web origin is trusted yet. + assert_eq!(navigation("http://127.0.0.1:3000/", &Phase::Connect), Navigation::Block); + } + + #[test] + fn navigation_while_loading() { + let loading = Phase::Loading(u("http://127.0.0.1:3000/codeman/")); + // The load itself, and the connect page the app may go back to. + assert_eq!( + navigation("http://127.0.0.1:3000/codeman/", &loading), + Navigation::Allow + ); + assert_eq!(navigation(CONNECT_PAGE, &loading), Navigation::Allow); + assert_eq!( + navigation(&format!("{CONNECT_PAGE}?manual=1"), &loading), + Navigation::Allow + ); + assert_eq!(navigation("http://127.0.0.1:3001/", &loading), Navigation::Block); + assert_eq!(navigation("https://example.com/", &loading), Navigation::Block); + } + + #[test] + fn new_window_policy() { + let srv = u("https://box.tailnet.ts.net/"); + let loaded = Phase::Loaded(srv.clone()); + let loading = Phase::Loading(srv); + let long = format!("https://github.com/{}", "a".repeat(5000)); + // The gates before the pop-out check hold whoever asked. + for via in [Via::Host, Via::Script] { + // window.open('') would get a window with none of the app's handlers. + assert_eq!(new_window("", &loaded, via), NewWindow::Deny); + assert_eq!(new_window("about:blank", &loaded, via), NewWindow::Deny); + assert_eq!( + new_window("https://github.com/", &loaded, via), + NewWindow::External(u("https://github.com/")) + ); + // Same origin but not a pop-out target: denied, never the browser. + assert_eq!( + new_window("https://box.tailnet.ts.net/webview/cap/x", &loaded, via), + NewWindow::Deny + ); + assert_eq!(new_window("https://box.tailnet.ts.net/", &loaded, via), NewWindow::Deny); + assert_eq!(new_window("javascript:alert(1)", &loaded, via), NewWindow::Deny); + assert_eq!(new_window("file:///tmp/x", &loaded, via), NewWindow::Deny); + assert_eq!(new_window("mailto:a@b.c", &loaded, via), NewWindow::Deny); + assert_eq!( + new_window("blob:https://box.tailnet.ts.net/x", &loaded, via), + NewWindow::Deny + ); + assert_eq!(new_window(&long, &loaded, via), NewWindow::Deny); + // Nothing opens before a server page committed. + assert_eq!(new_window("https://github.com/", &Phase::Connect, via), NewWindow::Deny); + assert_eq!(new_window("https://github.com/", &loading, via), NewWindow::Deny); + assert_eq!( + new_window("https://box.tailnet.ts.net/session/x", &loading, via), + NewWindow::Deny + ); + assert_eq!( + new_window("https://box.tailnet.ts.net/api/download?path=x", &loading, via), + NewWindow::Deny + ); + } + assert_eq!( + new_window("https://box.tailnet.ts.net/session/x", &loaded, Via::Host), + NewWindow::AppWindow(u("https://box.tailnet.ts.net/session/x")) + ); + } + + #[test] + fn new_window_by_who_asked() { + let loaded = Phase::Loaded(u("http://127.0.0.1:3000/")); + let script = |s: &str| new_window(s, &loaded, Via::Script); + let host = |s: &str| new_window(s, &loaded, Via::Host); + // What Codeman itself opens with window.open: either way. + for s in [ + "http://127.0.0.1:3000/api/sessions/s1/file-raw?path=src%2Fa.png", + "http://127.0.0.1:3000/api/sessions/s1/file-raw?path=a.txt&download=true", + "http://127.0.0.1:3000/api/sessions/s1/file-preview?path=a.docx", + "http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw", + "http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw?download=true", + "http://127.0.0.1:3000/api/sessions/s1/attachments/att1/preview", + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw", + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview", + "http://127.0.0.1:3000/api/download?path=x", + ] { + assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}"); + assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}"); + } + // What Codeman opens only through CodemanHost: refused to a script, + // in any spelling the pop-out check accepts. + for s in [ + "http://127.0.0.1:3000/session/abc", + "http://127.0.0.1:3000/%73ession/abc", + "http://127.0.0.1:3000/html-view/cap/index.html", + "http://127.0.0.1:3000/html-view/cap/sub/page.html", + "http://127.0.0.1:3000/%68tml-view/cap/index.html", + ] { + assert_eq!(script(s), NewWindow::Deny, "{s}"); + assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}"); + } + // Not a pop-out target for either. + for s in [ + "http://127.0.0.1:3000/webview/cap/x", + "http://127.0.0.1:3000/api/sessions/s1/input", + "http://127.0.0.1:3000/api/download/x", + "http://127.0.0.1:3000/x/session/abc", + ] { + assert_eq!(script(s), NewWindow::Deny, "{s}"); + assert_eq!(host(s), NewWindow::Deny, "{s}"); + } + + let based = Phase::Loaded(u("https://host/codeman/")); + assert_eq!( + new_window( + "https://host/codeman/api/sessions/s1/file-raw?path=a", + &based, + Via::Script + ), + NewWindow::AppWindow(u("https://host/codeman/api/sessions/s1/file-raw?path=a")) + ); + assert_eq!( + new_window("https://host/api/download?path=a", &based, Via::Script), + NewWindow::Deny + ); + assert_eq!( + new_window("https://host/codeman/session/abc", &based, Via::Script), + NewWindow::Deny + ); + assert_eq!( + new_window("https://host/codeman/session/abc", &based, Via::Host), + NewWindow::AppWindow(u("https://host/codeman/session/abc")) + ); + } + + #[test] + fn challenges_in_scope() { + let srv = u("http://127.0.0.1:3000/"); + let ours = |s: &str| challenge_in_scope(&u(s), &srv); + // Codeman's own pages, API and assets. + assert!(ours("http://127.0.0.1:3000/")); + assert!(ours("http://127.0.0.1:3000/index.html")); + assert!(ours("http://127.0.0.1:3000/app.js?v=1")); + assert!(ours("http://127.0.0.1:3000/api/status")); + assert!(ours("http://127.0.0.1:3000/session/abc")); + assert!(ours( + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/50%25.txt/raw" + )); + // `webview` with no capability below it is not the proxy. + assert!(ours("http://127.0.0.1:3000/webview")); + assert!(ours("http://127.0.0.1:3000/webviewx/cap/x")); + // Decoded once: `%2577ebview` reaches `%77ebview`, not the proxy. + assert!(ours("http://127.0.0.1:3000/%2577ebview/cap/x")); + + // A proxied dashboard's relayed 401, in any spelling the router decodes. + assert!(!ours("http://127.0.0.1:3000/webview/cap/x")); + assert!(!ours("http://127.0.0.1:3000/webview/cap/")); + assert!(!ours("http://127.0.0.1:3000/%77ebview/cap/x")); + assert!(!ours("http://127.0.0.1:3000/%77%65%62view/cap/x")); + // A rendered HTML file. + assert!(!ours("http://127.0.0.1:3000/html-view/c/x")); + assert!(!ours("http://127.0.0.1:3000/%68tml-view/c/x")); + // A path that does not decode. + assert!(!ours("http://127.0.0.1:3000/a%zz")); + assert!(!ours("http://127.0.0.1:3000/session/%ff")); + // Another origin. + assert!(!ours("http://127.0.0.1:3001/")); + assert!(!ours("https://127.0.0.1:3000/")); + assert!(!ours("http://localhost:3000/")); + assert!(!ours("https://evil.example/")); + + let based = u("https://host/codeman/"); + let ours_based = |s: &str| challenge_in_scope(&u(s), &based); + assert!(ours_based("https://host/codeman/")); + assert!(ours_based("https://host/codeman/api/status")); + assert!(ours_based("https://host:443/codeman/session/abc")); + assert!(!ours_based("https://host/codeman/webview/cap/x")); + assert!(!ours_based("https://host/codeman/%77ebview/cap/x")); + assert!(!ours_based("https://host/codeman/html-view/c/x")); + // Siblings outside the base, and Codeman answering unprefixed. + assert!(!ours_based("https://host/grafana/")); + assert!(!ours_based("https://host/grafana/api/x")); + assert!(!ours_based("https://host/codemanx/")); + assert!(!ours_based("https://host/codeman")); + assert!(!ours_based("https://host/")); + assert!(!ours_based("https://host/webview/cap/x")); + assert!(!ours_based("https://host/%63odeman/api/status")); + assert!(!ours_based("http://host/codeman/")); + } + + #[test] + fn session_ids_of_popouts() { + let srv = u("http://127.0.0.1:3000/"); + let id = |s: &str| session_id_of(&u(s), &srv); + assert_eq!(id("http://127.0.0.1:3000/session/abc").as_deref(), Some("abc")); + assert_eq!(id("http://127.0.0.1:3000/session/abc?x=1#y").as_deref(), Some("abc")); + assert_eq!( + id("http://127.0.0.1:3000/session/0b7f6c2e-1").as_deref(), + Some("0b7f6c2e-1") + ); + // Decoded once, as is_popout_target and the server's router read it. + assert_eq!(id("http://127.0.0.1:3000/%73ession/abc").as_deref(), Some("abc")); + assert_eq!(id("http://127.0.0.1:3000/session/a%62c").as_deref(), Some("abc")); + assert_eq!(id("http://127.0.0.1:3000/session/a%20b").as_deref(), Some("a b")); + // Only exactly `session/` below the base. + assert_eq!(id("http://127.0.0.1:3000/x/session/abc"), None); + assert_eq!(id("http://127.0.0.1:3000/session/abc/"), None); + assert_eq!(id("http://127.0.0.1:3000/session/abc/x"), None); + assert_eq!(id("http://127.0.0.1:3000/session/"), None); + assert_eq!(id("http://127.0.0.1:3000/session/a%2Fb"), None); + assert_eq!(id("http://127.0.0.1:3000/session/%2e%2e"), None); + assert_eq!(id("http://127.0.0.1:3000/session/a%2525b"), None); + assert_eq!(id("http://127.0.0.1:3000/Session/abc"), None); + assert_eq!(id("http://127.0.0.1:3000/webview/cap/session/abc"), None); + // Pop-out targets of the other kinds. + assert_eq!(id("http://127.0.0.1:3000/html-view/c/session/abc"), None); + assert_eq!(id("http://127.0.0.1:3000/api/sessions/abc/file-raw?path=a"), None); + assert_eq!(id("http://127.0.0.1:3000/api/download?path=a"), None); + // Other origins and userinfo. + assert_eq!(id("http://127.0.0.1:3001/session/abc"), None); + assert_eq!(id("http://admin:pw@127.0.0.1:3000/session/abc"), None); + + let based = u("https://host/codeman/"); + let idb = |s: &str| session_id_of(&u(s), &based); + assert_eq!(idb("https://host/codeman/session/abc").as_deref(), Some("abc")); + assert_eq!(idb("https://host/session/abc"), None); + assert_eq!(idb("https://host/grafana/session/abc"), None); + assert_eq!(idb("https://host/codeman/x/session/abc"), None); + // Agrees with is_popout_target on every session-shaped URL above. + for s in [ + "http://127.0.0.1:3000/session/abc", + "http://127.0.0.1:3000/%73ession/abc", + "http://127.0.0.1:3000/x/session/abc", + "http://127.0.0.1:3000/session/a%2Fb", + ] { + let url = u(s); + assert_eq!(session_id_of(&url, &srv).is_some(), is_popout_target(&url, &srv), "{s}"); + } + } + + #[test] + fn popout_targets_are_codemans_own_pages() { + let srv = u("http://127.0.0.1:3000/"); + let ok = |s: &str| is_popout_target(&u(s), &srv); + assert!(ok("http://127.0.0.1:3000/session/0b7f6c2e-1")); + assert!(ok("http://127.0.0.1:3000/session/abc?x=1#y")); + assert!(ok("http://127.0.0.1:3000/html-view/cap/index.html")); + assert!(ok("http://127.0.0.1:3000/html-view/cap/sub/page.html")); + assert!(ok("http://127.0.0.1:3000/api/sessions/s1/file-raw?path=src%2Fa.png")); + assert!(ok( + "http://127.0.0.1:3000/api/sessions/s1/file-raw?path=a.txt&download=true" + )); + assert!(ok("http://127.0.0.1:3000/api/sessions/s1/file-preview?path=a.docx")); + assert!(ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw")); + assert!(ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/preview")); + assert!(ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw")); + assert!(ok( + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview" + )); + assert!(ok("http://127.0.0.1:3000/api/download?path=x")); + // A single decode is how the server's router sees the path. + assert!(ok("http://127.0.0.1:3000/%73ession/abc")); + + // Proxied dashboards, in any spelling. + assert!(!ok("http://127.0.0.1:3000/webview/cap/x")); + assert!(!ok("http://127.0.0.1:3000/webview/cap/")); + assert!(!ok("http://127.0.0.1:3000/%77ebview/cap/x")); + assert!(!ok("http://127.0.0.1:3000/%2577ebview/cap/x")); + // Traversal: the url crate resolves `%2e%2e` itself; encoded slashes + // and double encoding are refused after the decode. + assert!(!ok("http://127.0.0.1:3000/session/%2e%2e/webview/cap/")); + assert!(!ok("http://127.0.0.1:3000/html-view/c/%2e%2e/%2e%2e/webview/cap/")); + assert!(!ok("http://127.0.0.1:3000/html-view%2F..%2Fwebview%2Fcap%2F")); + assert!(!ok("http://127.0.0.1:3000/html-view/c/%252e%252e/x")); + assert!(!ok("http://127.0.0.1:3000/html-view/c/..%5C..%5Cwebview%5Cx")); + assert!(!ok("http://127.0.0.1:3000/session/a%00b")); + assert!(!ok("http://127.0.0.1:3000/session/a%zzb")); + assert!(!ok("http://127.0.0.1:3000/session/a%2")); + assert!(!ok("http://127.0.0.1:3000/session/%ff")); + // Near misses of the allowlist. + assert!(!ok("http://127.0.0.1:3000/")); + assert!(!ok("http://127.0.0.1:3000/index.html")); + assert!(!ok("http://127.0.0.1:3000/session/")); + assert!(!ok("http://127.0.0.1:3000/session/abc/")); + assert!(!ok("http://127.0.0.1:3000/session/a%2Fb")); + assert!(!ok("http://127.0.0.1:3000/Session/abc")); + assert!(!ok("http://127.0.0.1:3000/html-view/")); + assert!(!ok("http://127.0.0.1:3000/api/sessions")); + assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/input")); + assert!(!ok("http://127.0.0.1:3000/api/sessions//file-raw")); + assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete")); + assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/")); + assert!(!ok("http://127.0.0.1:3000/api/download/x")); + assert!(!ok("http://127.0.0.1:3000/api/status")); + // Other origins and userinfo. + assert!(!ok("http://127.0.0.1:3001/session/abc")); + assert!(!ok("https://127.0.0.1:3000/session/abc")); + assert!(!ok("http://admin:pw@127.0.0.1:3000/session/abc")); + } + + #[test] + fn popout_targets_below_a_base_path() { + let srv = u("https://host/codeman/"); + let ok = |s: &str| is_popout_target(&u(s), &srv); + assert!(ok("https://host/codeman/session/abc")); + assert!(ok("https://host/codeman/html-view/c/x.html")); + assert!(ok("https://host/codeman/api/sessions/s1/attachments/a1/raw")); + // Outside the base: another app on the same origin, or Codeman's + // routes answering unprefixed. + assert!(!ok("https://host/session/abc")); + assert!(!ok("https://host/grafana/session/abc")); + assert!(!ok("https://host/codemanx/session/abc")); + assert!(!ok("https://host/codeman/../webview/c/")); + assert!(!ok("https://host/codeman/%2e%2e/webview/c/")); + assert!(!ok("https://host/%63odeman/session/abc")); + assert!(!ok("https://host/codeman/webview/c/x")); + assert!(!ok("https://host/codeman/%77ebview/c/x")); + } + + #[test] + fn trusted_frames() { + let srv = u("http://127.0.0.1:3000/"); + let t = |s: &str| trusted_frame(&u(s), &srv); + assert!(t("http://127.0.0.1:3000/")); + assert!(t("http://127.0.0.1:3000/?x=1")); + assert!(t("http://127.0.0.1:3000/index.html")); + assert!(t("http://127.0.0.1:3000/session/abc")); + assert!(t("http://127.0.0.1:3000/session/abc#frag")); + // The server routes the decoded path: any `%` is untrusted. + assert!(!t("http://127.0.0.1:3000/%73ession/abc")); + assert!(!t("http://127.0.0.1:3000/%68tml-view/c/x.html")); + assert!(!t("http://127.0.0.1:3000/%77ebview/c/")); + assert!(!t("http://127.0.0.1:3000/session/a%62c")); + assert!(!t("http://127.0.0.1:3000/html-view/c/x.html")); + assert!(!t("http://127.0.0.1:3000/webview/c/")); + assert!(!t("http://127.0.0.1:3000/some/relayed/dashboard/path")); + assert!(!t("http://127.0.0.1:3000/session/")); + assert!(!t("http://127.0.0.1:3000/session/abc/x")); + assert!(!t("http://127.0.0.1:3000/api/status")); + assert!(!t("http://127.0.0.1:3001/")); + assert!(!t("https://127.0.0.1:3000/")); + assert!(!t("codemanapp://localhost/index.html")); + + let based = u("http://127.0.0.1:3000/codeman/"); + let tb = |s: &str| trusted_frame(&u(s), &based); + assert!(tb("http://127.0.0.1:3000/codeman/")); + assert!(tb("http://127.0.0.1:3000/codeman/index.html")); + assert!(tb("http://127.0.0.1:3000/codeman/session/x")); + assert!(!tb("http://127.0.0.1:3000/codeman/html-view/c/")); + // Outside the base path is untrusted (fail closed). + assert!(!tb("http://127.0.0.1:3000/")); + assert!(!tb("http://127.0.0.1:3000/session/x")); + assert!(!tb("http://127.0.0.1:3000/codeman")); + assert!(!tb("http://127.0.0.1:3000/codemanx/")); + } + + #[test] + fn authorization_stays_within_the_base_path() { + let srv = u("https://host/codeman/"); + assert!(within_base(&u("https://host/codeman/"), &srv)); + assert!(within_base(&u("https://host/codeman/session/x?y=1"), &srv)); + assert!(!within_base(&u("https://host/codemanx/"), &srv)); + assert!(!within_base(&u("https://host/grafana/"), &srv)); + assert!(!within_base(&u("https://host/"), &srv)); + assert!(!within_base(&u("https://host/codeman/../grafana/"), &srv)); + assert!(!within_base(&u("http://host/codeman/"), &srv)); + assert!(!within_base(&u("https://other/codeman/"), &srv)); + let root = u("http://127.0.0.1:3000/"); + assert!(within_base(&u("http://127.0.0.1:3000/anything"), &root)); + assert!(!within_base(&u("http://127.0.0.1:3001/"), &root)); + } + + #[test] + fn webkit_safe_urls() { + // The url crate leaves these raw; pre-14 NSURL rejects them. + assert_eq!( + webkit_safe_url(&u("http://127.0.0.1:3000/a|b^c[d]/x?q={x}|^[]`\\")), + "http://127.0.0.1:3000/a%7Cb%5Ec%5Bd%5D/x?q=%7Bx%7D%7C%5E%5B%5D%60%5C" + ); + assert_eq!(webkit_safe_url(&u("http://h/p#a{b}#c")), "http://h/p#a%7Bb%7D%23c"); + // Host and port untouched: an IPv6 literal keeps its brackets. + assert_eq!( + webkit_safe_url(&u("http://[::1]:3000/x[1]?a[]=1")), + "http://[::1]:3000/x%5B1%5D?a%5B%5D=1" + ); + // Existing escapes stay; a `%` that starts none is escaped itself. + assert_eq!( + webkit_safe_url(&u("http://h/%7Bx%7d?q=%7B%41")), + "http://h/%7Bx%7d?q=%7B%41" + ); + assert_eq!(webkit_safe_url(&u("http://h/a%zz?b=5%")), "http://h/a%25zz?b=5%25"); + // Ordinary URLs pass through unchanged. + for s in [ + CONNECT_PAGE, + "codemanapp://localhost/index.html?manual=1", + "http://127.0.0.1:3000/", + "https://box.tailnet.ts.net/codeman/session/abc", + "http://h/api/sessions/s/file-raw?path=src%2Fa%20b.png&download=true", + ] { + assert_eq!(webkit_safe_url(&u(s)), s); + } + // The result still parses to the same resource. + let messy = u("http://h/a|b?q={x}#f[1]"); + assert_eq!(u(&webkit_safe_url(&messy)).host_str(), Some("h")); + assert_eq!(u(&webkit_safe_url(&messy)).path(), "/a%7Cb"); + } + + #[test] + fn open_budget_bursts_then_refills() { + let t0 = Instant::now(); + let mut b = OpenBudget::new(); + assert!(b.allow(t0)); + assert!(b.allow(t0)); + assert!(b.allow(t0)); + assert!(!b.allow(t0)); + assert!(!b.allow(t0 + Duration::from_millis(500))); + assert!(b.allow(t0 + Duration::from_millis(1000))); + assert!(!b.allow(t0 + Duration::from_millis(1000))); + // A clock going backwards refills nothing. + assert!(!b.allow(t0)); + assert!(b.allow(t0 + Duration::from_millis(2000))); + // A long pause refills to the burst, never beyond it. + let later = t0 + Duration::from_secs(60); + assert!(b.allow(later)); + assert!(b.allow(later)); + assert!(b.allow(later)); + assert!(!b.allow(later)); + } + + #[test] + fn basic_auth() { + assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0"); + } + + #[test] + fn ipc_from_server_page() { + let srv = u("http://127.0.0.1:3000/"); + let loaded = Phase::Loaded(srv.clone()); + let main = "http://127.0.0.1:3000/"; + assert_eq!( + parse_ipc( + main, + r#"{"cmd":"openWindow","url":"http://127.0.0.1:3000/session/1"}"#, + &loaded, + true + ), + Some(Ipc::Host(HostCmd::OpenWindow("http://127.0.0.1:3000/session/1".into()))) + ); + assert_eq!( + parse_ipc( + "http://127.0.0.1:3000/session/1", + r#"{"cmd":"closeWindow"}"#, + &loaded, + false + ), + Some(Ipc::Host(HostCmd::CloseWindow)) + ); + assert_eq!( + parse_ipc( + main, + r#"{"cmd":"openExternal","url":"https://github.com/"}"#, + &loaded, + true + ), + Some(Ipc::Host(HostCmd::OpenExternal("https://github.com/".into()))) + ); + assert_eq!( + parse_ipc(main, r#"{"cmd":"sessionExpired"}"#, &loaded, true), + Some(Ipc::Host(HostCmd::SessionExpired)) + ); + assert_eq!( + parse_ipc( + "http://127.0.0.1:3000/session/1", + r#"{"cmd":"sessionExpired"}"#, + &loaded, + false + ), + Some(Ipc::Host(HostCmd::SessionExpired)) + ); + // Untrusted surfaces, other origins, connect commands: refused. + for frame in [ + "http://127.0.0.1:3000/html-view/c/x.html", + "http://127.0.0.1:3000/%68tml-view/c/x.html", + "http://127.0.0.1:3000/webview/c/", + "http://127.0.0.1:3000/%77ebview/c/", + "http://127.0.0.1:3000/some/relayed/dashboard/path", + "https://evil.example/", + "about:blank", + ] { + assert_eq!(parse_ipc(frame, r#"{"cmd":"focusWindow"}"#, &loaded, true), None); + assert_eq!(parse_ipc(frame, r#"{"cmd":"sessionExpired"}"#, &loaded, true), None); + } + assert_eq!( + parse_ipc(main, r#"{"cmd":"startServer","url":"x"}"#, &loaded, true), + None + ); + assert_eq!(parse_ipc(main, r#"{"cmd":"focusWindow"}"#, &Phase::Connect, true), None); + assert_eq!( + parse_ipc(main, r#"{"cmd":"runShell","line":"rm -rf ~"}"#, &loaded, true), + None + ); + assert_eq!(parse_ipc(main, r#"{"cmd":"openWindow"}"#, &loaded, true), None); + assert_eq!(parse_ipc(main, "not json", &loaded, true), None); + // serde ignores extra keys on a no-argument command even with + // deny_unknown_fields; harmless, the command itself is still checked. + assert_eq!( + parse_ipc(main, r#"{"cmd":"focusWindow","extra":1}"#, &loaded, true), + Some(Ipc::Host(HostCmd::FocusWindow)) + ); + } + + #[test] + fn ipc_from_a_based_server_page() { + let loaded = Phase::Loaded(u("https://host/codeman/")); + assert_eq!( + parse_ipc("https://host/codeman/", r#"{"cmd":"focusWindow"}"#, &loaded, true), + Some(Ipc::Host(HostCmd::FocusWindow)) + ); + assert_eq!( + parse_ipc("https://host/grafana/", r#"{"cmd":"focusWindow"}"#, &loaded, true), + None + ); + assert_eq!( + parse_ipc("https://host/", r#"{"cmd":"focusWindow"}"#, &loaded, true), + None + ); + } + + #[test] + fn ipc_while_loading() { + let loading = Phase::Loading(u("http://127.0.0.1:3000/")); + let page = "codemanapp://localhost/index.html"; + // The connect page is still the document: it stays usable. + assert_eq!( + parse_ipc(page, r#"{"cmd":"cancel"}"#, &loading, true), + Some(Ipc::Connect(ConnectCmd::Cancel)) + ); + assert_eq!( + parse_ipc(page, r#"{"cmd":"connect","url":"127.0.0.1:3001"}"#, &loading, true), + Some(Ipc::Connect(ConnectCmd::Connect { + url: "127.0.0.1:3001".into(), + username: None, + password: None, + remember: false + })) + ); + // The server page has not committed: nothing it says is heard yet. + assert_eq!( + parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"focusWindow"}"#, &loading, true), + None + ); + assert_eq!( + parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"sessionExpired"}"#, &loading, true), + None + ); + assert_eq!( + parse_ipc( + "http://127.0.0.1:3000/", + r#"{"cmd":"openExternal","url":"https://github.com/"}"#, + &loading, + true + ), + None + ); + } + + #[test] + fn ipc_install_commands() { + let page = "codemanapp://localhost/index.html"; + let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true); + assert_eq!( + c(r#"{"cmd":"localStatus","quick":true}"#), + Some(Ipc::Connect(ConnectCmd::LocalStatus { + quick: true, + deep: false + })) + ); + assert_eq!( + c(r#"{"cmd":"localStatus","deep":true}"#), + Some(Ipc::Connect(ConnectCmd::LocalStatus { + quick: false, + deep: true + })) + ); + assert_eq!( + c(r#"{"cmd":"localStatus"}"#), + Some(Ipc::Connect(ConnectCmd::LocalStatus { + quick: false, + deep: false + })) + ); + assert_eq!( + c(r#"{"cmd":"installLocal","network":true}"#), + Some(Ipc::Connect(ConnectCmd::InstallLocal { network: true })) + ); + assert_eq!( + c(r#"{"cmd":"installLocal"}"#), + Some(Ipc::Connect(ConnectCmd::InstallLocal { network: false })) + ); + assert_eq!( + c(r#"{"cmd":"installRemote","target":"kay@box"}"#), + Some(Ipc::Connect(ConnectCmd::InstallRemote { + target: "kay@box".into() + })) + ); + assert_eq!( + c(r#"{"cmd":"copyInstallCommand"}"#), + Some(Ipc::Connect(ConnectCmd::CopyInstallCommand)) + ); + // Nothing else rides along: no script, flags or command from the page. + assert_eq!(c(r#"{"cmd":"installLocal","args":["--yes"]}"#), None); + assert_eq!( + c(r#"{"cmd":"installRemote","target":"kay@box","command":"rm -rf ~"}"#), + None + ); + assert_eq!(c(r#"{"cmd":"installRemote"}"#), None); + assert_eq!(c(r#"{"cmd":"localStatus","quick":"yes"}"#), None); + let long = format!( + r#"{{"cmd":"installRemote","target":"{}"}}"#, + "a".repeat(MAX_FIELD_LEN + 1) + ); + assert_eq!(c(&long), None); + // Only the connect page, only in the main window, never a server page. + assert_eq!( + parse_ipc(page, r#"{"cmd":"copyInstallCommand"}"#, &Phase::Connect, false), + None + ); + let loaded = Phase::Loaded(u("http://127.0.0.1:3000/")); + assert_eq!( + parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installLocal"}"#, &loaded, true), + None + ); + assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None); + } + + #[test] + fn ipc_from_connect_page() { + let page = "codemanapp://localhost/index.html"; + assert_eq!( + parse_ipc(page, r#"{"cmd":"ready"}"#, &Phase::Connect, true), + Some(Ipc::Connect(ConnectCmd::Ready)) + ); + assert_eq!( + parse_ipc( + "codemanapp://localhost/index.html?manual=1", + r#"{"cmd":"ready"}"#, + &Phase::Connect, + true + ), + Some(Ipc::Connect(ConnectCmd::Ready)) + ); + assert_eq!( + parse_ipc( + page, + r#"{"cmd":"connect","url":"127.0.0.1:3000","username":"admin","password":"pw","remember":true}"#, + &Phase::Connect, + true + ), + Some(Ipc::Connect(ConnectCmd::Connect { + url: "127.0.0.1:3000".into(), + username: Some("admin".into()), + password: Some("pw".into()), + remember: true + })) + ); + assert_eq!( + parse_ipc( + page, + r#"{"cmd":"setStartFullscreen","value":true}"#, + &Phase::Connect, + true + ), + Some(Ipc::Connect(ConnectCmd::SetStartFullscreen(true))) + ); + // Not from a pop-out window, not once a server page committed, never + // host commands, and only from the connect page itself. + assert_eq!(parse_ipc(page, r#"{"cmd":"ready"}"#, &Phase::Connect, false), None); + let loaded = Phase::Loaded(u("http://127.0.0.1:3000/")); + assert_eq!(parse_ipc(page, r#"{"cmd":"ready"}"#, &loaded, true), None); + assert_eq!( + parse_ipc(page, r#"{"cmd":"openWindow","url":"x"}"#, &Phase::Connect, true), + None + ); + assert_eq!( + parse_ipc(page, r#"{"cmd":"sessionExpired"}"#, &Phase::Connect, true), + None + ); + assert_eq!( + parse_ipc( + "codemanapp://localhost/icon.png", + r#"{"cmd":"ready"}"#, + &Phase::Connect, + true + ), + None + ); + assert_eq!( + parse_ipc( + "codemanapp://elsewhere/index.html", + r#"{"cmd":"ready"}"#, + &Phase::Connect, + true + ), + None + ); + let long = "p".repeat(2000); + assert_eq!( + parse_ipc( + page, + &format!(r#"{{"cmd":"connect","url":"h","password":"{long}"}}"#), + &Phase::Connect, + true + ), + None + ); + } + + #[test] + fn external_urls_are_http_only() { + assert!(external_url("https://github.com/Ark0N/Codeman").is_some()); + assert!(external_url("file:///Applications/Calculator.app").is_none()); + assert!(external_url("codemanapp://localhost/").is_none()); + assert!(external_url("vscode://file/x").is_none()); + } +} diff --git a/packages/desktop/src/probe.rs b/packages/desktop/src/probe.rs new file mode 100644 index 00000000..0998f2c2 --- /dev/null +++ b/packages/desktop/src/probe.rs @@ -0,0 +1,727 @@ +//! "Is a Codeman server answering at this address, and does it want a login?" +//! +//! One `GET /api/status`, five seconds at most, run off the UI thread. +//! The answer is classified into something the connect page can explain. It is +//! asked once per user action, never in a loop: Codeman counts a request +//! without valid credentials against the login rate limit (10 per IP). + +use serde::Serialize; +use std::time::Duration; +use url::Url; + +const TIMEOUT: Duration = Duration::from_secs(5); +const MAX_BODY: u64 = 8 * 1024 * 1024; + +/// The path the probe asks for, relative to the server's base URL. +const STATUS_PATH: &str = "api/status"; + +/// rustls' text for `Error::InvalidCertificate`, which is what a handshake +/// with a certificate the OS does not trust fails with. ureq hands it over as +/// an `io::Error` of kind `InvalidData`, so the text is all there is to match +/// without a direct rustls dependency. +const CERT_REJECTED_PREFIX: &str = "invalid peer certificate"; + +/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which +/// cannot be matched on stable Rust, so the prefix is the only handle. The +/// prefix is the same on macOS and Linux; only the resolver's reason differs. +const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ProbeOutcome { + Ok { + version: String, + }, + /// 401 and we sent no credentials. + NeedsLogin, + /// 401 although we sent credentials. + BadCredentials, + /// 429: too many failed logins from this address. A single-user Codeman + /// still accepts the right password, so the page needs to know whether + /// this probe carried one. `retry_after_secs` is the server's + /// `Retry-After`, when it gave one in seconds. + #[serde(rename_all = "camelCase")] + RateLimited { + sent_credentials: bool, + #[serde(skip_serializing_if = "Option::is_none")] + retry_after_secs: Option, + }, + /// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a + /// multi-user Codeman wants this account's password changed first. Page + /// loads are exempt, so the web UI can show its change-password dialog. + PasswordChangeRequired, + /// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain). + HostNotAllowed, + /// 3xx. `location` is the target resolved against the probed URL. + /// `suggested` is a server address to use instead, set only when the + /// redirect stays on the same host, only moves to https or another port, + /// and still points at the status API. Never followed: a second request + /// would carry the credentials to an address the user did not type. + Redirect { + location: String, + suggested: Option, + }, + /// Something answered, but not Codeman's status API. + NotCodeman { + status: u16, + }, + Refused, + /// No route to the address: the machine is off or on another network, + /// or macOS's Local Network privacy setting blocked the app. + Unreachable, + /// An http:// probe whose connection was closed without an answer. A + /// Codeman started with `--https` does exactly that to plain HTTP. + MaybeHttps, + Timeout, + TlsUntrusted { + detail: String, + }, + Unresolvable, + Failed { + detail: String, + }, +} + +/// Classify an HTTP answer to `/api/status`. `request` is the URL that +/// was asked, `location` and `retry_after` are those response headers. +pub fn classify_response( + request: &Url, + status: u16, + location: Option<&str>, + retry_after: Option<&str>, + sent_credentials: bool, + body: &str, +) -> ProbeOutcome { + match status { + 200 => { + let parsed: Option = serde_json::from_str(body).ok(); + let version = parsed.as_ref().and_then(|v| { + (v.get("success")? == &serde_json::Value::Bool(true)) + .then(|| v.get("data")?.get("version")?.as_str().map(str::to_string)) + .flatten() + }); + match version { + Some(version) => ProbeOutcome::Ok { version }, + None => ProbeOutcome::NotCodeman { status }, + } + } + 300..=399 => { + // A 3xx without a usable Location (304, a garbled header) is not a + // redirect anyone can act on. + match location.and_then(|l| request.join(l.trim()).ok()) { + Some(target) => ProbeOutcome::Redirect { + suggested: suggested_server(request, &target), + location: target.into(), + }, + None => ProbeOutcome::NotCodeman { status }, + } + } + 401 if sent_credentials => ProbeOutcome::BadCredentials, + 401 => ProbeOutcome::NeedsLogin, + 403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed, + // Codeman only says this after the Basic credentials verified (the + // probe sends no cookie), so without credentials it is not Codeman's + // answer to us, and loading the page without them would not help. + 403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => { + ProbeOutcome::PasswordChangeRequired + } + 429 => ProbeOutcome::RateLimited { + sent_credentials, + retry_after_secs: retry_after.and_then(|v| v.trim().parse::().ok()), + }, + other => ProbeOutcome::NotCodeman { status: other }, + } +} + +/// `errorCode` of a Codeman `ApiResponse` error body. +fn error_code(body: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(body).ok()?; + Some(parsed.get("errorCode")?.as_str()?.to_string()) +} + +/// The server base URL a redirect points to, when it is the same server +/// moved to https or another port (a reverse proxy upgrading plain HTTP, the +/// usual case). Anything else, such as another host (a login gateway the +/// window could not pass anyway) or a downgrade to http, gets no suggestion. +fn suggested_server(request: &Url, target: &Url) -> Option { + let upgrade = request.scheme() == "http" && target.scheme() == "https"; + let port_change = + request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default(); + if !(upgrade || port_change) || target.host() != request.host() { + return None; + } + if !target.username().is_empty() || target.password().is_some() { + return None; + } + // The redirect must keep pointing at the status API (a proxy may add a + // base path in front of it); the base is what comes before it. + if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) { + return None; + } + let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()]; + let mut base = target.clone(); + base.set_path(base_path); + base.set_query(None); + base.set_fragment(None); + Some(base.into()) +} + +/// Transport failures, reduced to what the connect page explains differently. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + Refused, + Unreachable, + ClosedWithoutAnswer, + Timeout, + Tls(String), + Dns, + Other(String), +} + +pub fn classify_transport(err: TransportError) -> ProbeOutcome { + match err { + TransportError::Refused => ProbeOutcome::Refused, + TransportError::Unreachable => ProbeOutcome::Unreachable, + TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps, + TransportError::Timeout => ProbeOutcome::Timeout, + TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail }, + TransportError::Dns => ProbeOutcome::Unresolvable, + TransportError::Other(detail) => ProbeOutcome::Failed { detail }, + } +} + +/// `plain_http`: the probe went to an http:// URL. Only there does a closed +/// connection hint at an https-only server; over https the same close is a +/// failed handshake with nothing more to say about it. +fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError { + use std::io::ErrorKind; + match err { + ureq::Error::Timeout(_) => TransportError::Timeout, + ureq::Error::HostNotFound => TransportError::Dns, + ureq::Error::ConnectionFailed => TransportError::Refused, + // ureq passes both a rejected certificate (rustls wraps it as + // InvalidData during the handshake) and a failed name lookup (std's + // own error) through as plain io errors, so they are told apart here + // before the kinds below swallow them. + ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => { + TransportError::Tls(e.to_string()) + } + ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => { + TransportError::Dns + } + ureq::Error::Io(e) => match e.kind() { + ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => { + TransportError::Refused + } + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable, + ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer, + ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout, + _ => TransportError::Other(e.to_string()), + }, + // ureq's own rustls errors come from setting up the TLS client (the + // handshake's arrive as Io above); only a certificate rejection is + // "untrusted", anything else is reported as it is. + ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()), + other => TransportError::Other(other.to_string()), + } +} + +/// Ask the server. Blocking: call it from a worker thread. +pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { + let status_url = match server.join(STATUS_PATH) { + Ok(u) => u, + Err(e) => return ProbeOutcome::Failed { detail: e.to_string() }, + }; + let tls = ureq::tls::TlsConfig::builder() + .root_certs(ureq::tls::RootCerts::PlatformVerifier) + .build(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(TIMEOUT)) + .http_status_as_error(false) + // Never follow: a 3xx comes back as an answer and is explained. + .max_redirects(0) + .tls_config(tls) + .user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION"))) + .build() + .into(); + let mut req = agent.get(status_url.as_str()).header("Accept", "application/json"); + if let Some(auth) = authorization { + req = req.header("Authorization", auth); + } + match req.call() { + Ok(mut resp) => { + let status = resp.status().as_u16(); + let header = |name: &str| { + resp.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + let location = header("location"); + let retry_after = header("retry-after"); + let body = resp + .body_mut() + .with_config() + .limit(MAX_BODY) + .read_to_string() + .unwrap_or_default(); + classify_response( + &status_url, + status, + location.as_deref(), + retry_after.as_deref(), + authorization.is_some(), + &body, + ) + } + Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + + fn status_url() -> Url { + Url::parse("http://studio.lan:3000/api/status").unwrap() + } + + /// `classify_response` for the cases that need no headers. + fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome { + classify_response(&status_url(), status, None, None, sent_credentials, body) + } + + fn redirect(request: &str, location: &str) -> ProbeOutcome { + classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "") + } + + fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome { + let err = ureq::Error::Io(io::Error::new(kind, message.to_string())); + classify_transport(transport_error(&err, plain_http)) + } + + #[test] + fn ok_needs_success_and_version() { + assert_eq!( + classify( + 200, + false, + r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"# + ), + ProbeOutcome::Ok { + version: "1.47.0".into() + } + ); + assert_eq!( + classify(200, false, "hello"), + ProbeOutcome::NotCodeman { status: 200 } + ); + assert_eq!( + classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#), + ProbeOutcome::NotCodeman { status: 200 } + ); + assert_eq!( + classify(200, false, r#"{"success":true}"#), + ProbeOutcome::NotCodeman { status: 200 } + ); + } + + #[test] + fn auth_and_guards() { + assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin); + assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials); + assert_eq!( + classify(403, false, "Forbidden: host not allowed"), + ProbeOutcome::HostNotAllowed + ); + // The Host guard runs before authentication, so it wins even with credentials. + assert_eq!( + classify(403, true, "Forbidden: host not allowed"), + ProbeOutcome::HostNotAllowed + ); + assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 }); + assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 }); + } + + /// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`. + #[test] + fn password_change_required_only_after_sent_credentials() { + let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#; + assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired); + assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 }); + // Another Codeman error code, or the text without the JSON field, is not it. + assert_eq!( + classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#), + ProbeOutcome::NotCodeman { status: 403 } + ); + assert_eq!( + classify(403, true, "PASSWORD_CHANGE_REQUIRED"), + ProbeOutcome::NotCodeman { status: 403 } + ); + } + + #[test] + fn rate_limited_says_whether_credentials_went_along() { + let body = "Too Many Requests, try again later"; + assert_eq!( + classify(429, false, body), + ProbeOutcome::RateLimited { + sent_credentials: false, + retry_after_secs: None + } + ); + assert_eq!( + classify(429, true, body), + ProbeOutcome::RateLimited { + sent_credentials: true, + retry_after_secs: None + } + ); + // auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed. + assert_eq!( + classify_response(&status_url(), 429, None, Some(" 742 "), true, body), + ProbeOutcome::RateLimited { + sent_credentials: true, + retry_after_secs: Some(742) + } + ); + assert_eq!( + classify_response( + &status_url(), + 429, + None, + Some("Wed, 21 Oct 2026 07:28:00 GMT"), + false, + body + ), + ProbeOutcome::RateLimited { + sent_credentials: false, + retry_after_secs: None + } + ); + } + + #[test] + fn redirect_to_https_on_the_same_host_is_suggested() { + assert_eq!( + redirect("http://studio.lan/api/status", "https://studio.lan/api/status"), + ProbeOutcome::Redirect { + location: "https://studio.lan/api/status".into(), + suggested: Some("https://studio.lan/".into()) + } + ); + // A base path the proxy adds stays part of the suggestion; a query does not. + assert_eq!( + redirect( + "http://studio.lan:3000/api/status", + "https://studio.lan/codeman/api/status?from=http" + ), + ProbeOutcome::Redirect { + location: "https://studio.lan/codeman/api/status?from=http".into(), + suggested: Some("https://studio.lan/codeman/".into()) + } + ); + // A relative Location resolves against the probed URL; another port is fine. + assert_eq!( + redirect( + "http://studio.lan:3000/base/api/status", + "//studio.lan:3001/base/api/status" + ), + ProbeOutcome::Redirect { + location: "http://studio.lan:3001/base/api/status".into(), + suggested: Some("http://studio.lan:3001/base/".into()) + } + ); + } + + #[test] + fn other_redirects_are_reported_without_a_suggestion() { + // Another host (a login gateway, say). + assert_eq!( + redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"), + ProbeOutcome::Redirect { + location: "https://sso.example.com/login?next=x".into(), + suggested: None + } + ); + // Same host, but no longer the status API. + assert_eq!( + redirect("http://studio.lan/api/status", "/login"), + ProbeOutcome::Redirect { + location: "http://studio.lan/login".into(), + suggested: None + } + ); + // A downgrade to plain http. + assert_eq!( + redirect("https://studio.lan/api/status", "http://studio.lan/api/status"), + ProbeOutcome::Redirect { + location: "http://studio.lan/api/status".into(), + suggested: None + } + ); + // Same scheme and port, only the path moved. + assert_eq!( + redirect("http://studio.lan/api/status", "/x/api/status"), + ProbeOutcome::Redirect { + location: "http://studio.lan/x/api/status".into(), + suggested: None + } + ); + // Credentials in the target are never suggested. + assert_eq!( + redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"), + ProbeOutcome::Redirect { + location: "https://u:p@studio.lan/api/status".into(), + suggested: None + } + ); + // No Location (304), or one that does not parse: nothing to act on. + assert_eq!( + classify_response(&status_url(), 304, None, None, false, ""), + ProbeOutcome::NotCodeman { status: 304 } + ); + assert_eq!( + classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""), + ProbeOutcome::NotCodeman { status: 302 } + ); + } + + #[test] + fn transport_classes() { + assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused); + assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout); + assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable); + assert_eq!( + classify_transport(TransportError::Tls("bad cert".into())), + ProbeOutcome::TlsUntrusted { + detail: "bad cert".into() + } + ); + } + + /// The io errors exactly as ureq hands them over (`Error::Io`). + #[test] + fn io_errors_are_told_apart() { + use io::ErrorKind; + // rustls' certificate rejection from the handshake. + let cert = "invalid peer certificate: UnknownIssuer"; + assert_eq!( + io_outcome(ErrorKind::InvalidData, cert, false), + ProbeOutcome::TlsUntrusted { detail: cert.into() } + ); + // Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem. + assert_eq!( + io_outcome( + ErrorKind::InvalidData, + "received corrupt message of type InvalidContentType", + false + ), + ProbeOutcome::Failed { + detail: "received corrupt message of type InvalidContentType".into() + } + ); + // std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable). + assert_eq!( + io_outcome( + ErrorKind::Other, + "failed to lookup address information: nodename nor servname provided, or not known", + true + ), + ProbeOutcome::Unresolvable + ); + // The same text from the OS (an errno) is not a lookup failure. + let os = ureq::Error::Io(io::Error::from_raw_os_error(1)); + assert!(!matches!( + classify_transport(transport_error(&os, true)), + ProbeOutcome::Unresolvable + )); + // ureq's "Peer disconnected": over http a hint at https, over https just a failure. + assert_eq!( + io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true), + ProbeOutcome::MaybeHttps + ); + assert_eq!( + io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false), + ProbeOutcome::Failed { + detail: "Peer disconnected".into() + } + ); + // No route: Local Network privacy, or a machine that is off. + assert_eq!( + io_outcome(ErrorKind::HostUnreachable, "No route to host", true), + ProbeOutcome::Unreachable + ); + assert_eq!( + io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false), + ProbeOutcome::Unreachable + ); + assert_eq!( + io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true), + ProbeOutcome::Refused + ); + assert_eq!( + classify_transport(transport_error(&ureq::Error::HostNotFound, true)), + ProbeOutcome::Unresolvable + ); + // A ureq TLS setup message is not an untrusted certificate. + assert_eq!( + classify_transport(transport_error( + &ureq::Error::Tls("Rustls invalid dns name error"), + false + )), + ProbeOutcome::Failed { + detail: "Rustls invalid dns name error".into() + } + ); + } + + #[test] + fn outcome_serializes_for_the_page() { + let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap(); + assert_eq!( + json(ProbeOutcome::NotCodeman { status: 404 }), + serde_json::json!({"kind":"notCodeman","status":404}) + ); + assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"})); + assert_eq!( + json(ProbeOutcome::RateLimited { + sent_credentials: true, + retry_after_secs: None + }), + serde_json::json!({"kind":"rateLimited","sentCredentials":true}) + ); + assert_eq!( + json(ProbeOutcome::RateLimited { + sent_credentials: false, + retry_after_secs: Some(60) + }), + serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60}) + ); + assert_eq!( + json(ProbeOutcome::PasswordChangeRequired), + serde_json::json!({"kind":"passwordChangeRequired"}) + ); + assert_eq!( + json(ProbeOutcome::Redirect { + location: "https://studio.lan/api/status".into(), + suggested: Some("https://studio.lan/".into()) + }), + serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"}) + ); + assert_eq!( + json(ProbeOutcome::Redirect { + location: "https://sso.example.com/".into(), + suggested: None + }), + serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null}) + ); + assert_eq!( + json(ProbeOutcome::Unreachable), + serde_json::json!({"kind":"unreachable"}) + ); + assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"})); + assert_eq!( + json(ProbeOutcome::TlsUntrusted { detail: "x".into() }), + serde_json::json!({"kind":"tlsUntrusted","detail":"x"}) + ); + assert_eq!( + json(ProbeOutcome::Unresolvable), + serde_json::json!({"kind":"unresolvable"}) + ); + } + + /// Against real sockets: a closed port is `Refused`, a listener that + /// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`, + /// a 401 is `NeedsLogin`, a redirect is reported without being followed, + /// and a listener that hangs up without answering is `MaybeHttps`. + #[test] + fn probe_against_local_sockets() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let closed = { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap(); + assert_eq!(probe(&url, None), ProbeOutcome::Refused); + + /// Accept one connection, read the request and send `response` + /// (nothing, then close, for an empty one). + fn serve_raw(response: String) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + if let Ok((mut s, _)) = l.accept() { + // Read the whole request head (a GET has no body) so that + // closing sends a FIN, not a reset for unread bytes. + let mut head = Vec::new(); + let mut buf = [0u8; 4096]; + while !head.windows(4).any(|w| w == b"\r\n\r\n") { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => head.extend_from_slice(&buf[..n]), + } + } + let _ = s.write_all(response.as_bytes()); + } + }); + port + } + fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 { + serve_raw(format!( + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}", + body.len() + )) + } + + let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!( + probe(&url, None), + ProbeOutcome::Ok { + version: "9.9.9".into() + } + ); + + let p = serve_once("404 Not Found", "", "nope"); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 }); + + let p = serve_once("401 Unauthorized", "", "Unauthorized"); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin); + + let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests"); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!( + probe(&url, Some("Basic YWRtaW46eA==")), + ProbeOutcome::RateLimited { + sent_credentials: true, + retry_after_secs: Some(30) + } + ); + + // Nothing listens on the redirect target: had it been followed, the + // answer would be Refused instead. + let p = serve_once( + "301 Moved Permanently", + &format!("Location: https://127.0.0.1:{closed}/api/status\r\n"), + "", + ); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!( + probe(&url, None), + ProbeOutcome::Redirect { + location: format!("https://127.0.0.1:{closed}/api/status"), + suggested: Some(format!("https://127.0.0.1:{closed}/")) + } + ); + + let p = serve_raw(String::new()); + let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); + assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps); + } +} diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs new file mode 100644 index 00000000..2efa931f --- /dev/null +++ b/packages/desktop/src/setup.rs @@ -0,0 +1,1522 @@ +//! The connect screen's "This Mac" tab and its installs: where the Codeman on +//! this Mac listens, whether it is installed, its login, and the installer runs +//! for this Mac and for another machine over SSH. +//! +//! The installer always runs in Terminal, never inside the app. `install.sh` +//! asks for the administrator password on a terminal (Homebrew, its sudo +//! keep-alive), the Xcode tools arrive as a system dialog, and ssh asks about +//! host keys and passwords on a terminal too: a run without one fails on the +//! first fresh Mac. So the app writes a small script that deletes itself and +//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no +//! permission, unlike scripting Terminal through osascript (an Automation +//! prompt). Everything the scripts contain is built here and unit-tested; the +//! only text from the user is the SSH login, which `SshTarget::parse` limits to +//! the characters of a user and host name before it is quoted. + +use std::collections::HashMap; +use std::io::Write; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::Serialize; +use url::Url; + +use crate::local_server; +use crate::policy; +use crate::probe::{self, ProbeOutcome}; + +/// Codeman's published installer (a redirect to `install.sh` on master). +pub const INSTALLER_URL: &str = "https://getcodeman.com/install"; + +/// What to paste into a terminal on another machine; the SSH install runs the +/// same thing there. +pub const INSTALL_ONE_LINER: &str = "curl -fsSL https://getcodeman.com/install | bash"; + +/// Codeman's default port, and the first one a fresh install tries. +pub const DEFAULT_PORT: u16 = 3000; + +/// The Terminal scripts' file names (`--.command`). The local +/// one is also how `inspect` recognizes an install that is still running. +pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local"; +pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote"; + +/// How many ports above 3000 a fresh install tries when 3000 is taken. +const PORT_SEARCH: u16 = 100; + +/// The job label both `install.sh` and `codeman service install` use for the +/// default instance (src/config/service-names.ts). +const SERVICE_LABEL: &str = "com.codeman.web"; + +/// The local Codeman's settings, as its LaunchAgent (or LaunchDaemon) runs it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceConfig { + pub port: u16, + /// `''` or `/codeman`, as Codeman normalizes it (src/config/base-path.ts). + pub base_path: String, + pub https: bool, + pub username: Option, + pub password: Option, + /// From /Library/LaunchDaemons: a system job, not this user's agent. + pub daemon: bool, +} + +/// Read a launchd job as `plutil -convert json` prints it. The two writers +/// differ: `install.sh` runs a bare `web` and puts the port, base path and +/// password in EnvironmentVariables; `codeman service install` passes +/// `--port`, `--base-url` and `--https` as arguments and keeps no secret in +/// the file. A flag wins over its variable, as in Codeman's CLI (src/cli.ts: +/// the variable is only the flag's default). +pub fn parse_service(job: &serde_json::Value) -> ServiceConfig { + let env = job.get("EnvironmentVariables").and_then(|v| v.as_object()); + let var = |key: &str| { + env.and_then(|e| e.get(key)) + .and_then(|v| v.as_str()) + .map(str::to_string) + .filter(|s| !s.is_empty()) + }; + let args: Vec<&str> = job + .get("ProgramArguments") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let mut port = var("CODEMAN_PORT"); + let mut base = var("CODEMAN_BASE_URL"); + let mut https = false; + // Only what follows the `web` subcommand: node's own flags come before it. + let after_web = args + .iter() + .position(|a| *a == "web") + .map_or(&[][..], |i| &args[i + 1..]); + let mut it = after_web.iter(); + while let Some(arg) = it.next() { + match *arg { + "-p" | "--port" => port = it.next().map(|s| s.to_string()), + "--base-url" => base = it.next().map(|s| s.to_string()), + "--https" => https = true, + // Options that take a value: skip it, so it is never read as a flag. + "-H" | "--host" | "--title-hostname" => { + it.next(); + } + other => { + if let Some(v) = other.strip_prefix("--port=") { + port = Some(v.to_string()); + } else if let Some(v) = other.strip_prefix("--base-url=") { + base = Some(v.to_string()); + } + } + } + } + ServiceConfig { + port: port + .and_then(|p| p.trim().parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(DEFAULT_PORT), + base_path: normalize_base_path(base.as_deref().unwrap_or("")), + https, + username: var("CODEMAN_USERNAME"), + password: var("CODEMAN_PASSWORD"), + daemon: false, + } +} + +/// Codeman's `normalizeBasePath`, plus its character check: a value Codeman +/// would refuse at startup counts as the root here. +fn normalize_base_path(input: &str) -> String { + let mut p = input.trim().to_string(); + if p.is_empty() || p == "/" { + return String::new(); + } + if !p.starts_with('/') { + p.insert(0, '/'); + } + while p.contains("//") { + p = p.replace("//", "/"); + } + let p = p.trim_end_matches('/').to_string(); + let valid = !p.is_empty() + && p.split('/') + .skip(1) + .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || "._~-".contains(c))); + if valid { + p + } else { + String::new() + } +} + +/// Codeman's `parseEnvFile` (src/codeman-credentials.ts): blank lines and `#` +/// comments skipped, an `export ` prefix allowed, one layer of matching quotes +/// stripped, anything else ignored. +pub fn parse_env_file(text: &str) -> HashMap { + let mut vars = HashMap::new(); + for raw in text.split('\n') { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = match line.strip_prefix("export") { + Some(rest) if rest.starts_with(char::is_whitespace) => rest.trim_start(), + _ => line, + }; + let Some((name, value)) = line.split_once('=') else { + continue; + }; + let mut chars = name.chars(); + let portable = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !portable { + continue; + } + let mut value = value.trim(); + let quoted = + (value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\'')); + if quoted { + // A lone quote reads as empty, as JavaScript's slice(1, -1) does. + value = value.get(1..value.len() - 1).unwrap_or(""); + } + vars.insert(name.to_string(), value.to_string()); + } + vars +} + +/// Where the local Codeman answers: its service's port, base path and scheme, +/// else Codeman's defaults. Always 127.0.0.1, which every bind Codeman offers +/// answers on (loopback, all interfaces, and Tailscale serve's target). +pub fn local_url(service: Option<&ServiceConfig>) -> Url { + let (scheme, port, base) = match service { + Some(s) => (if s.https { "https" } else { "http" }, s.port, s.base_path.as_str()), + None => ("http", DEFAULT_PORT, ""), + }; + Url::parse(&format!("{scheme}://127.0.0.1:{port}{base}/")).expect("a valid loopback URL") +} + +/// The login for the Codeman on this Mac, from where it keeps one: its launchd +/// job's environment (which is the server's own), else the data dir's `.env` +/// that Codeman's command-line clients read (src/codeman-credentials.ts). +/// Username and password come from the same place, `admin` by default: the +/// server takes its username from its own environment, so a `.env` username +/// never pairs with the job's password. Only for 127.0.0.1 (the address +/// Codeman binds; `localhost` can be another socket, such as a dev server on +/// `::1`) on the local Codeman's port. +pub fn login_for( + url: &Url, + service: Option<&ServiceConfig>, + env_file: &HashMap, +) -> Option<(String, String)> { + if url.host() != Some(url::Host::Ipv4(Ipv4Addr::LOCALHOST)) { + return None; + } + let port = service.map_or(DEFAULT_PORT, |s| s.port); + if url.port_or_known_default() != Some(port) { + return None; + } + if let Some(s) = service { + if let Some(password) = &s.password { + let username = s.username.clone().unwrap_or_else(|| "admin".into()); + return Some((username, password.clone())); + } + } + let from_file = |key: &str| env_file.get(key).filter(|v| !v.is_empty()).cloned(); + let password = from_file("CODEMAN_PASSWORD")?; + let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| "admin".into()); + Some((username, password)) +} + +fn agent_path(home: &Path) -> PathBuf { + home.join("Library/LaunchAgents").join(format!("{SERVICE_LABEL}.plist")) +} + +/// A headless Mac's owner may run Codeman as a system daemon instead. +fn daemon_path() -> PathBuf { + PathBuf::from(format!("/Library/LaunchDaemons/{SERVICE_LABEL}.plist")) +} + +/// The local Codeman's launchd job, if there is one this app can read. +pub fn read_service() -> Option { + let home = dirs::home_dir()?; + let agent = agent_path(&home); + if agent.is_file() { + if let Some(s) = read_service_file(&agent) { + return Some(s); + } + } + let daemon = daemon_path(); + if daemon.is_file() { + return read_service_file(&daemon).map(|s| ServiceConfig { daemon: true, ..s }); + } + None +} + +/// One launchd job file, read with `plutil` (XML or binary plist alike). +fn read_service_file(path: &Path) -> Option { + let out = Command::new("/usr/bin/plutil") + .args(["-convert", "json", "-o", "-"]) + .arg(path) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let job: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + Some(parse_service(&job)) +} + +fn read_env_file() -> HashMap { + dirs::home_dir() + .and_then(|h| std::fs::read_to_string(h.join(".codeman/.env")).ok()) + .map(|t| parse_env_file(&t)) + .unwrap_or_default() +} + +/// `login_for` with what is on disk now, and only while one of this user's +/// own Codeman servers holds that port: with Codeman stopped, whatever else +/// listens there (another user's process, say) never gets the password. Runs +/// `plutil`, `ps` and `lsof`, so never on the event loop. +pub fn local_login(url: &Url) -> Option<(String, String)> { + if url.host() != Some(url::Host::Ipv4(Ipv4Addr::LOCALHOST)) { + return None; + } + let login = login_for(url, read_service().as_ref(), &read_env_file())?; + let port = url.port_or_known_default()?; + let servers = codeman_servers(&list_processes()); + serves_loopback_port(&listeners(&servers), port).then_some(login) +} + +/// This user's processes, as `ps` lists them: (pid, command line). +fn list_processes() -> Vec<(u32, String)> { + let Ok(out) = Command::new("/bin/ps") + .args(["-x", "-ww", "-o", "pid=", "-o", "command="]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + parse_ps(&String::from_utf8_lossy(&out.stdout)) +} + +fn parse_ps(text: &str) -> Vec<(u32, String)> { + text.lines() + .filter_map(|line| { + let line = line.trim_start(); + let (pid, command) = line.split_once(char::is_whitespace)?; + Some((pid.parse().ok()?, command.trim().to_string())) + }) + .collect() +} + +/// Whether a command line is a Codeman server (`codeman web`, the +/// `install.sh` clone's `dist/index.js web`, a source checkout's +/// `src/index.ts web`), not one of its short-lived control commands. +pub fn is_codeman_server(command: &str) -> bool { + let words: Vec<&str> = command.split_whitespace().collect(); + let entry = words.iter().position(|w| { + let name = w.rsplit('/').next().unwrap_or(w); + w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman" + }); + match entry { + Some(i) => { + words.get(i + 1) == Some(&"web") && !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status")) + } + None => false, + } +} + +/// Whether a command line is an install the app started on this Mac (the +/// Terminal script, or the installer it downloaded), and the port it asked +/// for once the installer runs. +pub fn install_in_progress(command: &str) -> Option> { + let words: Vec<&str> = command.split_whitespace().collect(); + let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string(); + if words.iter().any(|w| { + let n = name(w); + n.starts_with(&format!("{LOCAL_SCRIPT_NAME}-")) && n.ends_with(".command") + }) { + return Some(None); + } + if words.iter().any(|w| name(w).starts_with("codeman-install.")) { + let port = words + .iter() + .position(|w| *w == "--port") + .and_then(|i| words.get(i + 1)) + .and_then(|p| p.parse().ok()); + return Some(port); + } + None +} + +fn codeman_servers(procs: &[(u32, String)]) -> Vec { + procs + .iter() + .filter(|(_, c)| is_codeman_server(c)) + .map(|(pid, _)| *pid) + .collect() +} + +/// A listening TCP socket: its pid, address (`*`, `127.0.0.1`, `[::1]`, a +/// specific IP) and port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Listener { + pub pid: u32, + pub host: String, + pub port: u16, +} + +/// What `lsof -F pn` printed: `p` starts a process, `n:` +/// names one of its sockets; other fields are ignored. +pub fn parse_lsof(text: &str) -> Vec { + let mut pid = None; + let mut found = Vec::new(); + for line in text.lines() { + if let Some(p) = line.strip_prefix('p') { + pid = p.parse().ok(); + } else if let (Some(addr), Some(pid)) = (line.strip_prefix('n'), pid) { + if let Some((host, port)) = addr.rsplit_once(':') { + if let Ok(port) = port.parse() { + found.push(Listener { + pid, + host: host.to_string(), + port, + }); + } + } + } + } + found +} + +/// The TCP ports the given processes listen on. +fn listeners(pids: &[u32]) -> Vec { + if pids.is_empty() { + return Vec::new(); + } + let list: Vec = pids.iter().map(u32::to_string).collect(); + let Ok(out) = Command::new("/usr/sbin/lsof") + .args(["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-F", "pn", "-p"]) + .arg(list.join(",")) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + parse_lsof(&String::from_utf8_lossy(&out.stdout)) +} + +/// Whether one of `found` answers 127.0.0.1:`port` (bound to it, or to every +/// interface). +fn serves_loopback_port(found: &[Listener], port: u16) -> bool { + found + .iter() + .any(|l| l.port == port && matches!(l.host.as_str(), "*" | "127.0.0.1" | "0.0.0.0")) +} + +/// The address a listener is reached at: 127.0.0.1 for a loopback or +/// all-interfaces bind, else its own address. +fn listener_url(l: &Listener) -> Option { + let host = match l.host.as_str() { + "*" | "0.0.0.0" | "127.0.0.1" => "127.0.0.1".to_string(), + h if h.starts_with('[') => h.to_string(), + h => h.to_string(), + }; + Url::parse(&format!("http://{host}:{}/", l.port)).ok() +} + +/// What the This Mac tab shows. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LocalState { + /// Codeman answers, and the app has its login if it needs one. + Running, + /// Codeman answers but wants a login the app does not have. + NeedsLogin, + /// A Codeman server of this user runs, but not at an address the app can + /// open (bound to one network address, say): see `elsewhere`. + RunningElsewhere, + /// Nothing answers, and Codeman is installed. + Stopped, + /// Nothing answers, and Codeman is not installed (or, in a quick check, + /// not installed by `install.sh`). + NotInstalled, + /// Something else happened at that address: see `outcome`. + Other, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalStatus { + /// Where the local Codeman answers, or would. + pub url: String, + pub state: LocalState, + pub version: Option, + /// A launchd job runs Codeman here. + pub service: bool, + /// That job is a system LaunchDaemon, which only an administrator starts. + pub daemon: bool, + /// Codeman is on this Mac: it answers or runs, `install.sh` left its + /// files or a launchd job, or (not in a quick check) the login shell + /// finds it. + pub installed: bool, + /// `install.sh` finished its work (its `.install-complete` marker). + pub install_complete: bool, + /// An install the app started is still running in Terminal, and the + /// port it asked for once that is known. + pub install_running: bool, + pub install_port: Option, + /// For RunningElsewhere: the addresses that Codeman listens on. + pub elsewhere: Vec, + /// The probe's outcome, for the connect page's explanations. + pub outcome: ProbeOutcome, +} + +/// How hard `inspect` looks for an installed Codeman when nothing answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Depth { + /// Files and processes only (while an install runs). + Quick, + /// Also the login shell (`-l`), as every automatic look does. + Login, + /// Also the interactive profile (`-i`), on the user's Check again. + Interactive, +} + +/// Look at the Codeman on this Mac. +pub fn inspect(depth: Depth) -> LocalStatus { + let home = dirs::home_dir().unwrap_or_default(); + let service = read_service(); + let url = local_url(service.as_ref()); + let procs = list_processes(); + let servers = codeman_servers(&procs); + let found = listeners(&servers); + let install = procs.iter().find_map(|(_, c)| install_in_progress(c)); + + // The login goes only to this user's own Codeman on that port. + let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) { + login_for(&url, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p)) + } else { + None + }; + let mut outcome = probe::probe(&url, auth.as_deref()); + let mut url = url; + let mut elsewhere = Vec::new(); + + // Nothing at the usual address, but a Codeman server of this user runs: + // find where (a `codeman web --port 4000` in a terminal, a bind to one + // network address), so the tab never offers to start a second server on + // the same sessions. + if outcome == ProbeOutcome::Refused && !servers.is_empty() { + let mut ports_seen = Vec::new(); + for l in &found { + if ports_seen.contains(&(l.host.clone(), l.port)) { + continue; + } + ports_seen.push((l.host.clone(), l.port)); + let Some(candidate) = listener_url(l) else { + continue; + }; + let answer = probe::probe(&candidate, None); + if matches!( + answer, + ProbeOutcome::Ok { .. } + | ProbeOutcome::NeedsLogin + | ProbeOutcome::BadCredentials + | ProbeOutcome::RateLimited { .. } + | ProbeOutcome::PasswordChangeRequired + ) { + url = candidate; + outcome = answer; + break; + } + elsewhere.push(format!("{}:{}", l.host, l.port)); + } + if outcome == ProbeOutcome::Refused && elsewhere.is_empty() { + elsewhere = servers.iter().map(|pid| format!("process {pid}")).collect(); + } + } + + let app = home.join(".codeman/app"); + let install_complete = app.join(".install-complete").is_file(); + let answering = match &outcome { + ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => Some(LocalState::Running), + ProbeOutcome::NeedsLogin | ProbeOutcome::BadCredentials | ProbeOutcome::RateLimited { .. } => { + Some(LocalState::NeedsLogin) + } + _ => None, + }; + // Something else on Codeman's port (a dev server on 3000, say) says + // nothing about whether Codeman is installed, so look either way. + let on_disk = install_complete + || app.join("dist/index.js").is_file() + || agent_path(&home).is_file() + || daemon_path().is_file(); + let installed = answering.is_some() + || !servers.is_empty() + || on_disk + || match depth { + Depth::Quick => false, + Depth::Login => local_server::installed(false), + Depth::Interactive => local_server::installed(true), + }; + let state = match (answering, &outcome) { + (Some(state), _) => state, + (None, ProbeOutcome::Refused) if !servers.is_empty() => LocalState::RunningElsewhere, + (None, ProbeOutcome::Refused) if installed => LocalState::Stopped, + (None, ProbeOutcome::Refused) => LocalState::NotInstalled, + (None, _) => LocalState::Other, + }; + if state != LocalState::RunningElsewhere { + elsewhere.clear(); + } + let version = match &outcome { + ProbeOutcome::Ok { version } => Some(version.clone()), + _ => None, + }; + LocalStatus { + url: url.to_string(), + state, + version, + service: service.is_some(), + daemon: service.as_ref().is_some_and(|s| s.daemon), + installed, + install_complete, + install_running: install.is_some(), + install_port: install.flatten(), + elsewhere, + outcome, + } +} + +/// Why the app will not start a server: one of this user's Codeman servers +/// already runs. A second one on the same data dir and tmux socket would +/// attach to the first one's live sessions (src/config/instance.ts), and +/// `codeman web -d` only notices a server it started itself or one at the +/// same address. +fn already_running() -> Option { + let servers = codeman_servers(&list_processes()); + let pid = servers.first()?; + let found = listeners(&servers); + let at: Vec = found.iter().map(|l| format!("{}:{}", l.host, l.port)).collect(); + let where_ = if at.is_empty() { + format!("process {pid}") + } else { + at.join(", ") + }; + Some(format!( + "A Codeman server is already running on this Mac ({where_}). Starting another one would take over its sessions: connect to it instead, or stop it first." + )) +} + +/// Start the Codeman on this Mac for `url`. A LaunchAgent that serves that +/// address is started through launchd, so it runs with its own settings +/// (host, password) and stays supervised; anything else gets `codeman web -d` +/// (local_server::start). Never while one of this user's Codeman servers runs. +pub fn start_local(url: &Url) -> Result { + if let Some(why) = already_running() { + return Err(why); + } + match read_service() { + Some(s) if local_url(Some(&s)) == *url => { + if s.daemon { + return Err(format!( + "A system LaunchDaemon runs Codeman on this Mac; an administrator starts it: sudo launchctl kickstart -k system/{SERVICE_LABEL}" + )); + } + start_launch_agent(s.port) + } + _ => local_server::start(url), + } +} + +/// `launchctl kickstart` the LaunchAgent if launchd has it loaded, else +/// `bootstrap` it, then wait until its port takes connections (a TCP connect, +/// never an HTTP request, which a password-protected server would count as a +/// failed login). +fn start_launch_agent(port: u16) -> Result { + let home = dirs::home_dir().ok_or("No home folder.")?; + let plist = agent_path(&home); + let uid = Command::new("/usr/bin/id") + .arg("-u") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse::().ok()) + .ok_or("Could not find the user id.")?; + let domain = format!("gui/{uid}"); + let target = format!("{domain}/{SERVICE_LABEL}"); + let launchctl = |args: &[&str]| { + Command::new("/bin/launchctl") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + }; + let loaded = launchctl(&["print", &target]).is_ok_and(|o| o.status.success()); + let plist_arg = plist.to_string_lossy().to_string(); + let run = if loaded { + launchctl(&["kickstart", "-k", &target]) + } else { + launchctl(&["bootstrap", &domain, &plist_arg]) + }; + match run { + Ok(o) if o.status.success() => {} + Ok(o) => { + let said = String::from_utf8_lossy(&o.stderr).trim().to_string(); + return Err(format!("launchd did not start Codeman: {said}")); + } + Err(e) => return Err(format!("Could not run launchctl: {e}")), + } + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { + if !port_free(port) { + return Ok(format!("launchd started Codeman ({SERVICE_LABEL}).")); + } + std::thread::sleep(Duration::from_millis(500)); + } + Err("launchd started Codeman, but it did not answer within 30 seconds. Its log is /tmp/codeman.log for an install.sh setup.".into()) +} + +/// Whether nothing listens on 127.0.0.1:`port`. +fn port_free(port: u16) -> bool { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + match TcpStream::connect_timeout(&addr, Duration::from_millis(300)) { + Ok(_) => false, + Err(e) => e.kind() == std::io::ErrorKind::ConnectionRefused, + } +} + +/// The port a fresh install listens on: 3000 when it is free, else the first +/// free one above it. 3000 is also where most web dev servers run. +pub fn pick_install_port() -> u16 { + pick_port(port_free) +} + +fn pick_port(free: impl Fn(u16) -> bool) -> u16 { + (DEFAULT_PORT..DEFAULT_PORT + PORT_SEARCH) + .find(|p| free(*p)) + .unwrap_or(DEFAULT_PORT) +} + +/// `s` as one bash word. +pub fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// What the This Mac install asks `install.sh` for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalInstall { + pub port: u16, + /// Let the installer ask how other devices reach Codeman (Tailscale, the + /// local network with a password). Off: this Mac only (`--local`). + pub network: bool, +} + +/// `install.sh`'s flags for a This Mac install. Always `--service`: a +/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts +/// again at login, and the app finds it there (read_service). No `--yes`: the +/// user answers the installer's questions (Homebrew, AI coding tools). +pub fn local_install_args(opts: LocalInstall) -> Vec { + let mut args = Vec::new(); + if !opts.network { + args.push("--local".to_string()); + } + args.push("--service".to_string()); + args.push("--port".to_string()); + args.push(opts.port.to_string()); + args +} + +/// The Terminal script for a This Mac install. It downloads the installer to a +/// temporary file and runs it from there, so the installer's input is the +/// terminal itself. +pub fn local_install_script(opts: LocalInstall) -> String { + let args = local_install_args(opts); + let shown = format!("bash install.sh {}", args.join(" ")); + let quoted: Vec = args.iter().map(|a| sh_quote(a)).collect(); + format!( + r#"#!/bin/bash +rm -f -- "$0" +printf '\033]0;%s\007' 'Install Codeman' +cat <<'CODEMAN_MSG' +Installing Codeman on this Mac. + +The installer may ask for your Mac password (it installs Homebrew, Node.js and +tmux when they are missing) and which AI coding tools to set up. Codeman then +runs in the background and starts when you log in, so your sessions keep +running when the app is closed. The Codeman app opens it by itself when the +install is done. + +CODEMAN_MSG +tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1 +trap 'rm -f "$tmp"' EXIT +echo {from} +if ! curl -fsSL {url} -o "$tmp"; then + echo + echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.' + exit 1 +fi +echo {shown} +echo +bash "$tmp" {args} +status=$? +echo +if [ "$status" -eq 0 ]; then + echo 'Done. You can close this window.' +else + echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log" +fi +exit "$status" +"#, + from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")), + url = sh_quote(INSTALLER_URL), + shown = sh_quote(&format!("$ {shown}")), + args = quoted.join(" "), + ) +} + +/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a +/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshTarget { + pub user: Option, + pub host: String, + pub port: Option, +} + +const SSH_FORMAT: &str = + "Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config."; + +impl SshTarget { + /// Only the characters of a user and host name, never a leading `-` (ssh + /// would read it as an option) and never an IPv6 literal (ambiguous with + /// the port; a host name or an IPv4 address works). + pub fn parse(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() || s.len() > 300 { + return Err(SSH_FORMAT.into()); + } + let (user, rest) = match s.split_once('@') { + Some((u, r)) => (Some(u), r), + None => (None, s), + }; + let (host, port) = match rest.split_once(':') { + Some((h, p)) => { + let port = p.parse::().ok().filter(|p| *p > 0).ok_or_else(|| { + "The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string() + })?; + (h, Some(port)) + } + None => (rest, None), + }; + let name_ok = |v: &str, max: usize| { + !v.is_empty() + && v.len() <= max + && !v.starts_with(['-', '.']) + && v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + }; + if let Some(u) = user { + if !name_ok(u, 64) { + return Err(SSH_FORMAT.into()); + } + } + if !name_ok(host, 253) { + return Err(SSH_FORMAT.into()); + } + Ok(SshTarget { + user: user.map(str::to_string), + host: host.to_string(), + port, + }) + } + + /// `user@host`, or `host`. + pub fn destination(&self) -> String { + match &self.user { + Some(u) => format!("{u}@{}", self.host), + None => self.host.clone(), + } + } + + /// The address the connect screen suggests once the install is done: the + /// installer's default port on that host. Tailscale and a sub-path give + /// another address, which the installer prints at the end. + pub fn suggested_url(&self) -> String { + format!("http://{}:{DEFAULT_PORT}", self.host) + } +} + +/// What the remote login shell runs. It may be fish or tcsh, so the work is +/// one single-quoted `bash -c` word with nothing in it those shells treat +/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the +/// documented install and the installer asks its questions on ssh's terminal +/// (`-t`); `pipefail` makes a failed download fail the whole command, where a +/// plain pipe would report bash's success on an empty script. The installer +/// needs bash anyway. +pub fn remote_command() -> String { + format!( + "bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \ + elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \ + else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'", + u = INSTALLER_URL + ) +} + +/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the +/// installer's questions, the port, and `--` before the destination. +pub fn ssh_args(target: &SshTarget) -> Vec { + let mut args = vec!["-t".to_string()]; + if let Some(port) = target.port { + args.push("-p".into()); + args.push(port.to_string()); + } + args.push("--".into()); + args.push(target.destination()); + args.push(remote_command()); + args +} + +/// The Terminal script for an install on another machine over SSH. +pub fn remote_install_script(target: &SshTarget) -> String { + let args: Vec = ssh_args(target).iter().map(|a| sh_quote(a)).collect(); + let dest = target.destination(); + format!( + r#"#!/bin/bash +rm -f -- "$0" +printf '\033]0;%s\007' {title} +echo {what} +cat <<'CODEMAN_MSG' + +ssh may ask you to confirm the machine and for its password. Then the installer +asks how Codeman should be reached (Tailscale, the local network with a password, +or that machine only) and whether to run it in the background. At the end it +prints the address of Codeman there: connect to it in the Codeman app, under +Another machine. + +CODEMAN_MSG +ssh {args} +status=$? +echo +if [ "$status" -eq 0 ]; then + echo 'Done. You can close this window.' +else + echo "ssh or the installer stopped (exit $status)." +fi +exit "$status" +"#, + title = sh_quote(&format!("Install Codeman on {}", target.host)), + what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")), + args = args.join(" "), + ) +} + +static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Run `script` in a new Terminal window. The script file is private to this +/// user (the per-user temporary folder, mode 0700, created fresh) and deletes +/// itself as it starts. +#[cfg(target_os = "macos")] +pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> { + use std::os::unix::fs::OpenOptionsExt; + + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id())); + let written = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&path) + .and_then(|mut f| f.write_all(script.as_bytes())); + if let Err(e) = written { + let _ = std::fs::remove_file(&path); + return Err(format!("Could not write the install script: {e}")); + } + let out = Command::new("/usr/bin/open") + .args(["-b", "com.apple.Terminal"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + match out { + Ok(o) if o.status.success() => Ok(()), + Ok(o) => { + let _ = std::fs::remove_file(&path); + let said = String::from_utf8_lossy(&o.stderr).trim().to_string(); + Err(if said.is_empty() { + "Could not open Terminal.".into() + } else { + format!("Could not open Terminal: {said}") + }) + } + Err(e) => { + let _ = std::fs::remove_file(&path); + Err(format!("Could not open Terminal: {e}")) + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> { + let _ = &SCRIPT_COUNTER; + Err("Installing from the app is only available on macOS so far.".into()) +} + +/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure +/// context, so it cannot use the web clipboard API itself. +pub fn copy_to_clipboard(text: &str) -> Result<(), String> { + let mut child = Command::new("/usr/bin/pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Could not copy: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(text.as_bytes()) + .map_err(|e| format!("Could not copy: {e}"))?; + } + match child.wait() { + Ok(s) if s.success() => Ok(()), + _ => Err("Could not copy the command.".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn u(s: &str) -> Url { + Url::parse(s).unwrap() + } + + #[test] + fn install_sh_service_reads_from_its_environment() { + let job = json!({ + "Label": "com.codeman.web", + "ProgramArguments": ["/opt/homebrew/bin/node", "/Users/k/.codeman/app/dist/index.js", "web"], + "EnvironmentVariables": { + "PATH": "/opt/homebrew/bin:/usr/bin", + "CODEMAN_HOST": "0.0.0.0", + "CODEMAN_PASSWORD": "s3cret", + "CODEMAN_PORT": "3001", + "CODEMAN_BASE_URL": "/codeman/" + } + }); + let s = parse_service(&job); + assert_eq!(s.port, 3001); + assert_eq!(s.base_path, "/codeman"); + assert!(!s.https); + assert_eq!(s.password.as_deref(), Some("s3cret")); + assert_eq!(s.username, None); + assert_eq!(local_url(Some(&s)).as_str(), "http://127.0.0.1:3001/codeman/"); + } + + /// The real file shapes through the real plutil: install.sh's template + /// (an XML-escaped password, booleans and an integer plutil must carry + /// into JSON) and `codeman service install`'s. + #[cfg(target_os = "macos")] + #[test] + fn plutil_reads_both_launch_agent_shapes() { + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let install_sh = r#" + + + + Label + com.codeman.web + ProgramArguments + + /opt/homebrew/bin/node + /Users/k/.codeman/app/dist/index.js + web + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + HOME + /Users/k + LANG + en_US.UTF-8 + CODEMAN_HOST + 0.0.0.0 + CODEMAN_PASSWORD + p&w<"x' + CODEMAN_PORT + 3002 + + WorkingDirectory + /Users/k + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + /tmp/codeman.log + + +"#; + let a = dir.join("install-sh.plist"); + std::fs::write(&a, install_sh).unwrap(); + let s = read_service_file(&a).expect("plutil read the install.sh agent"); + assert_eq!(s.port, 3002); + assert_eq!(s.password.as_deref(), Some("p&w<\"x'")); + assert_eq!(local_url(Some(&s)).as_str(), "http://127.0.0.1:3002/"); + + let service = install_sh + .replace( + "web", + "web\n --host\n 127.0.0.1\n --port\n 4100\n --base-url\n /cm", + ) + .replace("CODEMAN_PASSWORD\n p&w<\"x'\n", ""); + let b = dir.join("service.plist"); + std::fs::write(&b, service).unwrap(); + let s = read_service_file(&b).expect("plutil read the service-install agent"); + assert_eq!( + (s.port, s.base_path.as_str(), s.password.as_deref()), + (4100, "/cm", None) + ); + assert!(read_service_file(&dir.join("missing.plist")).is_none()); + std::fs::write(dir.join("broken.plist"), "x").unwrap(); + assert!(read_service_file(&dir.join("broken.plist")).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn service_install_reads_its_arguments_and_flags_win() { + let job = json!({ + "ProgramArguments": [ + "/usr/local/bin/node", "--max-old-space-size=4096", "/opt/codeman/dist/index.js", + "web", "--host", "--port", "--port", "4100", "--https", "--base-url", "x//y/" + ], + "EnvironmentVariables": { "CODEMAN_PORT": "3005", "HOME": "/Users/k" } + }); + let s = parse_service(&job); + // --host takes "--port" as its value; the real --port follows it. + assert_eq!(s.port, 4100); + assert!(s.https); + assert_eq!(s.base_path, "/x/y"); + assert_eq!(s.password, None); + assert_eq!(local_url(Some(&s)).as_str(), "https://127.0.0.1:4100/x/y/"); + + let eq = parse_service(&json!({ "ProgramArguments": ["node", "i.js", "web", "--port=4200", "--base-url=/a"] })); + assert_eq!((eq.port, eq.base_path.as_str()), (4200, "/a")); + } + + #[test] + fn service_defaults_and_bad_values() { + let s = parse_service(&json!({})); + assert_eq!((s.port, s.base_path.as_str(), s.https), (DEFAULT_PORT, "", false)); + assert_eq!(local_url(None).as_str(), "http://127.0.0.1:3000/"); + let bad = parse_service(&json!({ + "EnvironmentVariables": { "CODEMAN_PORT": "0", "CODEMAN_BASE_URL": "/a b", "CODEMAN_PASSWORD": "" } + })); + assert_eq!((bad.port, bad.base_path.as_str()), (DEFAULT_PORT, "")); + assert_eq!(bad.password, None); + assert_eq!( + parse_service(&json!({"ProgramArguments": ["web", "--port", "99999"]})).port, + DEFAULT_PORT + ); + // Before `web` is node's business, not Codeman's. + assert_eq!( + parse_service(&json!({"ProgramArguments": ["--port", "1", "x.js", "web"]})).port, + DEFAULT_PORT + ); + } + + #[test] + fn base_path_matches_codeman() { + assert_eq!(normalize_base_path(""), ""); + assert_eq!(normalize_base_path("/"), ""); + assert_eq!(normalize_base_path("codeman"), "/codeman"); + assert_eq!(normalize_base_path("//codeman//"), "/codeman"); + assert_eq!(normalize_base_path("/a/b.c~_-/"), "/a/b.c~_-"); + assert_eq!(normalize_base_path("/a?x"), ""); + assert_eq!(normalize_base_path("/%2e"), ""); + } + + #[test] + fn env_file_like_codeman() { + let vars = parse_env_file( + "# comment\n\nexport CODEMAN_PASSWORD=\"pa ss\"\nCODEMAN_USERNAME='kay'\r\n\ + BAD-NAME=1\nexportX=2\nEMPTY=\nQ=\"\nnot an assignment\n", + ); + assert_eq!(vars.get("CODEMAN_PASSWORD").map(String::as_str), Some("pa ss")); + assert_eq!(vars.get("CODEMAN_USERNAME").map(String::as_str), Some("kay")); + assert_eq!(vars.get("exportX").map(String::as_str), Some("2")); + assert_eq!(vars.get("EMPTY").map(String::as_str), Some("")); + assert_eq!(vars.get("Q").map(String::as_str), Some("")); + assert!(!vars.contains_key("BAD-NAME")); + } + + fn service(port: u16, user: Option<&str>, pass: Option<&str>) -> ServiceConfig { + ServiceConfig { + port, + base_path: String::new(), + https: false, + username: user.map(Into::into), + password: pass.map(Into::into), + daemon: false, + } + } + + #[test] + fn local_login_only_for_the_local_codeman() { + let none = HashMap::new(); + let file: HashMap = [("CODEMAN_PASSWORD", "fromfile"), ("CODEMAN_USERNAME", "kay")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let svc = service(3001, None, Some("svc")); + + // The service's password, with the service's username or admin: never + // the file's username, which the server does not read. + assert_eq!( + login_for(&u("http://127.0.0.1:3001/"), Some(&svc), &file), + Some(("admin".into(), "svc".into())) + ); + assert_eq!( + login_for( + &u("http://127.0.0.1:3001/x/"), + Some(&service(3001, Some("ops"), Some("svc"))), + &file + ), + Some(("ops".into(), "svc".into())) + ); + // No password in the service: the file's pair. + assert_eq!( + login_for(&u("http://127.0.0.1:3001/"), Some(&service(3001, None, None)), &file), + Some(("kay".into(), "fromfile".into())) + ); + // Only 127.0.0.1 itself, on the service's port. + for other in [ + "http://localhost:3001/", + "http://127.0.0.2:3001/", + "http://[::1]:3001/", + "http://127.0.0.1:3000/", + "http://192.168.1.5:3001/", + "https://box.ts.net/", + ] { + assert_eq!(login_for(&u(other), Some(&svc), &file), None, "{other}"); + } + assert_eq!(login_for(&u("http://127.0.0.1:3000/"), None, &none), None); + // No service: Codeman's default port. + assert_eq!( + login_for(&u("http://127.0.0.1:3000/"), None, &file), + Some(("kay".into(), "fromfile".into())) + ); + } + + #[test] + fn processes_and_listeners() { + let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\ + 202 node /opt/homebrew/bin/codeman web --port 4000\n\ + 303 /usr/local/bin/node --require /x/tsx/preflight.cjs --import file:///x/loader.mjs src/index.ts web\n\ + 404 node /opt/homebrew/bin/codeman web --stop\n\ + 505 node /opt/homebrew/bin/codeman attach w1\n\ + 606 /bin/zsh -l\n\ + 707 vim dist/index.js\n\ + 808 /bin/bash /var/folders/x/T/install-codeman-local-55-0.command\n\ + 909 bash /var/folders/x/T/codeman-install.Ab12Cd --local --service --port 3001\n\ + 1001 /bin/bash /var/folders/x/T/install-codeman-remote-55-1.command\n\ + garbage line\n"; + let procs = parse_ps(ps); + assert_eq!( + procs[0], + ( + 101, + "/opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web".to_string() + ) + ); + assert_eq!(codeman_servers(&procs), [101, 202, 303]); + let installs: Vec> = procs.iter().filter_map(|(_, c)| install_in_progress(c)).collect(); + assert_eq!(installs, [None, Some(3001)]); + + let lsof = "p101\nf21\nn127.0.0.1:3000\nf22\nn172.17.0.1:3999\np202\nf9\nn*:4000\np303\nn[::1]:5000\nnbogus\n"; + let found = parse_lsof(lsof); + assert_eq!(found.len(), 4); + assert_eq!( + found[0], + Listener { + pid: 101, + host: "127.0.0.1".into(), + port: 3000 + } + ); + assert_eq!( + found[2], + Listener { + pid: 202, + host: "*".into(), + port: 4000 + } + ); + assert_eq!( + found[3], + Listener { + pid: 303, + host: "[::1]".into(), + port: 5000 + } + ); + assert!(serves_loopback_port(&found, 3000)); + assert!(serves_loopback_port(&found, 4000)); + assert!(!serves_loopback_port(&found, 3999)); + assert!(!serves_loopback_port(&found, 5000)); + assert_eq!(listener_url(&found[1]).unwrap().as_str(), "http://172.17.0.1:3999/"); + assert_eq!(listener_url(&found[2]).unwrap().as_str(), "http://127.0.0.1:4000/"); + assert_eq!(listener_url(&found[3]).unwrap().as_str(), "http://[::1]:5000/"); + } + + #[test] + fn install_port_skips_taken_ones() { + assert_eq!(pick_port(|_| true), 3000); + assert_eq!(pick_port(|p| p >= 3002), 3002); + assert_eq!(pick_port(|_| false), 3000); + } + + #[test] + fn quoting() { + assert_eq!(sh_quote("plain"), "'plain'"); + assert_eq!(sh_quote("it's"), r"'it'\''s'"); + assert_eq!(sh_quote(""), "''"); + } + + #[test] + fn local_install_flags() { + assert_eq!( + local_install_args(LocalInstall { + port: 3000, + network: false + }), + ["--local", "--service", "--port", "3000"] + ); + assert_eq!( + local_install_args(LocalInstall { + port: 3007, + network: true + }), + ["--service", "--port", "3007"] + ); + } + + /// Run a script under bash with `curl` and `bash`... replaced, and report + /// what it did. The real installer never runs in a test. + #[cfg(unix)] + fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) { + use std::os::unix::fs::PermissionsExt; + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + for (name, body) in fakes { + let p = dir.join("bin").join(name); + std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = dir.join("run.command"); + std::fs::write(&file, script).unwrap(); + let out = Command::new("/bin/bash") + .arg(&file) + .env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display())) + .env("TMPDIR", &dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default(); + let self_deleted = !file.exists(); + let _ = std::fs::remove_dir_all(&dir); + (log, out.status.code().unwrap_or(-1), self_deleted) + } + + #[cfg(unix)] + #[test] + fn local_script_downloads_then_runs_the_installer_with_its_flags() { + let script = local_install_script(LocalInstall { + port: 3001, + network: false, + }); + let (log, code, self_deleted) = run_script( + &script, + &[( + "curl", + r#"#!/bin/sh +echo "curl $*" >> DIR/log +out="" +while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done +cat > "$out" <<'INSTALLER' +for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done +echo >> DIR/log +exit 7 +INSTALLER +"#, + )], + ); + assert!(self_deleted, "the script must delete itself"); + assert!( + log.starts_with("curl -fsSL https://getcodeman.com/install -o "), + "{log}" + ); + assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}"); + assert_eq!(code, 7, "the installer's exit status is the script's"); + } + + #[cfg(unix)] + #[test] + fn local_script_stops_when_the_download_fails() { + let script = local_install_script(LocalInstall { + port: 3000, + network: true, + }); + let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]); + assert_eq!(log, "curl\n"); + assert_eq!(code, 1); + } + + #[test] + fn ssh_targets() { + assert_eq!( + SshTarget::parse(" kay@build-box ").unwrap(), + SshTarget { + user: Some("kay".into()), + host: "build-box".into(), + port: None + } + ); + assert_eq!( + SshTarget::parse("pi@192.168.1.20:2222").unwrap(), + SshTarget { + user: Some("pi".into()), + host: "192.168.1.20".into(), + port: Some(2222) + } + ); + assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini"); + assert_eq!( + SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(), + "a.b@tnode.tailnet.ts.net" + ); + for bad in [ + "", + "-oProxyCommand=touch /tmp/x", + "kay@-oProxyCommand=x", + "-l@host", + "kay@host name", + "kay@host;rm", + "kay@host$(x)", + "k'y@host", + "kay@", + "@host", + "kay@host:", + "kay@host:0", + "kay@host:x", + "kay@fe80::1", + "kay@[::1]", + "a@b@c", + ".hidden", + ] { + assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}"); + } + assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000"); + } + + #[test] + fn ssh_arguments() { + let t = SshTarget::parse("kay@box:2222").unwrap(); + let args = ssh_args(&t); + assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]); + assert_eq!(args.len(), 6); + let remote = &args[5]; + let head = "bash -o pipefail -c '"; + assert!(remote.starts_with(head) && remote.ends_with('\'')); + // One word for fish and tcsh: no quote inside, nothing they expand. + let inner = &remote[head.len()..remote.len() - 1]; + assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}"); + assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash")); + assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]); + } + + #[cfg(unix)] + #[test] + fn remote_script_runs_ssh_with_exactly_those_arguments() { + let t = SshTarget::parse("kay@box:2222").unwrap(); + let (log, code, self_deleted) = run_script( + &remote_install_script(&t), + &[( + "ssh", + "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n", + )], + ); + assert!(self_deleted); + let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect(); + assert_eq!(log, format!("{expected}\n")); + assert_eq!(code, 3); + } + + /// The remote command does what it says under a POSIX shell: curl first, + /// wget without curl, and a clear error with neither. + #[cfg(unix)] + #[test] + fn remote_command_picks_a_downloader() { + use std::os::unix::fs::PermissionsExt; + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + let exe = |name: &str, body: &str| { + let p = dir.join("bin").join(name); + std::fs::write(&p, body).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + // The outer `bash -o pipefail` is the real one; the `bash` the + // download is piped into only reports what it was fed. + exe( + "bash", + "#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n", + ); + exe("wget", "#!/bin/sh\necho \"wget $*\"\n"); + let run = || { + let out = Command::new("/bin/sh") + .arg("-c") + .arg(remote_command()) + .env("PATH", dir.join("bin")) + .output() + .unwrap(); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code(), + ) + }; + let (out, _, _) = run(); + assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n"); + exe("curl", "#!/bin/sh\necho \"curl $*\"\n"); + let (out, _, _) = run(); + assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n"); + // A failed download fails the command (pipefail), not "Done". + exe( + "curl", + "#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n", + ); + let (_, _, code) = run(); + assert_ne!(code, Some(0)); + std::fs::remove_file(dir.join("bin/curl")).unwrap(); + std::fs::remove_file(dir.join("bin/wget")).unwrap(); + let (out, err, code) = run(); + assert_eq!(out, ""); + assert!(err.contains("needs curl or wget"), "{err}"); + assert_eq!(code, Some(1)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 9aa28a44..b275b533 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -983,6 +983,14 @@ 'Could not open a new window for this session': '无法在新窗口中打开此会话', 'Could not open a new window for this preview': '无法在新窗口中打开此预览', 'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签', + // The Codeman desktop app's shim when the app would not open the window a + // page asked for (packages/desktop/src/assets.rs). + 'Could not open a new window': '无法打开新窗口', + // A subagent transcript or a detached preview whose window.open came back + // null (panels-ui.js viewSubagentTranscript / detachFilePreview). + 'Could not open the transcript window: pop-up windows are blocked here': '无法打开对话记录窗口:此处已阻止弹出窗口', + 'Pop-up blocked: allow pop-ups for this site to detach previews': + '弹出窗口已被阻止:请允许此网站的弹出窗口以分离预览', // Dictation whose session closed before the text was sent (voice-input.js). 'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送', // Terminal touch-selection bar (long-press to select). The bar is a sibling of @@ -1457,6 +1465,10 @@ [/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`], // A pick, drop or paste that mixed admitted files with refused ones (image-input.js). [/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`], + // The Codeman desktop app's download toasts (packages/desktop/src/assets.rs). + // The file name is user text: it passes through untranslated. + [/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`], + [/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], // Session names are user text too. diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index e46baa4a..5dd80f38 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -1963,7 +1963,16 @@ Object.assign(CodemanApp.prototype, { // Show in a modal or new window const content = data.data.formatted.join('\n'); - const win = window.open('', '_blank', 'width=800,height=600'); + // This open runs after the fetch above, so a pop-up blocker that wants a + // click can refuse it, and the Codeman desktop app always refuses an + // empty window. Both return null; say so instead of throwing into the + // catch below, whose 'Failed to load transcript' would be wrong. + let win = null; + try { win = window.open('', '_blank', 'width=800,height=600'); } catch {} + if (!win) { + this.showToast('Could not open the transcript window: pop-up windows are blocked here', 'error'); + return; + } win.document.write(` diff --git a/test/desktop-toast-i18n.test.ts b/test/desktop-toast-i18n.test.ts new file mode 100644 index 00000000..63d7cd33 --- /dev/null +++ b/test/desktop-toast-i18n.test.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview zh-CN coverage for the toasts shown when a window could not be + * opened, and for the Codeman desktop app's own toasts. + * + * The literals are read from their sources: the transcript and detached + * preview toasts from panels-ui.js, and the desktop shim's toasts from + * packages/desktop/src/assets.rs (a Rust file whose strings reach Codeman's + * `showToast`), so a reworded toast on either side without an entry fails + * here. The download toasts carry a file name and go through the pattern + * rule, which must keep the name exactly as written. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8'); +const I18N = read('i18n.js'); +const PANELS_UI = read('panels-ui.js'); +const ASSETS_RS = readFileSync(resolve(import.meta.dirname, '../packages/desktop/src/assets.rs'), 'utf8'); + +type Api = { t(s: string): string; configure(o: Record): void }; +function translator(language: string) { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.configure({ language }); + return { api, dom }; +} +const zh = translator('zh-CN'); +const en = translator('en'); +afterAll(() => { + zh.dom.window.close(); + en.dom.window.close(); +}); + +const panelToasts = [ + ...PANELS_UI.matchAll(/this\.showToast\('(Could not open the transcript window[^']*|Pop-up blocked[^']*)'/g), +].map((m) => m[1]); +// The shim's JavaScript may quote a literal either way. +const shimToasts = [...ASSETS_RS.matchAll(/showToast\((['"])([^'"]+)\1/g)].map((m) => m[2]); +const downloadTemplates = [...ASSETS_RS.matchAll(/format!\("([^"]*\{file_name\}[^"]*)"\)/g)].map((m) => m[1]); + +// File names with spaces, dots, brackets and English words, one of them a +// dictionary word on its own ("Settings saved"), which must not be translated. +const FILE_NAMES = ['report v2.pdf', 'Settings saved', 'a (1).tar.gz', '数据.csv']; + +describe('the window and desktop toasts in zh-CN', () => { + it('finds the strings it checks (the check is not vacuous)', () => { + expect(new Set(panelToasts)).toEqual( + new Set([ + 'Could not open the transcript window: pop-up windows are blocked here', + 'Pop-up blocked: allow pop-ups for this site to detach previews', + ]) + ); + expect(new Set(shimToasts)).toEqual(new Set(['Could not open a new window'])); + expect(new Set(downloadTemplates)).toEqual( + new Set(['Downloaded {file_name} to Downloads', 'Download of {file_name} failed']) + ); + }); + + it('every literal toast translates, and reads the same in English', () => { + const bad = [...panelToasts, ...shimToasts].filter((l) => { + const text = zh.api.t(l); + return text === l || /[A-Za-z]/.test(text) || en.api.t(l) !== l; + }); + expect(bad).toEqual([]); + }); + + it('the download toasts translate around the file name, which stays as written', () => { + for (const template of downloadTemplates) { + for (const name of FILE_NAMES) { + const source = template.replace('{file_name}', name); + const text = zh.api.t(source); + expect(text, source).not.toBe(source); + expect(text.split(name), source).toHaveLength(2); + expect(text.replace(name, ''), source).not.toMatch(/[A-Za-z]/); + expect(en.api.t(source)).toBe(source); + } + } + expect(zh.api.t('Downloaded report v2.pdf to Downloads')).toBe('已将 report v2.pdf 下载到“下载”文件夹'); + expect(zh.api.t('Download of report v2.pdf failed')).toBe('下载 report v2.pdf 失败'); + }); +}); From 01d3379b74535bd966526881745eb2e3d278b9cd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:20:01 +0100 Subject: [PATCH 2/5] fix(desktop): recover a crashed page, name every dialog's frame, and four smaller review fixes The findings of the 2026-11-05 review of this branch, each with a unit test where the code can be tested without a window: - A window whose web content process ended (a WebKit crash, or macOS taking the process back under memory pressure) stayed blank: wry answers WebKit's notice itself, which turns WebKit's own reload off. The window now loads its page again. A second end within 30 seconds sends the main window back to the connect page and closes a pop-out, so it cannot loop (policy::recovery). - A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or blob: URL under an opaque origin) showed the page's text as its bold first line. The first line is now always the app's own, the "embedded" flag is set when the origin names no host (it was the wrong way round), and every sub-frame counts as embedded, a trusted web tab on Codeman's origin included. - The connect page lost the text of an error that needs the login fields: the tab switch cleared the status it had just set. - Start for http://localhost:PORT missed the LaunchAgent (the compare was exact against 127.0.0.1) and ran a second, unsupervised server on its port. - The path picker's preview Open link (api/filesystem/preview) did nothing: it was the one page Codeman opens in a window that the allowlist lacked. - The port of a running install was lost whenever the Terminal script that wraps the installer came first in ps. Co-Authored-By: Claude Fable 5.1 --- packages/desktop/README.md | 7 +-- packages/desktop/assets/connect.html | 3 +- packages/desktop/src/macos.rs | 54 +++++++++++---------- packages/desktop/src/main.rs | 67 +++++++++++++++++++++++++- packages/desktop/src/policy.rs | 70 ++++++++++++++++++++++++++-- packages/desktop/src/setup.rs | 49 ++++++++++++++++++- 6 files changed, 214 insertions(+), 36 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 75eaf4fc..de628b92 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -33,12 +33,13 @@ cannot do. | **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | | **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | | **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | -| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. | +| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. | | **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. | | **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. | | **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. | | **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). | | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | +| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | | **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. | ## Build (on a Mac) @@ -95,8 +96,8 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency. - An app window opens only for Codeman's own pop-out pages under the server's base path, checked after decoding the path once, and which ones depends on who asks. `window.CodemanHost` (the app shell only) may open sessions, - rendered HTML views, the raw, preview, attachment and folder file routes, and - `api/download`. A `window.open` or `target=_blank` link, which WebKit passes + rendered HTML views, the raw, preview, attachment and folder file routes, the + path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes on from any frame without saying which, gets only the file routes and `api/download`, the pages Codeman itself opens that way; otherwise a rendered HTML file could open copies of itself as top-level windows that pass for the diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index 972c7e2f..a950f178 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -798,11 +798,12 @@ break; case 'error': setBusy(false); - setStatus('bad', msg.message || 'Something went wrong.'); + // The tab first: switching it clears the status line. if (msg.needsPassword) { selectTab('remote'); showLogin(); } + setStatus('bad', msg.message || 'Something went wrong.'); break; case 'serverStarting': setBusy(true, false); diff --git a/packages/desktop/src/macos.rs b/packages/desktop/src/macos.rs index 5f339ab9..a26e4940 100644 --- a/packages/desktop/src/macos.rs +++ b/packages/desktop/src/macos.rs @@ -161,14 +161,17 @@ fn origin_label(host: &str, port: i64) -> Option { } /// The bold first line of a dialog, like Safari's, so a frame cannot pass its -/// text off as the app's own. `embedded` is a sandboxed frame (an HTML file -/// preview, an untrusted web tab): its origin is opaque, so `origin` is only -/// the server it was loaded from, and it must not read as Codeman speaking. -fn dialog_title(origin: &str, embedded: bool) -> String { - if embedded { - format!("An embedded page on {origin} says:") - } else { - format!("The page at {origin} says:") +/// text off as the app's own: the page's text always goes below it. `frame` is +/// the host that names the frame and whether it is embedded, which a sandboxed +/// frame is (an HTML file preview, an untrusted web tab): its origin is opaque, +/// so the host is only the server it was loaded from, and it must not read as +/// Codeman speaking. `None` is a frame nothing names: an opaque origin whose +/// document has no host either (`about:srcdoc`, a `data:` or `blob:` URL). +fn dialog_title(frame: Option<(&str, bool)>) -> String { + match frame { + Some((origin, false)) => format!("The page at {origin} says:"), + Some((origin, true)) => format!("An embedded page on {origin} says:"), + None => "An embedded page says:".to_string(), } } @@ -574,13 +577,11 @@ mod imp { Some(message) => clipped(message), None => NSString::from_str(""), }; - match frame.and_then(frame_origin) { - Some((origin, embedded)) => { - alert.setMessageText(&NSString::from_str(&dialog_title(&origin, embedded))); - alert.setInformativeText(&body); - } - None => alert.setMessageText(&body), - } + // The bold line is always the app's own; the page's text goes below. + let named = frame.and_then(frame_origin); + let title = dialog_title(named.as_ref().map(|(origin, embedded)| (origin.as_str(), *embedded))); + alert.setMessageText(&NSString::from_str(&title)); + alert.setInformativeText(&body); let _ = alert.addButtonWithTitle(&NSString::from_str("OK")); if panel != Panel::Alert { let _ = alert.addButtonWithTitle(&NSString::from_str("Cancel")); @@ -623,16 +624,18 @@ mod imp { } /// The host (and port) of the frame that opened a dialog: its security - /// origin, else its request URL's host. The flag is set when the origin is - /// opaque (a sandboxed frame), where only the URL names a host. + /// origin, else its request URL's host. The flag says "embedded". It is + /// set for every sub-frame, whatever its origin (a trusted web tab is + /// proxied under Codeman's own origin and would read as Codeman + /// otherwise), and whenever the origin names no host, which is an opaque + /// origin (a sandboxed frame) or no origin at all: a frame the app is + /// unsure about never speaks as the page. fn frame_origin(frame: &WKFrameInfo) -> Option<(String, bool)> { autoreleasepool(|_| { // SAFETY (every read here): property reads on objects WebKit handed // us, with nil handled by the `Option` return types. + let sub_frame = !unsafe { frame.isMainFrame() }; let origin: Option> = unsafe { msg_send![frame, securityOrigin] }; - // An origin without a host is opaque; when unsure, say "embedded" - // rather than let a frame speak as the page. - let opaque = origin.is_some(); if let Some(origin) = origin { let host: Option> = unsafe { msg_send![&*origin, host] }; let port: NSInteger = unsafe { msg_send![&*origin, port] }; @@ -641,7 +644,7 @@ mod imp { .and_then(rust_string) .and_then(|h| origin_label(&h, port as i64)) { - return Some((label, false)); + return Some((label, sub_frame)); } } let request: Option> = unsafe { msg_send![frame, request] }; @@ -650,7 +653,7 @@ mod imp { let url = url?; let host: Option> = unsafe { msg_send![&*url, host] }; let host = host?; - Some((origin_label(&rust_string(&host)?, 0)?, opaque)) + Some((origin_label(&rust_string(&host)?, 0)?, true)) }) } @@ -1042,12 +1045,15 @@ mod tests { assert_eq!(origin_label("::1", 3000).as_deref(), Some("[::1]:3000")); assert_eq!(origin_label(" ", 3000), None); assert_eq!( - dialog_title("localhost:3000", false), + dialog_title(Some(("localhost:3000", false))), "The page at localhost:3000 says:" ); assert_eq!( - dialog_title("localhost:3000", true), + dialog_title(Some(("localhost:3000", true))), "An embedded page on localhost:3000 says:" ); + // A frame nothing names (about:srcdoc under an opaque origin) still + // gets a first line of the app's own, never its own text in bold. + assert_eq!(dialog_title(None), "An embedded page says:"); } } diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 0025f47a..12e4ae6a 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -51,7 +51,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView use config::{Config, Rect, WindowFrame}; use credentials::Keychain; -use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Via}; +use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; use probe::ProbeOutcome; const APP_NAME: &str = "Codeman"; @@ -68,6 +68,10 @@ const LOAD_TIMEOUT: Duration = Duration::from_secs(15); /// page reported that its session is gone, so a login the server keeps /// refusing can never turn into a reload loop. const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60); +/// A window loads its page again at most once in this long after its web +/// content process ended, so a page that cannot stay up never becomes a reload +/// loop. +const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30); /// When the app first looks whether the main window made it into the full /// screen it asked for at launch, how often it asks again, and how many times. /// tao retries a failed entry only for a window BUILT in full screen, which @@ -88,6 +92,7 @@ const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter th const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \ enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes."; const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again."; +const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again."; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; mod menu_id { @@ -157,6 +162,12 @@ enum UserEvent { /// The challenge handler sent its login and got challenged again (macOS). /// Not trusted on its own: recheck_login asks the server first. AuthRejected, + /// The web content process of a window ended (macOS): a crash, or the + /// system taking it back under memory pressure. The window is blank until + /// something loads in it again (on_content_process_gone). + ContentProcessGone { + window: WindowId, + }, /// A background step for the connect page finished (This Mac status, an /// install opened in Terminal, the install command copied): the message /// goes to the page if it is still the one choosing a server. @@ -211,6 +222,8 @@ struct AppWindow { committed: Option, /// When the window last reloaded itself because its session expired. session_reload_at: Option, + /// When the window's web content process last ended. + content_process_gone_at: Option, } struct AppMenu { @@ -324,6 +337,7 @@ fn main() { kind: Kind::Main, committed: None, session_reload_at: None, + content_process_gone_at: None, }, ); @@ -714,6 +728,17 @@ fn build_webview( }); }); + // wry answers WebKit's "web content process ended" notice itself, which + // turns WebKit's own reload off: without a handler the window stays blank. + #[cfg(target_os = "macos")] + { + use wry::WebViewBuilderExtDarwin; + let gone_proxy = proxy.clone(); + builder = builder.with_on_web_content_process_terminate_handler(move || { + let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id }); + }); + } + builder = match load { Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE), Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)), @@ -973,6 +998,45 @@ impl App { } } UserEvent::AuthRejected => self.recheck_login(), + UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window), + } + } + + /// The web content process of `window` ended, so the window is blank + /// (policy::recovery decides what it does about it). A second end within + /// CRASH_RELOAD_COOLDOWN counts as the same page failing again. + fn on_content_process_gone(&mut self, window: WindowId) { + let now = Instant::now(); + let Some(w) = self.windows.get_mut(&window) else { + return; + }; + // A pop-out hidden for its download shows no page, and the download + // does not run in that process: loading again would fetch it twice. + if matches!( + w.kind, + Kind::Popout { + hidden_for_download: true, + .. + } + ) { + return; + } + let again = w + .content_process_gone_at + .is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN); + w.content_process_gone_at = Some(now); + let on_server = self.phase.borrow().loaded().is_some(); + match policy::recovery(window == self.main_id, on_server, again) { + Recovery::Reload => { + if let Some((url, with_auth)) = self.reload_target(window) { + self.load_in(window, &url, with_auth); + } + } + Recovery::ConnectPage { gave_up } => { + self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false); + } + Recovery::Close => self.remove_window(window), + Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"), } } @@ -1610,6 +1674,7 @@ impl App { }, committed: None, session_reload_at: None, + content_process_gone_at: None, }, ); true diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 7680cc33..a1fbb194 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -186,8 +186,9 @@ pub fn trusted_frame(frame: &Url, server: &Url) -> bool { /// (`session/`), a rendered HTML file (`html-view/...`, served with a /// sandbox CSP), a detached file preview or attachment (`api/sessions//` /// `file-raw`, `file-preview`, `attachments//raw|preview`, -/// `folders//files/...`) and a download (`api/download`). A script's -/// `window.open` gets only the last two kinds (new_window, Via::Script). +/// `folders//files/...`, and the path picker's `api/filesystem/preview`) +/// and a download (`api/download`). A script's `window.open` gets only the +/// last two kinds (new_window, Via::Script). /// /// Everything else on the origin is refused, a proxied dashboard under /// `webview/` above all. A browser keeps the opener frame's sandbox on a popup; @@ -222,8 +223,8 @@ enum Popout { Session(String), /// `html-view/...`: a rendered HTML file. HtmlView, - /// A detached file preview, an attachment, a file in a granted folder, or - /// a download (`api/download`). + /// A detached file preview, an attachment, a file in a granted folder, + /// the path picker's preview, or a download (`api/download`). File, } @@ -245,7 +246,7 @@ fn popout_kind(url: &Url, server: &Url) -> Option { } match segments.as_slice() { ["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())), - ["api", "download"] => Some(Popout::File), + ["api", "download"] | ["api", "filesystem", "preview"] => Some(Popout::File), ["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File), ["api", "sessions", id, "attachments", attachment, "raw" | "preview"] if !id.is_empty() && !attachment.is_empty() => @@ -414,6 +415,38 @@ impl Default for OpenBudget { } } +/// What a window does once its web content process has ended: a crash, or the +/// system taking the process back under memory pressure. The window is blank +/// from then on, since nothing reloads it by itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Recovery { + /// Load the window's page again. + Reload, + /// Show the connect page: with the reason, when a server page was given up. + ConnectPage { gave_up: bool }, + /// Close the pop-out. + Close, + /// Leave the window as it is. + Nothing, +} + +/// How a window recovers from the end of its web content process. `on_server`: +/// a server page is the main window's document (pop-outs only exist then). +/// `again`: the same window lost its process a moment ago already, so its page +/// cannot stay up and loading it once more would only repeat that. +pub fn recovery(is_main: bool, on_server: bool, again: bool) -> Recovery { + match (is_main, on_server, again) { + (_, true, false) => Recovery::Reload, + (true, true, true) => Recovery::ConnectPage { gave_up: true }, + // The connect page itself, or a server load that had not committed. + (true, false, false) => Recovery::ConnectPage { gave_up: false }, + (true, false, true) => Recovery::Nothing, + (false, _, true) => Recovery::Close, + // A pop-out with no server page behind it is on its way out anyway. + (false, false, false) => Recovery::Nothing, + } +} + #[derive(Debug, PartialEq, Eq)] pub enum Navigation { Allow, @@ -930,6 +963,7 @@ mod tests { "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw", "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview", "http://127.0.0.1:3000/api/download?path=x", + "http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md", ] { assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}"); assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}"); @@ -1105,6 +1139,10 @@ mod tests { "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview" )); assert!(ok("http://127.0.0.1:3000/api/download?path=x")); + // The path picker's preview, which its Open link shows in a window. + assert!(ok( + "http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md&sessionId=s1" + )); // A single decode is how the server's router sees the path. assert!(ok("http://127.0.0.1:3000/%73ession/abc")); @@ -1138,6 +1176,10 @@ mod tests { assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete")); assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/")); assert!(!ok("http://127.0.0.1:3000/api/download/x")); + // The picker's other routes list and create folders: never a window. + assert!(!ok("http://127.0.0.1:3000/api/filesystem/browse?path=%2F")); + assert!(!ok("http://127.0.0.1:3000/api/filesystem/mkdir")); + assert!(!ok("http://127.0.0.1:3000/api/filesystem/preview/x")); assert!(!ok("http://127.0.0.1:3000/api/status")); // Other origins and userinfo. assert!(!ok("http://127.0.0.1:3001/session/abc")); @@ -1274,6 +1316,24 @@ mod tests { assert!(!b.allow(later)); } + #[test] + fn a_window_recovers_from_a_lost_content_process_once() { + // The first time, every window that shows a server page loads it again. + assert_eq!(recovery(true, true, false), Recovery::Reload); + assert_eq!(recovery(false, true, false), Recovery::Reload); + // The connect page is shown anew, which also ends a load under way. + assert_eq!(recovery(true, false, false), Recovery::ConnectPage { gave_up: false }); + // A second time in a row never reloads: that would loop. + assert_eq!(recovery(true, true, true), Recovery::ConnectPage { gave_up: true }); + assert_eq!(recovery(false, true, true), Recovery::Close); + assert_eq!(recovery(true, false, true), Recovery::Nothing); + for is_main in [true, false] { + for on_server in [true, false] { + assert_ne!(recovery(is_main, on_server, true), Recovery::Reload); + } + } + } + #[test] fn basic_auth() { assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0"); diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs index 2efa931f..c3719b93 100644 --- a/packages/desktop/src/setup.rs +++ b/packages/desktop/src/setup.rs @@ -359,6 +359,16 @@ fn codeman_servers(procs: &[(u32, String)]) -> Vec { .collect() } +/// The install the app started on this Mac, if one runs, with the port it +/// asked for as soon as one of its processes names it. The Terminal script +/// that wraps the installer names none and is the older process, so the first +/// match alone would report no port for the whole install. +fn running_install(procs: &[(u32, String)]) -> Option> { + let mut installs = procs.iter().filter_map(|(_, c)| install_in_progress(c)).peekable(); + installs.peek()?; + Some(installs.flatten().next()) +} + /// A listening TCP socket: its pid, address (`*`, `127.0.0.1`, `[::1]`, a /// specific IP) and port. #[derive(Debug, Clone, PartialEq, Eq)] @@ -494,7 +504,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { let procs = list_processes(); let servers = codeman_servers(&procs); let found = listeners(&servers); - let install = procs.iter().find_map(|(_, c)| install_in_progress(c)); + let install = running_install(&procs); // The login goes only to this user's own Codeman on that port. let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) { @@ -612,6 +622,15 @@ fn already_running() -> Option { )) } +/// Whether `url` is where the launchd job `service` answers: its scheme and +/// port on this Mac, under `localhost` as much as 127.0.0.1. The path is left +/// out: a job with a base path answers below `/` too, and a second server on +/// its port would run without the job's settings. +fn served_by(service: &ServiceConfig, url: &Url) -> bool { + let at = local_url(Some(service)); + policy::is_loopback(url) && url.scheme() == at.scheme() && url.port_or_known_default() == at.port_or_known_default() +} + /// Start the Codeman on this Mac for `url`. A LaunchAgent that serves that /// address is started through launchd, so it runs with its own settings /// (host, password) and stays supervised; anything else gets `codeman web -d` @@ -621,7 +640,7 @@ pub fn start_local(url: &Url) -> Result { return Err(why); } match read_service() { - Some(s) if local_url(Some(&s)) == *url => { + Some(s) if served_by(&s, url) => { if s.daemon { return Err(format!( "A system LaunchDaemon runs Codeman on this Mac; an administrator starts it: sudo launchctl kickstart -k system/{SERVICE_LABEL}" @@ -1218,6 +1237,27 @@ mod tests { ); } + #[test] + fn a_start_goes_to_the_launchd_job_that_serves_the_address() { + let job = parse_service(&json!({ + "ProgramArguments": ["node", "i.js", "web"], + "EnvironmentVariables": { "CODEMAN_PORT": "3001", "CODEMAN_BASE_URL": "/codeman" } + })); + // The app's own address for it, and what a user types for the same server. + assert!(served_by(&job, &u("http://127.0.0.1:3001/codeman/"))); + assert!(served_by(&job, &u("http://localhost:3001/codeman/"))); + assert!(served_by(&job, &u("http://LOCALHOST:3001/"))); + // Another port, scheme or machine is not this job's server. + assert!(!served_by(&job, &u("http://127.0.0.1:3000/codeman/"))); + assert!(!served_by(&job, &u("https://127.0.0.1:3001/codeman/"))); + assert!(!served_by(&job, &u("http://192.168.1.5:3001/codeman/"))); + assert!(!served_by(&job, &u("http://localhost.example.com:3001/"))); + + let https = parse_service(&json!({ "ProgramArguments": ["node", "i.js", "web", "--https"] })); + assert!(served_by(&https, &u("https://localhost:3000/"))); + assert!(!served_by(&https, &u("http://localhost:3000/"))); + } + #[test] fn processes_and_listeners() { let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\ @@ -1242,6 +1282,11 @@ mod tests { assert_eq!(codeman_servers(&procs), [101, 202, 303]); let installs: Vec> = procs.iter().filter_map(|(_, c)| install_in_progress(c)).collect(); assert_eq!(installs, [None, Some(3001)]); + // The Terminal script comes first and names no port: the installer's wins. + assert_eq!(running_install(&procs), Some(Some(3001))); + // Before the installer is downloaded, only the script runs. + assert_eq!(running_install(&procs[..8]), Some(None)); + assert_eq!(running_install(&procs[..7]), None); let lsof = "p101\nf21\nn127.0.0.1:3000\nf22\nn172.17.0.1:3999\np202\nf9\nn*:4000\np303\nn[::1]:5000\nnbogus\n"; let found = parse_lsof(lsof); From 116db3ba66a665ce3dd9a24c9d1daf13a066b22b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:26:57 +0100 Subject: [PATCH 3/5] fix(desktop): a connect page that holds still, answers a press and never strands the user Layout: - The header no longer moves. The page was centred with align-items, so every change of the card's height moved everything (up to 150px between states), and a page taller than its window lost its top above the scroll origin, where no scrolling reaches it. The top margin is now fixed, the card grows downward and has a minimum height, and the page scrolls from its top. A short window gets a compact header. - The status line sits right under the action it reports on, above the "not installed there yet?" section, takes no room while empty, and is scrolled into view in a small window. It was below the fold there. - Looking at this Mac again keeps what was found on screen (the pane used to collapse to "Looking..." and grow back). Feedback and keys: - Buttons answer hover and press, every control shows keyboard focus, the primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps its spinner while the server page loads. - Return opens or starts the Codeman on this Mac, Esc cancels a connect. Install always takes a click. - The This Mac result is a live region, Forget buttons are named after their server, a long unbroken token wraps inside the card, the status text can be selected. Correctness: - The second click of a double click acts only on the control that took the first. It could connect to the server that slid under the pointer after a Forget, or flip "Connect automatically". - An address, username or password too long for the app is refused with a reason. The app drops such a message without an answer, which left the page on "Connecting..." (the limits are pinned to policy.rs by a test). - A connect answer goes to the tab that asked, a failed SSH install to its tab, and the login block no longer claims a saved password for another server. - An automatic connect to the Codeman on this Mac no longer looks at this Mac first; a failed connect, Cancel or an error asks for that look. - A login over plain http:// to another machine says that it is unencrypted. Co-Authored-By: Claude Fable 5.1 --- packages/desktop/README.md | 2 +- packages/desktop/assets/connect.html | 197 ++++++++++++++++++++------- packages/desktop/src/assets.rs | 13 ++ packages/desktop/src/policy.rs | 4 +- 4 files changed, 163 insertions(+), 53 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index de628b92..ece14eda 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -30,7 +30,7 @@ cannot do. | --- | --- | | **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | | **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. | -| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | +| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | | **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | | **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | | **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. | diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index a950f178..d84f3fda 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -20,20 +20,35 @@ --ok: #4ade80; } * { box-sizing: border-box; } - html, body { height: 100%; margin: 0; } + html { height: 100%; } body { + min-height: 100%; margin: 0; background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; - display: flex; align-items: center; justify-content: center; + display: flex; flex-direction: column; align-items: center; -webkit-user-select: none; user-select: none; } - main { width: min(440px, calc(100vw - 48px)); } + /* The top margin centres the tallest everyday page (a saved server with its + login, 708px), so the header stays where it is while the card changes: a + taller card grows downward, and a window too small for the page scrolls it + from the top (centring with align-items would cut the top off, out of + reach). */ + main { width: min(440px, calc(100vw - 48px)); margin: max(20px, calc((100vh - 708px) / 2)) 0 24px; } header { text-align: center; margin-bottom: 26px; } header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; } h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; } header p { margin: 4px 0 0; color: var(--muted); } + @media (max-height: 640px) { + header { margin-bottom: 16px; } + header img { width: 48px; height: 48px; margin-bottom: 8px; } + header p { display: none; } + h1 { font-size: 20px; } + } + /* As tall as its usual content at least, with the two settings at its foot, + so switching tabs or a status arriving does not resize it. */ .card { + display: flex; flex-direction: column; min-height: 304px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45); } @@ -67,16 +82,23 @@ button.primary, button.secondary { height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default; } - button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); } - button.primary:disabled { opacity: 0.55; } + button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, #2f6fed 0%, #2563eb 100%); } button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); } + /* A press answers at once, before the app does. Disabled comes last, so a + button that cannot be pressed does not look as if it could. */ + button.primary:hover { filter: brightness(1.07); } + button.primary:active { filter: brightness(0.9); } button.secondary:hover { border-color: var(--accent-2); } - button.secondary:disabled { opacity: 0.55; } + button.secondary:active { background: #1f232b; } + button.primary:disabled, button.secondary:disabled { opacity: 0.55; filter: none; } + button.secondary:disabled { border-color: var(--line-strong); background: #16181d; } + button:focus-visible, .check input:focus-visible, summary:focus-visible, footer a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; } button[hidden] { display: none; } - #status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; } + #status { margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; } #status.bad { color: var(--bad); } #status.warn { color: var(--warn); } #status.ok { color: var(--ok); } + #status-text { min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } #status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; } #suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #suggest[hidden] { display: none; } @@ -86,7 +108,8 @@ .spinner[hidden] { display: none; } @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } } - .prefs { margin-top: 16px; } + .prefs { margin-top: auto; } + .prefs::before { content: ''; display: block; margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); } .tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; } .tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; } .tabs button:hover { color: var(--text); } @@ -103,7 +126,8 @@ #local-title { font-weight: 600; } .sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; } #local-network-row[hidden] { display: none; } - #elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); } + #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } + #elsewhere[hidden] { display: none; } #elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; } #elsewhere summary::-webkit-details-marker { display: none; } #elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; } @@ -116,10 +140,11 @@ .oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; } .oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; } .statusrow { display: flex; gap: 10px; align-items: flex-start; } + .statusrow[hidden] { display: none; } .statusrow #status { flex: 1; } .statusrow #cancel { margin-top: 10px; } - footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; } - footer a { color: #7c8491; text-decoration: none; cursor: default; } + footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; } + footer a { color: #aab1bb; text-decoration: none; cursor: default; } footer a:hover { color: var(--accent); } kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; } @@ -143,7 +168,7 @@

-
+
Looking for Codeman on this Mac…
@@ -163,7 +188,7 @@ -
+