From 7c185c9b360f9c8ea3448b681942978101d5377c Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 21:22:22 -0700 Subject: [PATCH] Hide secondary windows until the login UI is painted, open external links in the default browser, and add signed Githop auto-updates Hop-State: A_06FPJAAEMY0VXGY9S90PE90 Hop-Proposal: R_06FPJA9RY0ZAWJ1K0K760W8 Hop-Task: T_06FPJ5688NFG8VWKSFCTZEG Hop-Attempt: AT_06FPJ5688M08V2K0PEFVQX0 --- README.md | 26 ++ package.json | 4 +- scripts/create-update-manifest.mjs | 31 ++ src-tauri/Cargo.lock | 367 +++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/build.rs | 2 + .../autogenerated/open_external_url.toml | 11 + .../autogenerated/reveal_window.toml | 11 + src-tauri/permissions/local-app.toml | 2 + src-tauri/src/lib.rs | 200 +++++++++- src-tauri/tauri.conf.json | 6 + src-tauri/tauri.release.conf.json | 5 + src/App.tsx | 22 ++ 13 files changed, 677 insertions(+), 12 deletions(-) create mode 100644 scripts/create-update-manifest.mjs create mode 100644 src-tauri/permissions/autogenerated/open_external_url.toml create mode 100644 src-tauri/permissions/autogenerated/reveal_window.toml create mode 100644 src-tauri/tauri.release.conf.json diff --git a/README.md b/README.md index eecd400..efbfb78 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,29 @@ cargo test --manifest-path src-tauri/Cargo.toml - The selected node's complete website experience after authentication. The node address and HttpOnly website session persist between launches using the native webview's cookie store. + +## Auto-updates + +Production builds check the newest public release at +`GnosysLabs/Synapsis-Desktop` on Githop. When a newer signed release is present, +Synapsis downloads it, verifies it with the embedded updater public key, installs +it, and relaunches automatically. Development builds do not check for updates. + +The encrypted updater private key lives outside this repository at +`/Users/christopher/.tauri/synapsis-desktop-updater.key`. Matching +`TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets are +configured for the Githop repository. Never commit the private key or password. + +To prepare a macOS update locally: + +1. Increment `version` in `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, and + `package.json`. +2. Export `TAURI_SIGNING_PRIVATE_KEY` and + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` for the updater signing key. +3. Run `bun run release` to build the app, updater archive, and signature. +4. Run `bun run release:manifest` to create `latest.json` beside the archive. +5. Create a Githop release tagged `v` and attach + `Synapsis.app.tar.gz`, `Synapsis.app.tar.gz.sig`, and `latest.json`. + +The release tag and manifest tag must match. Override the defaults with +`SYNAPSIS_RELEASE_TAG` and `SYNAPSIS_RELEASE_NOTES` when needed. diff --git a/package.json b/package.json index 14071dc..2ef7472 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "release": "tauri build --config src-tauri/tauri.release.conf.json", + "release:manifest": "bun scripts/create-update-manifest.mjs" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/create-update-manifest.mjs b/scripts/create-update-manifest.mjs new file mode 100644 index 0000000..b849d52 --- /dev/null +++ b/scripts/create-update-manifest.mjs @@ -0,0 +1,31 @@ +import { readFile, writeFile } from "node:fs/promises"; +import process from "node:process"; + +const repository = "https://githop.xyz/GnosysLabs/Synapsis-Desktop"; +const bundleDirectory = new URL("../src-tauri/target/release/bundle/macos/", import.meta.url); +const config = JSON.parse( + await readFile(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"), +); +const version = config.version; +const tag = process.env.SYNAPSIS_RELEASE_TAG || `v${version}`; +const notes = process.env.SYNAPSIS_RELEASE_NOTES || `Synapsis ${version}`; +const archiveName = "Synapsis.app.tar.gz"; +const signature = ( + await readFile(new URL(`${archiveName}.sig`, bundleDirectory), "utf8") +).trim(); +const architecture = process.arch === "arm64" ? "aarch64" : "x86_64"; +const manifest = { + version, + notes, + pub_date: new Date().toISOString(), + platforms: { + [`darwin-${architecture}`]: { + signature, + url: `${repository}/releases/download/${encodeURIComponent(tag)}/${archiveName}`, + }, + }, +}; + +const destination = new URL("latest.json", bundleDirectory); +await writeFile(destination, `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`Created updater manifest for Synapsis ${version}.`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7396846..e45253d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -725,6 +734,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1027,6 +1047,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1849,6 +1879,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1894,6 +1943,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[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" @@ -2109,6 +2188,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2388,6 +2473,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2451,6 +2548,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl" version = "0.10.81" @@ -2510,6 +2618,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3083,15 +3205,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3159,6 +3286,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -3169,6 +3308,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3522,6 +3688,22 @@ 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.3" @@ -3683,6 +3865,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-updater", ] [[package]] @@ -3757,7 +3941,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3790,6 +3974,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3813,7 +4008,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3944,6 +4139,61 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3954,7 +4204,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3977,7 +4227,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4784,6 +5034,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -5052,6 +5311,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5085,13 +5353,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5122,6 +5407,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5134,6 +5425,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5146,12 +5443,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5164,6 +5473,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5176,6 +5491,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5188,6 +5509,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5200,6 +5527,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5264,7 +5597,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5311,6 +5644,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5475,6 +5818,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d221f7f..90e2548 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["cookies", "json", "rustls-tls"] } tauri-plugin-notification = "2" +tauri-plugin-opener = "2" +tauri-plugin-updater = "2" [dev-dependencies] http = "1" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 458c7d3..c02a490 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -8,6 +8,8 @@ fn main() { "has_web_session", "open_web_app", "open_login_window", + "open_external_url", + "reveal_window", ]), )) .unwrap(); diff --git a/src-tauri/permissions/autogenerated/open_external_url.toml b/src-tauri/permissions/autogenerated/open_external_url.toml new file mode 100644 index 0000000..3a89010 --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_external_url.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-external-url" +description = "Enables the open_external_url command without any pre-configured scope." +commands.allow = ["open_external_url"] + +[[permission]] +identifier = "deny-open-external-url" +description = "Denies the open_external_url command without any pre-configured scope." +commands.deny = ["open_external_url"] diff --git a/src-tauri/permissions/autogenerated/reveal_window.toml b/src-tauri/permissions/autogenerated/reveal_window.toml new file mode 100644 index 0000000..cf045d5 --- /dev/null +++ b/src-tauri/permissions/autogenerated/reveal_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-reveal-window" +description = "Enables the reveal_window command without any pre-configured scope." +commands.allow = ["reveal_window"] + +[[permission]] +identifier = "deny-reveal-window" +description = "Denies the reveal_window command without any pre-configured scope." +commands.deny = ["reveal_window"] diff --git a/src-tauri/permissions/local-app.toml b/src-tauri/permissions/local-app.toml index 943ae75..4a81a2b 100644 --- a/src-tauri/permissions/local-app.toml +++ b/src-tauri/permissions/local-app.toml @@ -9,4 +9,6 @@ commands.allow = [ "has_web_session", "open_web_app", "open_login_window", + "open_external_url", + "reveal_window", ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4e3e2f..0feec58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ use std::sync::Mutex; use std::time::Duration; use tauri::webview::{Cookie, PageLoadEvent}; use tauri::{Manager, State, Webview, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri_plugin_opener::OpenerExt; +use tauri_plugin_updater::UpdaterExt; struct AppState { client: Client, @@ -56,6 +58,85 @@ struct ErrorPayload { error: Option, } +const UPDATER_PUBLIC_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYzOEZCMTg0QzJERkUwNTcKUldSWDROL0NoTEdQODlCOW10cXAvYzNTNitFQkxUd3F2T3UybUV3REJYT1Z6OVczazFmamxhWlIK"; +const LATEST_RELEASE_API: &str = + "https://githop.xyz/api/v1/repos/GnosysLabs/Synapsis-Desktop/releases/latest"; + +#[derive(Debug, Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: Url, +} + +#[derive(Debug, Deserialize)] +struct ReleaseInfo { + assets: Vec, +} + +fn manifest_url(release: ReleaseInfo) -> Option { + release + .assets + .into_iter() + .find(|asset| asset.name == "latest.json") + .map(|asset| asset.browser_download_url) +} + +async fn latest_manifest_url(client: &Client) -> Result, String> { + let response = client + .get(LATEST_RELEASE_API) + .header("Accept", "application/json") + .send() + .await + .map_err(|error| format!("Couldn’t reach the Synapsis release feed: {error}"))?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let release = decode_response::(response).await?; + Ok(manifest_url(release)) +} + +fn start_auto_updater(app: tauri::AppHandle, client: Client) { + if cfg!(debug_assertions) { + return; + } + + tauri::async_runtime::spawn(async move { + let endpoint = match latest_manifest_url(&client).await { + Ok(Some(endpoint)) => endpoint, + Ok(None) => return, + Err(error) => { + eprintln!("Couldn’t discover the latest Synapsis release: {error}"); + return; + } + }; + let updater = match app + .updater_builder() + .pubkey(UPDATER_PUBLIC_KEY) + .endpoints(vec![endpoint]) + .and_then(|builder| builder.build()) + { + Ok(updater) => updater, + Err(error) => { + eprintln!("Couldn’t initialize Synapsis auto-updates: {error}"); + return; + } + }; + + match updater.check().await { + Ok(Some(update)) => { + let version = update.version.clone(); + if let Err(error) = update.download_and_install(|_, _| {}, || {}).await { + eprintln!("Couldn’t install Synapsis {version}: {error}"); + return; + } + app.restart(); + } + Ok(None) => {} + Err(error) => eprintln!("Couldn’t check for a Synapsis update: {error}"), + } + }); +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RegistrationRequest<'a> { @@ -304,7 +385,11 @@ fn remote_node_capability(origin: &str, window_label: &str, sequence: u64) -> St "remote": { "urls": [format!("{origin}/*")] }, - "permissions": ["notification:default", "allow-open-login-window"] + "permissions": [ + "notification:default", + "allow-open-login-window", + "allow-open-external-url" + ] }) .to_string() } @@ -377,6 +462,48 @@ async fn install_new_window_button(client: Client, webview: Webview, page_url: U let _ = webview.eval(new_window_button_script(accent_color.as_deref())); } +fn external_link_script() -> &'static str { + r#" +(() => { + if (window.__SYNAPSIS_EXTERNAL_LINKS_INSTALLED__) return; + window.__SYNAPSIS_EXTERNAL_LINKS_INSTALLED__ = true; + + document.addEventListener("click", (event) => { + if (event.defaultPrevented || event.button !== 0) return; + const link = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!link || link.hasAttribute("download")) return; + + let destination; + try { + destination = new URL(link.href, window.location.href); + } catch { + return; + } + + if (!/^https?:$/.test(destination.protocol) || destination.origin === window.location.origin) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + window.__TAURI_INTERNALS__.invoke("open_external_url", { url: destination.href }) + .catch((error) => console.error("Could not open external link", error)); + }, true); +})(); +"# +} + +#[tauri::command] +fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { + let destination = Url::parse(&url).map_err(|_| "This link is not a valid URL.".to_string())?; + if !matches!(destination.scheme(), "http" | "https") { + return Err("Only web links can be opened externally.".into()); + } + app.opener() + .open_url(destination.as_str(), None::<&str>) + .map_err(|error| format!("Couldn’t open this link in the default browser: {error}")) +} + #[tauri::command] async fn open_login_window( app: tauri::AppHandle, @@ -395,6 +522,7 @@ async fn open_login_window( .inner_size(1280.0, 820.0) .min_inner_size(720.0, 560.0) .center() + .visible(false) .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Visible) .theme(Some(tauri::Theme::Dark)) @@ -404,6 +532,19 @@ async fn open_login_window( .map_err(|error| format!("Couldn’t open another Synapsis window: {error}")) } +#[tauri::command] +fn reveal_window(webview: WebviewWindow) -> Result<(), String> { + if !webview.label().starts_with("node-") { + return Ok(()); + } + webview + .show() + .map_err(|error| format!("Couldn’t reveal the Synapsis window: {error}"))?; + webview + .set_focus() + .map_err(|error| format!("Couldn’t focus the Synapsis window: {error}")) +} + #[tauri::command] async fn logout(state: State<'_, AppState>, node_url: String) -> Result<(), String> { let base = normalize_node(&node_url)?; @@ -432,19 +573,34 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .plugin( + tauri_plugin_updater::Builder::new() + .pubkey(UPDATER_PUBLIC_KEY) + .build(), + ) .manage(AppState { client, notification_capability_sequence: AtomicU64::new(0), window_sequence: AtomicU64::new(1), entry_urls: Mutex::new(HashMap::new()), }) + .setup(|app| { + let client = app.state::().client.clone(); + start_auto_updater(app.handle().clone(), client); + Ok(()) + }) .on_page_load(|webview, payload| { - if payload.event() != PageLoadEvent::Finished - || !matches!(payload.url().scheme(), "http" | "https") - { + if payload.event() != PageLoadEvent::Finished { return; } + if !matches!(payload.url().scheme(), "http" | "https") { + return; + } + + let _ = webview.eval(external_link_script()); + let Ok(cookies) = webview.cookies_for_url(payload.url().clone()) else { return; }; @@ -493,7 +649,9 @@ pub fn run() { logout, has_web_session, open_web_app, - open_login_window + open_login_window, + open_external_url, + reveal_window ]) .run(tauri::generate_context!()) .expect("error while running Synapsis"); @@ -559,6 +717,7 @@ mod tests { assert_eq!(value["remote"]["urls"][0], "https://example.com/*"); assert_eq!(value["permissions"][0], "notification:default"); assert_eq!(value["permissions"][1], "allow-open-login-window"); + assert_eq!(value["permissions"][2], "allow-open-external-url"); } #[test] @@ -571,6 +730,15 @@ mod tests { assert!(script.contains("open_login_window")); } + #[test] + fn intercepts_external_links_without_hijacking_same_origin_navigation() { + let script = external_link_script(); + + assert!(script.contains("destination.origin === window.location.origin")); + assert!(script.contains("open_external_url")); + assert!(script.contains("event.preventDefault()")); + } + #[test] fn returns_to_entry_only_for_logged_out_explore() { let explore = Url::parse("https://example.com/explore").unwrap(); @@ -581,4 +749,26 @@ mod tests { assert!(!is_logged_out_explore(&timeline, &[])); assert!(!is_logged_out_explore(&explore, &[active_cookie])); } + + #[test] + fn selects_latest_json_from_githop_release_assets() { + let release = ReleaseInfo { + assets: vec![ + ReleaseAsset { + name: "Synapsis.app.tar.gz".into(), + browser_download_url: Url::parse("https://example.com/Synapsis.app.tar.gz") + .unwrap(), + }, + ReleaseAsset { + name: "latest.json".into(), + browser_download_url: Url::parse("https://example.com/latest.json").unwrap(), + }, + ], + }; + + assert_eq!( + manifest_url(release).unwrap().as_str(), + "https://example.com/latest.json" + ); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a9502b2..7a8c2dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,5 +38,11 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYzOEZCMTg0QzJERkUwNTcKUldSWDROL0NoTEdQODlCOW10cXAvYzNTNitFQkxUd3F2T3UybUV3REJYT1Z6OVczazFmamxhWlIK", + "endpoints": [] + } } } diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json new file mode 100644 index 0000000..d57cd00 --- /dev/null +++ b/src-tauri/tauri.release.conf.json @@ -0,0 +1,5 @@ +{ + "bundle": { + "createUpdaterArtifacts": true + } +} diff --git a/src/App.tsx b/src/App.tsx index a844030..2ec4849 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -264,6 +264,10 @@ function SignalField() { } function App() { + const isSecondaryWindow = useRef( + Boolean(window.__SYNAPSIS_FRESH_LOGIN__) || + new URLSearchParams(window.location.search).has("newWindow"), + ); const [screen, setScreen] = useState("node"); const [nodeInput, setNodeInput] = useState(DEFAULT_NODE); const [nodeUrl, setNodeUrl] = useState(""); @@ -325,6 +329,24 @@ function App() { void restore(); }, []); + useEffect(() => { + if (bootstrapping || !isSecondaryWindow.current || !isTauri()) return; + let cancelled = false; + let secondFrame = 0; + const firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + if (!cancelled) { + void invoke("reveal_window"); + } + }); + }); + return () => { + cancelled = true; + cancelAnimationFrame(firstFrame); + cancelAnimationFrame(secondFrame); + }; + }, [bootstrapping]); + async function connect(event: FormEvent) { event.preventDefault(); if (!isTauri()) {