From de76fd67a836c6f1b4bf3eec78e9df8c7d017e19 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 14 Aug 2026 12:55:49 +0800 Subject: [PATCH 1/2] [feature] panel self-update via tauri-plugin-updater; pb check-updates reports patchbay itself --- .github/workflows/release.yml | 65 ++++ CHANGELOG.md | 19 + README.md | 3 + app/bun.lock | 6 + app/package.json | 2 + app/src-tauri/Cargo.lock | 447 +++++++++++++++++++++++- app/src-tauri/Cargo.toml | 4 + app/src-tauri/capabilities/default.json | 9 +- app/src-tauri/src/lib.rs | 6 + app/src-tauri/tauri.conf.json | 9 + app/src/App.tsx | 5 + app/src/components/UpdateBanner.tsx | 68 ++++ app/src/lib/update.ts | 75 ++++ app/src/styles.css | 22 ++ app/tsconfig.json | 2 + crates/patchbay-core/src/registry.rs | 7 +- crates/patchbay-core/src/versions.rs | 168 +++++++++ crates/patchbay-mcp/src/server.rs | 5 + 18 files changed, 913 insertions(+), 9 deletions(-) create mode 100644 app/src/components/UpdateBanner.tsx create mode 100644 app/src/lib/update.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eed5179..9ae4f6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -192,6 +192,20 @@ jobs: # APPLE_SIGNING_IDENTITY / APPLE_TEAM_ID / APPLE_API_* arrive from # $GITHUB_ENV above, and only when the secrets were non-empty. Nothing # sets them to "" — see the job comment. + # + # The minisign keypair behind the in-app updater. This one is NOT + # optional the way the Apple identity is: `plugins.updater.pubkey` in + # tauri.conf.json makes signing mandatory, so a checkout without the + # private key cannot build the panel at all — tauri stops with "a + # public key has been found, but no private key". That is the right + # trade (an unsigned updater artifact is worse than none), but it does + # mean a fork must either add these two secrets or drop the + # `plugins.updater` block before it can build the .app. + # + # The password is legitimately empty for a key generated without one, + # so it is passed through as-is rather than gated on being non-empty. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: # No tagName: tauri-action then only builds. The release itself is # assembled once, in the `release` job, so every artifact lands on one @@ -220,6 +234,50 @@ jobs: fi ls -l dist + # The other half of the panel's release: what the in-app updater reads. + # + # `createUpdaterArtifacts` makes tauri tar the finished .app (after it has + # been notarized and stapled, so an updated copy verifies offline too) and + # sign the tarball with the minisign key. The feed is a `latest.json` we + # write here rather than one tauri produces, because the download URL is + # only knowable once we have decided what the asset is called. + # + # Both macOS platform keys point at the same file: the build is universal, + # so one bundle genuinely is the update for both architectures. The + # updater matches on the key, and an Intel Mac that finds no + # `darwin-x86_64` entry concludes there is no update for it. + - name: Collect updater bundle and latest.json + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + bundle="app/src-tauri/target/universal-apple-darwin/release/bundle/macos" + tarball="$bundle/patchbay.app.tar.gz" + if [ ! -f "$tarball" ] || [ ! -f "$tarball.sig" ]; then + echo "::error::no signed updater bundle at $tarball(.sig) — is TAURI_SIGNING_PRIVATE_KEY set?" + ls -l "$bundle" || true + exit 1 + fi + asset="patchbay-$tag-universal-apple-darwin.app.tar.gz" + cp "$tarball" "dist/$asset" + cp "$tarball.sig" "dist/$asset.sig" + # Version without the leading v: the updater parses it as semver and + # compares it against the running app's own version. + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/$tag/$asset" + jq -n \ + --arg version "${{ needs.verify.outputs.version }}" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg signature "$(cat "$tarball.sig")" \ + --arg url "$url" \ + '{ + version: $version, + pub_date: $pub_date, + platforms: { + "darwin-aarch64": { signature: $signature, url: $url }, + "darwin-x86_64": { signature: $signature, url: $url } + } + }' > dist/latest.json + cat dist/latest.json + # tauri notarizes and staples the .app, then signs the .dmg around it — # but it does not notarize the .dmg itself. An un-notarized disk image # still makes Gatekeeper do an online check on first open, and shows a @@ -288,7 +346,14 @@ jobs: # Appends the commit/PR list under whatever body we supplied. generate_release_notes: true fail_on_unmatched_files: true + # `*.tar.gz` covers both the CLI tarballs and the panel's updater + # bundle. The `.sig` and `latest.json` beside it are what the in-app + # updater fetches — `latest.json` has to be a release asset under this + # exact name, because the endpoint in tauri.conf.json is the + # `releases/latest/download/latest.json` redirect. files: | artifacts/*.tar.gz + artifacts/*.tar.gz.sig + artifacts/latest.json artifacts/*.dmg artifacts/SHA256SUMS-*.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3619523..35cacfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **patchbay updates itself** — the panel checks the signed release feed a + couple of seconds after launch and, when a newer build exists, says so in a + slim banner above the board: `update and relaunch`, or `not now`. Applying is + always a click — patchbay is a thing you open to answer a question about your + logins, and an update must never be what happens instead — and the banner sits + in the flow rather than over the board, so the answer you came for is never + covered. The download is verified against a minisign public key compiled into + the app before anything is installed, which is the whole reason this is + allowed to be automatic at all. A failed check is silent: an offline machine + has no update to offer, and that is not news. `not now` lasts for the session + and is written nowhere, because something you have neither accepted nor + refused should be asked again next launch. `pb check-updates` gained the + matching row: patchbay reports its own installed version (the build answering + the question, not whichever `pb` is on `PATH`) against the newest GitHub + release, on the same 24-hour cache and the same shared rate limit as every + other tool. Its `UPDATE WITH` is a human instruction — download the DMG, curl + the CLI tarball — the way `gcloud`'s is, rather than a command that does not + exist. + - **The panel writes to the key vault** — `add key` opens a form (id, provider, label, a masked secret field, with purpose, scopes, expiry, endpoint and the rotation checkbox folded away), and every row gets a trash affordance behind diff --git a/README.md b/README.md index 197f0de..2303479 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,11 @@ neon 2.38.2 3.1.1 brew brew upgrade neonctl wrangler 4.105.0 4.122.0 bun bun add -g wrangler@latest vercel 42.2.0 58.11.0 pnpm pnpm add -g vercel@latest gcloud 578.0.0 — self-managed +patchbay 0.2.0 0.3.0 github download the DMG / curl the CLI tarball from the release page ``` +**patchbay reports itself** in that table too — installed is the build answering the question, latest is the newest GitHub release — because a tool that tells you twenty-three CLIs are behind while saying nothing about itself is the one row you would have to remember to check by hand. In the panel you do not even get the command: when a newer *signed* build exists it offers `update and relaunch` in a banner above the board, verifies the signature, installs in place and restarts. + patchbay works out **how each tool was installed** and asks the right place. Every Homebrew tool is answered by a single `brew outdated --json=v2` call, npm/bun/pnpm globals by one small registry request each, and self-updating vendor CLIs (`gcloud`, `az`) by nothing at all — they get their own update command instead of a made-up version number. `latest: —` always means "could not check", never "up to date". Results are cached at `~/.config/patchbay/versions.json` for 24 hours. **`pb status` only ever reads that cache** — it never executes a binary and never touches the network, so the board stays in the tens of milliseconds whether the cache is warm or cold. A warm cache adds an update marker to the board: diff --git a/app/bun.lock b/app/bun.lock index 20b16ba..14078bd 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -5,6 +5,8 @@ "name": "patchbay-app", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "react": "^19.1.0", "react-dom": "^19.1.0", }, @@ -199,6 +201,10 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/app/package.json b/app/package.json index 088f068..61d1900 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b69d6a7..32ebe3f 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -133,6 +133,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -813,6 +822,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" @@ -1045,6 +1065,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1098,6 +1128,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-crate" version = "0.6.3" @@ -1793,6 +1833,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2195,6 +2250,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.20", + "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" @@ -2361,6 +2446,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2424,6 +2515,12 @@ dependencies = [ "unicase", ] +[[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" @@ -2677,6 +2774,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2692,6 +2790,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" @@ -2761,12 +2871,32 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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.20", +] + [[package]] name = "p256" version = "0.13.2" @@ -2836,6 +2966,8 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-process", + "tauri-plugin-updater", "tokio", ] @@ -3307,15 +3439,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", @@ -3416,6 +3553,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -3431,6 +3581,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.1" @@ -3440,6 +3602,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", + "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.14" @@ -3481,6 +3670,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3571,6 +3769,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "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.36.1" @@ -3823,6 +4044,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" @@ -4047,7 +4284,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4080,6 +4317,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" @@ -4103,7 +4351,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4199,6 +4447,65 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[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", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4209,7 +4516,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4232,7 +4539,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4299,6 +4606,19 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -4418,6 +4738,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -5069,6 +5399,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -5335,6 +5674,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" @@ -5368,13 +5716,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" @@ -5405,6 +5770,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" @@ -5417,6 +5788,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" @@ -5429,12 +5806,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" @@ -5447,6 +5836,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" @@ -5459,6 +5854,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" @@ -5471,6 +5872,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" @@ -5483,6 +5890,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" @@ -5550,7 +5963,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5609,6 +6022,16 @@ dependencies = [ "zeroize", ] +[[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" @@ -5727,6 +6150,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/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2dd15f1..a64f962 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -20,6 +20,10 @@ patchbay-core = { path = "../../crates/patchbay-core" } anyhow = "1" chrono = { version = "0.4", features = ["serde"] } tauri = { version = "2", features = [] } +# In-app self-update: the updater checks the signed release feed, `process` +# is what relaunches into the build it just installed. +tauri-plugin-updater = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread"] } diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index d6316bb..9ad37e1 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -1,7 +1,12 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Everything the panel window needs: nothing beyond core defaults.", + "description": "Everything the panel window needs: core defaults, plus check/install/relaunch for self-update.", "windows": ["main"], - "permissions": ["core:default", "core:window:allow-start-dragging"] + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "updater:default", + "process:default" + ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 41c1b69..844e5b9 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -230,6 +230,12 @@ async fn mcp_list() -> CmdResult> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + // Self-update. The updater reads the signed feed named in + // tauri.conf.json; `process` supplies the relaunch that takes the user + // into the build it just installed. Both are inert until the front end + // calls them — see `src/lib/update.ts`. + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) // A blank window is nearly always "the webview could not load its // source" — a debug binary run without `tauri dev` points at the Vite // URL and finds nothing there. Say which URL, and whether it loaded. diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 6c7c359..6b32016 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -25,8 +25,17 @@ "csp": null } }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/pathorsAI/patchbay/releases/latest/download/latest.json" + ], + "pubkey": "TAURI_UPDATER_PUBKEY_PLACEHOLDER" + } + }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "all", "icon": [ "icons/32x32.png", diff --git a/app/src/App.tsx b/app/src/App.tsx index 71dce3f..ec8d978 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import { McpView } from "./components/McpView"; import { Sidebar } from "./components/Sidebar"; import { ToolCard } from "./components/ToolCard"; import { ToolDetail } from "./components/ToolDetail"; +import { UpdateBanner } from "./components/UpdateBanner"; const POLL_MS = 30_000; /** Countdowns must not go stale between polls. */ @@ -195,6 +196,10 @@ export default function App() { />
+ {/* Above every view, in the flow: an update is worth saying once, + and never worth covering the thing the window is for. */} + + {view === "keys" && } {view === "mcp" && } diff --git a/app/src/components/UpdateBanner.tsx b/app/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..efe3d27 --- /dev/null +++ b/app/src/components/UpdateBanner.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { checkForUpdate, installUpdate, type AvailableUpdate } from "../lib/update"; + +/** + * The one row that says a newer patchbay exists. + * + * It sits in the flow above whatever view is open rather than over it: the + * board is what the window is for, and an update is never urgent enough to + * cover a login you came here to read. "not now" dismisses it for this session + * only — nothing is written down, so the next launch asks again, which is the + * honest behaviour for something the user has neither accepted nor refused. + */ +export function UpdateBanner() { + const [update, setUpdate] = useState(null); + const [dismissed, setDismissed] = useState(false); + /** Percent while downloading, `null` for "working, no number to show". */ + const [progress, setProgress] = useState(undefined); + const [failure, setFailure] = useState(null); + + // One check, shortly after launch. `checkForUpdate` is silent about failure + // and about dev builds, so there is nothing to guard here. + useEffect(() => { + const timer = setTimeout(() => { + void checkForUpdate().then(setUpdate); + }, 2000); + return () => clearTimeout(timer); + }, []); + + if (!update || dismissed) return null; + + const installing = progress !== undefined; + + const run = () => { + setFailure(null); + setProgress(null); + // installUpdate only ever returns by failing: a relaunch replaces us. + installUpdate(setProgress).catch((e) => { + // The install may well have succeeded and only the relaunch failed (an + // app still under Gatekeeper's translocation cannot restart itself), in + // which case the new version is already staged. Say so instead of + // reporting a failure the user cannot act on. + setProgress(undefined); + setFailure(`${String(e)} — quit and reopen patchbay to finish the update`); + }); + }; + + return ( +
+ + + {failure ?? (installing ? updatingLabel(progress) : `patchbay ${update.version} is available`)} + + + + + +
+ ); +} + +function updatingLabel(progress: number | null | undefined): string { + return typeof progress === "number" ? `downloading… ${progress}%` : "downloading…"; +} diff --git a/app/src/lib/update.ts b/app/src/lib/update.ts new file mode 100644 index 0000000..6c20111 --- /dev/null +++ b/app/src/lib/update.ts @@ -0,0 +1,75 @@ +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +/** + * In-app self-update. + * + * The updater asks the signed release feed named in `tauri.conf.json` + * (`plugins.updater`) whether a newer build exists, and only ever *offers* it: + * downloading, installing and relaunching all happen behind a click. patchbay + * is a thing you open to answer a question about your logins, so an update + * must never be the thing that happens instead. + * + * `check` and `relaunch` are imported STATICALLY on purpose. A dynamic import + * of `relaunch` after `downloadAndInstall` would go looking for its JS chunk in + * the bundle the install has just replaced on disk; the import rejects and the + * app never restarts. Loading both at startup never touches the swapped bundle. + */ + +/** What the banner needs to say. */ +export interface AvailableUpdate { + version: string; + /** The release notes for that version, or "" when the feed carried none. */ + notes: string; +} + +/** + * The live handle. It carries `downloadAndInstall`, so it cannot travel through + * React state — the banner holds the version string, this module holds the + * thing that can act on it. + */ +let pending: Update | null = null; + +/** + * Ask the feed. `null` for "nothing newer", and also for every failure: an + * offline machine, a rate-limited endpoint or a release without updater + * artifacts are all "no update to offer today", none of which is a problem the + * user opened this window to hear about. + */ +export async function checkForUpdate(): Promise { + // Dev has no updater artifacts and no signature to verify against, so the + // check can only ever fail — and a banner would be in the way regardless. + if (import.meta.env.DEV) return null; + try { + const update = await check(); + if (!update) return null; + pending = update; + return { version: update.version, notes: update.body ?? "" }; + } catch (e) { + console.warn("patchbay: update check failed", e); + return null; + } +} + +/** + * Download, verify, install, relaunch. `onProgress` gets a percentage while the + * bytes come down, or `null` when the feed did not declare a content length — + * the caller shows "updating…" rather than a number it made up. + * + * Only returns on failure: a successful relaunch replaces this process. + */ +export async function installUpdate(onProgress: (pct: number | null) => void): Promise { + if (!pending) throw new Error("no update is pending"); + let total = 0; + let got = 0; + await pending.downloadAndInstall((e) => { + if (e.event === "Started") { + total = e.data.contentLength ?? 0; + onProgress(total > 0 ? 0 : null); + } else if (e.event === "Progress") { + got += e.data.chunkLength; + onProgress(total > 0 ? Math.min(100, Math.round((got / total) * 100)) : null); + } + }); + await relaunch(); +} diff --git a/app/src/styles.css b/app/src/styles.css index e437bc2..21fa516 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -401,6 +401,28 @@ button { font-size: 12px; } +/* A newer patchbay is news, not a fault: same row, accent instead of risk. */ +.banner-update { + align-items: center; + border-color: var(--line); + background: var(--neutral-wash); +} + +.banner-update .glyph { + color: var(--accent); +} + +.banner-text { + flex: 1 1 auto; + min-width: 0; +} + +.banner-actions { + display: flex; + flex: none; + gap: 6px; +} + /* ---------- card ---------- */ /* Three rows, one height, identical padding — see .grid. */ diff --git a/app/tsconfig.json b/app/tsconfig.json index e46e8ef..c997360 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -5,6 +5,8 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + // `import.meta.env` — the updater skips its check in dev builds. + "types": ["vite/client"], "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/crates/patchbay-core/src/registry.rs b/crates/patchbay-core/src/registry.rs index 020e614..137f79d 100644 --- a/crates/patchbay-core/src/registry.rs +++ b/crates/patchbay-core/src/registry.rs @@ -195,8 +195,13 @@ impl Registry { /// /// Seconds, not milliseconds: this is the only entry point in patchbay that /// both spawns processes and makes outbound requests. + /// + /// patchbay is in the list too. A tool that tells you twenty-three CLIs are + /// behind while saying nothing about itself is the one row people would + /// have to remember to check by hand. pub fn check_updates(&self, options: CheckOptions) -> CheckReport { - let tools = self.tool_names(); + let mut tools = self.tool_names(); + tools.push(versions::SELF_TOOL); versions::check_updates(&self.paths, &tools, options) } diff --git a/crates/patchbay-core/src/versions.rs b/crates/patchbay-core/src/versions.rs index c4c99bd..c4fb9e9 100644 --- a/crates/patchbay-core/src/versions.rs +++ b/crates/patchbay-core/src/versions.rs @@ -664,6 +664,59 @@ pub fn spec_for(tool: &str) -> Option<&'static ToolVersionSpec> { VERSIONS.iter().find(|s| s.tool == tool) } +// --------------------------------------------------------------------------- +// patchbay's own row +// --------------------------------------------------------------------------- + +/// patchbay, as it appears in its own table. +/// +/// Deliberately *not* in [`VERSIONS`]: that table is keyed by registered tools, +/// and patchbay has no probe of itself. Callers ask for this row by putting the +/// name in the tool list ([`crate::Registry::check_updates`] always does), and +/// [`check_updates_with`] answers it without touching [`spec_for`]. +pub const SELF_TOOL: &str = "patchbay"; + +/// patchbay's release feed — the same one the panel's in-app updater reads. +pub const SELF_REPO: &str = "pathorsAI/patchbay"; + +/// The spec behind [`SELF_TOOL`]. +/// +/// `bins` and `args` are never used: the installed version is compiled in (see +/// [`resolve_self`]), because the code asking the question *is* the answer, and +/// the `pb` on `PATH` may be a different build entirely. The rest is a normal +/// GitHub-release spec, so the lookup and the update command fall out of the +/// machinery every other tool already goes through. +static SELF_SPEC: ToolVersionSpec = ToolVersionSpec { + tool: SELF_TOOL, + bins: &["pb"], + args: &["--version"], + parse: ParseStrategy::FirstSemver, + brew: None, + npm: None, + github: Some(SELF_REPO), + self_update: Some(SelfUpdate { + command: "download the DMG / curl the CLI tarball from the release page", + note: + "patchbay ships as a signed DMG and CLI tarballs; the panel offers the update in place", + }), +}; + +/// patchbay's row: the installed version without executing anything. +/// +/// [`Source::Github`] rather than self-managed — the release page really is the +/// index, so `latest` is a normal lookup that rides phase 3 with every other +/// GitHub tool, sharing the same rate-limit stop. Only the *update command* is +/// a human instruction, the way `gcloud`'s is. +fn resolve_self(now: DateTime) -> Resolved { + let mut info = VersionInfo::new(SELF_TOOL, Source::Github, now); + info.installed = Some(env!("CARGO_PKG_VERSION").to_string()); + Resolved { + spec: &SELF_SPEC, + package: None, + info, + } +} + // --------------------------------------------------------------------------- // install-source detection // --------------------------------------------------------------------------- @@ -1168,7 +1221,15 @@ pub fn check_updates_with( let cache = VersionCache::load(&paths.versions_file()); let mut specs: Vec<&'static ToolVersionSpec> = Vec::new(); + // patchbay reports itself alongside the tools it watches, but it is not one + // of them: no probe, no entry in VERSIONS, nothing to exec. Held aside here + // and answered by `resolve_self` below. + let mut wants_self = false; for tool in tools { + if *tool == SELF_TOOL { + wants_self = true; + continue; + } match spec_for(tool) { Some(spec) => specs.push(spec), None if OPTED_OUT.contains(tool) => {} @@ -1194,10 +1255,29 @@ pub fn check_updates_with( } } + // patchbay's own row obeys that same TTL — it is one more GitHub lookup, + // and there is no reason for it to be the one thing that asks every run. + let mut self_pending = false; + if wants_self { + match ( + options.refresh, + cache.get_fresh(SELF_TOOL, now, options.ttl), + ) { + (false, Some(cached)) => { + report.from_cache += 1; + report.entries.push(cached.clone()); + } + _ => self_pending = true, + } + } + // --- phase 1: local. Ask each tool its own version, in parallel. -------- let mut resolved: Vec = run_bounded(&pending, MAX_THREADS, |spec| { resolve_local(spec, paths, deps, now) }); + if self_pending { + resolved.push(resolve_self(now)); + } // --- phase 2: Homebrew. ONE call, however many brew tools there are. ---- let brew_wanted = resolved.iter().any(|r| r.info.source == Source::Homebrew); @@ -2343,6 +2423,94 @@ mod tests { assert_eq!(http.call_count(), 0); } + // --- patchbay's own row ------------------------------------------------- + + #[test] + fn test_patchbay_reports_itself_without_execing_a_binary() { + let dir = tempfile::tempdir().unwrap(); + // Nothing on PATH and a runner that refuses everything: the row must + // still come out installed, because the version is compiled in. + let bins = FakeBinaries::none(); + let runner = FakeRunner::new(&[]); + let brew = FakeBrew::new(BREW_OUTDATED); + let http = StubHttp::responding(HttpResponse::new(200, r#"{"tag_name":"v99.0.0"}"#)); + + let report = check_updates_with( + &Paths::for_test(dir.path()), + &[SELF_TOOL], + CheckOptions::default(), + &deps(&runner, &brew, &http, &bins), + ); + + assert_eq!(report.entries.len(), 1); + let entry = &report.entries[0]; + assert_eq!(entry.tool, "patchbay"); + assert_eq!( + entry.installed.as_deref(), + Some(env!("CARGO_PKG_VERSION")), + "the build asking the question is the build installed" + ); + assert_eq!(entry.latest.as_deref(), Some("99.0.0")); + assert_eq!(entry.source, Source::Github); + assert!(entry.update_available()); + // A human instruction, like gcloud's — there is no command that + // upgrades patchbay from a package index. + assert_eq!( + entry.update_command.as_deref(), + Some("download the DMG / curl the CLI tarball from the release page") + ); + assert_eq!( + http.last_url().unwrap(), + "https://api.github.com/repos/pathorsAI/patchbay/releases/latest" + ); + assert_eq!(runner.calls.load(Ordering::SeqCst), 0, "nothing was exec'd"); + assert_eq!(report.brew_calls, 0); + } + + #[test] + fn test_patchbays_own_row_is_cached_like_every_other() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths::for_test(dir.path()); + let now = Utc::now(); + + let mut cached = VersionInfo::new(SELF_TOOL, Source::Github, now); + cached.installed = Some("0.2.0".into()); + cached.latest = Some("0.3.0".into()); + let mut cache = VersionCache::default(); + cache.put(cached); + cache.save(&paths.versions_file()).unwrap(); + + let runner = FakeRunner::new(&[]); + let brew = FakeBrew::new(BREW_OUTDATED); + let http = StubHttp::failing("GitHub must not be asked inside the TTL"); + let bins = FakeBinaries::none(); + + let report = check_updates_with( + &paths, + &[SELF_TOOL], + CheckOptions::default(), + &deps(&runner, &brew, &http, &bins), + ); + assert_eq!(report.from_cache, 1); + assert_eq!(report.network_calls, 0); + assert_eq!(http.call_count(), 0); + assert_eq!(report.entries[0].latest.as_deref(), Some("0.3.0")); + } + + #[test] + fn test_patchbay_is_not_a_registered_tool_and_needs_no_version_entry() { + let dir = tempfile::tempdir().unwrap(); + let registry = crate::Registry::all(Paths::for_test(dir.path())); + assert!( + !registry.tool_names().contains(&SELF_TOOL), + "patchbay probes CLIs; it is not one of them" + ); + assert!( + spec_for(SELF_TOOL).is_none(), + "the self row lives outside VERSIONS on purpose — see SELF_SPEC" + ); + } + #[test] fn test_a_fresh_cache_makes_a_second_run_do_no_work_at_all() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/patchbay-mcp/src/server.rs b/crates/patchbay-mcp/src/server.rs index ae06433..a19feaf 100644 --- a/crates/patchbay-mcp/src/server.rs +++ b/crates/patchbay-mcp/src/server.rs @@ -474,6 +474,11 @@ install source). Say 'could not check', not 'current'. - `update_command` is the exact command for THIS machine's install — `brew upgrade gh` and \ `npm install -g wrangler@latest` are not interchangeable, so use the one given rather than \ guessing at a package manager. Offer it; do not run it without being asked. +- One entry is `patchbay` itself. It is NOT a connection — it will never appear on \ +list_connections and has no profiles to switch — it is patchbay reporting its own version against \ +its newest GitHub release. Its `update_command` is an instruction for the human (download the DMG, \ +curl the CLI tarball); the desktop panel offers the same update in place, so if the user runs the \ +panel, tell them to take it there. - `advisories[]` is curated deprecation knowledge, independent of version numbers: renames, \ removals, end-of-life dates. `removed` and `unmaintained` entries are worth raising even when the \ user only asked about versions. Every one carries a source `url` — pass it on. From b5395bfdee3f8d12883590df9b7bdc105e300642 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 14 Aug 2026 12:58:16 +0800 Subject: [PATCH 2/2] [chore] panel: real updater pubkey --- app/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 6b32016..f0751a5 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "endpoints": [ "https://github.com/pathorsAI/patchbay/releases/latest/download/latest.json" ], - "pubkey": "TAURI_UPDATER_PUBKEY_PLACEHOLDER" + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYzQUZFMUYzRTRBRUQ4OTUKUldTVjJLN2s4K0d2ODNnSDBIRGV0TGNnaDJMVVFoWlpmeHJpYnY5TlR3R2ZjME94ME8xZm1MVm4K" } }, "bundle": {