From 6e39955f1731b1315f18855343cc34b8de8ff26d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 23:45:25 -0700 Subject: [PATCH] Yaak Proxy: release build, dry run and updater (#836) Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-app.yml | 191 +++++- Cargo.lock | 21 + Cargo.toml | 2 + apps/yaak-client/lib/atoms.ts | 2 +- apps/yaak-client/lib/initGlobalListeners.tsx | 158 ++--- apps/yaak-proxy/components/Settings.tsx | 18 +- apps/yaak-proxy/lib/settings.test.ts | 12 + apps/yaak-proxy/lib/settings.ts | 13 +- apps/yaak-proxy/lib/tauri.ts | 13 + apps/yaak-proxy/lib/updates.test.ts | 87 +++ apps/yaak-proxy/lib/updates.ts | 134 ++++ apps/yaak-proxy/main.tsx | 3 + crates-tauri/yaak-app-client/Cargo.toml | 1 + .../yaak-app-client/bindings/index.ts | 20 - crates-tauri/yaak-app-client/index.ts | 3 +- crates-tauri/yaak-app-client/src/lib.rs | 4 +- crates-tauri/yaak-app-client/src/updates.rs | 380 +---------- crates-tauri/yaak-app-proxy/Cargo.toml | 10 + crates-tauri/yaak-app-proxy/src/lib.rs | 35 +- crates-tauri/yaak-app-proxy/src/updates.rs | 592 ++++++++++++++++++ .../yaak-app-proxy/src/window_menu.rs | 9 + .../yaak-app-proxy/tauri.release.conf.json | 14 +- crates-tauri/yaak-updater/Cargo.toml | 22 + crates-tauri/yaak-updater/bindings/index.ts | 32 + crates-tauri/yaak-updater/package.json | 6 + crates-tauri/yaak-updater/src/gate.rs | 569 +++++++++++++++++ crates-tauri/yaak-updater/src/lib.rs | 564 +++++++++++++++++ .../src/restart.rs | 0 package-lock.json | 9 + package.json | 1 + .../src/components}/ButtonInfiniteLoading.tsx | 0 .../components/settings/SettingsGeneral.tsx | 14 +- .../src/components/settings/settings.test.tsx | 16 + packages/ui/src/components/settings/types.ts | 2 + .../components/updates/updateToasts.test.tsx | 128 ++++ .../src/components/updates/updateToasts.tsx | 211 +++++++ packages/ui/src/index.ts | 15 + scripts/replace-version.cjs | 13 +- scripts/sign-proxy-vendored.cjs | 46 +- scripts/verify-proxy-bundle.mjs | 190 ++++++ 40 files changed, 3024 insertions(+), 536 deletions(-) create mode 100644 apps/yaak-proxy/lib/updates.test.ts create mode 100644 apps/yaak-proxy/lib/updates.ts create mode 100644 crates-tauri/yaak-app-proxy/src/updates.rs create mode 100644 crates-tauri/yaak-updater/Cargo.toml create mode 100644 crates-tauri/yaak-updater/bindings/index.ts create mode 100644 crates-tauri/yaak-updater/package.json create mode 100644 crates-tauri/yaak-updater/src/gate.rs create mode 100644 crates-tauri/yaak-updater/src/lib.rs rename crates-tauri/{yaak-app-client => yaak-updater}/src/restart.rs (100%) rename {apps/yaak-client/components/core => packages/ui/src/components}/ButtonInfiniteLoading.tsx (100%) create mode 100644 packages/ui/src/components/updates/updateToasts.test.tsx create mode 100644 packages/ui/src/components/updates/updateToasts.tsx create mode 100644 scripts/verify-proxy-bundle.mjs diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index a9bb6c42d..ae60acbc7 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -2,10 +2,24 @@ name: Release App Artifacts on: push: tags: [v*] + # The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and + # keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes. + workflow_dispatch: + inputs: + proxy_dry_run: + description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)" + type: boolean + required: true + default: true + proxy_version: + description: "Version to stamp into the dry-run build" + type: string + required: true + default: "0.0.1-dryrun" jobs: build-artifacts: - if: github.repository == 'mountain-loop/yaak' + if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push' permissions: contents: write @@ -290,3 +304,178 @@ jobs: path: ${{ runner.temp }}/yaak-release-artifacts/ retention-days: 7 if-no-files-found: warn + + # Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing + # with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly. + # + # On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's + # download links pick Yaak's assets by suffix and architecture, and its release audit expects + # Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release + # until the website expects it there. The dry run (workflow_dispatch) runs regardless, and + # only keeps workflow artifacts. + build-proxy: + if: >- + github.repository == 'mountain-loop/yaak' && ( + (github.event_name == 'push' && vars.RELEASE_PROXY == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run) + ) + permissions: + contents: write + + name: Build Yaak Proxy + strategy: + fail-fast: false + matrix: + include: + - target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above). + yaak_arch: "arm64" + lipo_arch: "arm64" + asset_arch: "aarch64" + - target: "x86_64-apple-darwin" # for Intel-based Macs. + yaak_arch: "x64" + lipo_arch: "x86_64" + asset_arch: "x64" + runs-on: macos-latest + timeout-minutes: 240 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }} + # The tag, as Yaak's version is taken, or the dry run's own + PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }} + steps: + - name: Checkout yaakapp/app + uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + # A cache of its own, so the proxy's builds never change what Yaak's jobs restore + - uses: Swatinem/rust-cache@v2 + with: + shared-key: release-proxy + key: ${{ matrix.target }} + cache-on-failure: true + + - name: Install Protoc for plugin-runtime + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - run: vp install + + - name: Set version + run: npm run replace-version -- proxy + env: + YAAK_VERSION: ${{ env.PROXY_VERSION }} + + - name: Import signing certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + rm "$RUNNER_TEMP/certificate.p12" + # Lets codesign use the key unattended; without it the first signature can wait on a + # prompt nobody answers + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null + security list-keychain -d user -s "$KEYCHAIN_PATH" + + # Builds the helper for the target and signs it and protoc before bundling (the bundle + # config's before-build step), then signs, notarizes and staples the app and makes the + # updater archive and its signature. + - name: Build, sign and notarize + run: >- + npm run proxy:bundle -- + --target ${{ matrix.target }} + --bundles dmg + --config crates-tauri/yaak-app-proxy/tauri.release.conf.json + env: + CI: true + YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # The helper only answers an app signed by this team + YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + + - name: Verify the bundle + run: >- + node scripts/verify-proxy-bundle.mjs + "target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app" + --signed + --version "$PROXY_VERSION" + --arch ${{ matrix.lipo_arch }} + --team "$YAAK_PROXY_TEAM_ID" + env: + YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets. + # The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs). + - name: Collect the release assets + run: | + set -euo pipefail + bundle="target/${{ matrix.target }}/release/bundle" + version="${PROXY_VERSION#v}" + out="$RUNNER_TEMP/yaak-proxy-release" + mkdir -p "$out" + dmgs=("$bundle"/dmg/*.dmg) + if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then + echo "Expected one .dmg in $bundle/dmg"; exit 1 + fi + cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg" + cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz" + cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig" + (cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt) + ls -l "$out" + + - name: Keep the bundles as workflow artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/yaak-proxy-release/ + retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }} + if-no-files-found: error + + # Yaak's jobs create the draft release; this waits for it rather than making its own, + # which could race theirs into two drafts. + - name: Upload to the release + if: env.DRY_RUN != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + for attempt in $(seq 1 120); do + if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi + if [ "$attempt" -eq 120 ]; then + echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1 + fi + sleep 60 + done + for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do + for attempt in 1 2 3 4; do + if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then + break + fi + if [ "$attempt" -eq 4 ]; then exit 1; fi + sleep 10 + done + done diff --git a/Cargo.lock b/Cargo.lock index 040fad061..2d425771c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11507,6 +11507,7 @@ dependencies = [ "yaak-tauri-utils", "yaak-templates", "yaak-tls", + "yaak-updater", "yaak-window", "yaak-ws", ] @@ -11522,9 +11523,12 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-os", + "tauri-plugin-updater", + "tokio", "yaak-mac-window", "yaak-proxy-lib", "yaak-rpc", + "yaak-updater", "yaak-window", ] @@ -12093,6 +12097,23 @@ dependencies = [ "yasna 0.6.0", ] +[[package]] +name = "yaak-updater" +version = "0.1.0" +dependencies = [ + "log 0.4.34", + "md5", + "nanoid", + "serde", + "serde_json", + "tauri", + "tauri-plugin-dialog", + "tauri-plugin-updater", + "thiserror 2.0.21", + "tokio", + "ts-rs", +] + [[package]] name = "yaak-wasm" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5e29d0da6..639762a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "crates-tauri/yaak-proxy-helper", "crates-tauri/yaak-system-appearance", "crates-tauri/yaak-tauri-utils", + "crates-tauri/yaak-updater", "crates-tauri/yaak-window", ] @@ -116,6 +117,7 @@ yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" } yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } +yaak-updater = { path = "crates-tauri/yaak-updater" } yaak-window = { path = "crates-tauri/yaak-window" } [profile.release] diff --git a/apps/yaak-client/lib/atoms.ts b/apps/yaak-client/lib/atoms.ts index 3345f9d8b..17081e905 100644 --- a/apps/yaak-client/lib/atoms.ts +++ b/apps/yaak-client/lib/atoms.ts @@ -1,5 +1,5 @@ import deepEqual from "@gilbarbara/deep-equal"; -import type { UpdateInfo } from "@yaakapp-internal/tauri-client"; +import type { UpdateInfo } from "@yaakapp-internal/tauri-updater"; import type { Atom } from "jotai"; import { atom } from "jotai"; import { selectAtom } from "jotai/utils"; diff --git a/apps/yaak-client/lib/initGlobalListeners.tsx b/apps/yaak-client/lib/initGlobalListeners.tsx index c84d0497a..8b14f4457 100644 --- a/apps/yaak-client/lib/initGlobalListeners.tsx +++ b/apps/yaak-client/lib/initGlobalListeners.tsx @@ -6,16 +6,19 @@ import type { ShowToastRequest, } from "@yaakapp-internal/plugins"; import { updateAllPlugins } from "@yaakapp-internal/plugins"; -import type { - PluginUpdateNotification, - UpdateInfo, - UpdateResponse, - YaakNotification, -} from "@yaakapp-internal/tauri-client"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import type { PluginUpdateNotification, YaakNotification } from "@yaakapp-internal/tauri-client"; +import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater"; +import { + ButtonInfiniteLoading, + HStack, + Icon, + showUpdateAvailableToast as showUpdateAvailableToastIn, + showUpdateInstalledToast as showUpdateInstalledToastIn, + showUpdateStateToast as showUpdateStateToastIn, + VStack, +} from "@yaakapp-internal/ui"; import { openSettings } from "../commands/openSettings"; import { Button } from "../components/core/Button"; -import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading"; // Listen for toasts import { platform } from "@yaakapp-internal/platform"; @@ -136,6 +139,11 @@ export function initGlobalListeners() { void showUpdateAvailableToast(payload); }); + // A check asked for while an update is already in hand says where it stands + platform.listen("update_state", (state) => { + showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state }); + }); + platform.listen("notification", (payload) => { console.log("Got notification event", payload); showNotificationToast(payload); @@ -149,131 +157,35 @@ export function initGlobalListeners() { } function showUpdateInstalledToast(version: string) { - const UPDATE_TOAST_ID = "update-info"; - - showToast({ - id: UPDATE_TOAST_ID, - color: "primary", - timeout: null, - message: ( - -

Yaak {version} was installed

-

Start using the new version now?

-
- ), - action: ({ hide }) => ( - { - hide(); - setTimeout(() => rpc("cmd_restart", {}), 200); - }} - > - Relaunch Yaak - - ), + showUpdateInstalledToastIn(jotaiStore, { + appName: "Yaak", + version, + onRelaunch: () => rpc("cmd_restart", {}), }); } async function showUpdateAvailableToast(updateInfo: UpdateInfo) { - const UPDATE_TOAST_ID = "update-info"; const { version, replyEventId, downloaded, install } = updateInfo; jotaiStore.set(updateAvailableAtom, { version, downloaded, install }); - const whatsNewButton = ( - - ); - - if (install !== "integrated") { - // Nothing to reply to here; the backend only told us so we can say how to update - const flatpak = install === "flatpak"; - showToast({ - id: UPDATE_TOAST_ID, - color: "info", - timeout: null, - message: ( - -

Yaak {version} is available

-

- {flatpak ? ( - <> - Update with flatpak update or your software center. - - ) : ( - "Download the new version to upgrade." - )} -

-
- ), - action: () => ( - - {!flatpak && ( - - )} - {whatsNewButton} - - ), - }); - return; + // Nothing to reply to otherwise; the backend only told us so we can say how to update + if (install === "integrated") { + // Acknowledge the event, so we don't time out and try the fallback update logic + await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse); } - // Acknowledge the event, so we don't time out and try the fallback update logic - await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse); - - showToast({ - id: UPDATE_TOAST_ID, - color: "info", - timeout: null, - message: ( - -

Yaak {version} is available

-

- {downloaded ? "Do you want to install" : "Download and install"} the update? -

-
- ), - action: () => ( - - { - await platform.emit(replyEventId, { - type: "action", - action: "install", - } satisfies UpdateResponse); - }} - > - {downloaded ? "Install Now" : "Download and Install"} - - {whatsNewButton} - - ), + showUpdateAvailableToastIn(jotaiStore, { + appName: "Yaak", + update: updateInfo, + whatsNewUrl: `https://yaak.app/changelog/${version}`, + downloadUrl: "https://yaak.app/download", + onOpenUrl: (url) => platform.openUrl(url), + onInstall: () => + platform.emit(replyEventId, { + type: "action", + action: "install", + } satisfies UpdateResponse), }); } diff --git a/apps/yaak-proxy/components/Settings.tsx b/apps/yaak-proxy/components/Settings.tsx index 0904f0ffd..e18ac39c7 100644 --- a/apps/yaak-proxy/components/Settings.tsx +++ b/apps/yaak-proxy/components/Settings.tsx @@ -16,13 +16,14 @@ import { type SettingsTitlebar, Toasts, } from "@yaakapp-internal/ui"; -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { settingsValuesAtom } from "../lib/interfaceSettings"; import { openLink, revealPath } from "../lib/opener"; import { proxySettingsView } from "../lib/settings"; import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; +import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates"; import { openSettingsVia, requestedSettingsTab, @@ -125,9 +126,16 @@ export function SettingsContent({ const [values, change] = useAtom(settingsValuesAtom); const app = useAppInfo(); const systemAppearance = useSystemAppearance(); + const store = useStore(); + const checking = useAtomValue(updateCheckingAtom); + const { data: hasUpdater = false } = useQuery({ + queryKey: ["updater_enabled"], + queryFn: updaterEnabled, + retry: false, + }); const view = useMemo( - () => proxySettingsView(values, app, systemAppearance), - [values, app, systemAppearance], + () => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null), + [values, app, systemAppearance, hasUpdater, checking], ); const showToast = useShowToast(); const callbacks = useMemo(() => { @@ -136,14 +144,14 @@ export function SettingsContent({ onChange: (patch) => change(patch), // The proxy draws its own title bar always; the row isn't shown onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }), - onCheckForUpdates: () => undefined, + onCheckForUpdates: () => void checkForUpdatesNow(store), onActivateLicense: async () => undefined, onDeactivateLicense: () => undefined, onOpenUrl: (url) => void openLink(url, fail), onRevealPath: (path) => void revealPath(path, fail), pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`, }; - }, [change, showToast]); + }, [change, showToast, store]); const props = { view, callbacks }; return ( diff --git a/apps/yaak-proxy/lib/settings.test.ts b/apps/yaak-proxy/lib/settings.test.ts index aa892b73b..42a65ed7f 100644 --- a/apps/yaak-proxy/lib/settings.test.ts +++ b/apps/yaak-proxy/lib/settings.test.ts @@ -67,6 +67,18 @@ describe("proxySettingsView", () => { ]); expect(view.app.name).toBe("Yaak Proxy"); }); + + test("shows Updates in a build with the updater, without Yaak's notifications row", () => { + const view = proxySettingsView(DEFAULT_SETTINGS_VALUES, APP, "light", { checking: true }); + expect(view.updates).toEqual({ checking: true, notificationsSetting: false }); + }); + + test("follows the beta channel until set otherwise", () => { + expect(DEFAULT_SETTINGS_VALUES.updateChannel).toBe("beta"); + expect(readSettingsValues(JSON.stringify({ updateChannel: "stable" })).updateChannel).toBe( + "stable", + ); + }); }); describe("size options", () => { diff --git a/apps/yaak-proxy/lib/settings.ts b/apps/yaak-proxy/lib/settings.ts index 117d83ad9..c90df14b5 100644 --- a/apps/yaak-proxy/lib/settings.ts +++ b/apps/yaak-proxy/lib/settings.ts @@ -4,6 +4,7 @@ import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme/src import type { SettingsAppInfo, SettingsThemeOption, + SettingsUpdatesView, SettingsValues, SettingsView, } from "@yaakapp-internal/ui"; @@ -22,7 +23,8 @@ export const DEFAULT_SETTINGS_VALUES: SettingsValues = { coloredMethods: false, useNativeTitlebar: false, hideWindowControls: false, - updateChannel: "stable", + // Early access ships on Yaak's beta tags, so the proxy follows beta until it goes stable + updateChannel: "beta", autoupdate: true, autoDownloadUpdates: true, checkNotifications: true, @@ -70,13 +72,15 @@ export function resolveAppearance(appearance: string, system: "light" | "dark"): /** * The proxy's settings as the shared tabs render them. The proxy has Yaak's two themes, no - * installed-font list, updater or feedback prompts yet, and its license comes with the license - * work, so those parts are left out, as are the Interface rows it doesn't act on. + * installed-font list or feedback prompts yet, and its license comes with the license work, so + * those parts are left out, as are the Interface rows it doesn't act on. `updates` is null in + * a build without the updater, which leaves its section out too. */ export function proxySettingsView( values: SettingsValues, app: SettingsAppInfo, systemAppearance: "light" | "dark", + updates: SettingsUpdatesView | null = null, ): SettingsView { const light = THEMES.find((t) => t.id === values.themeLight && !t.dark) ?? lightOption(); const dark = THEMES.find((t) => t.id === values.themeDark && t.dark) ?? darkOption(); @@ -93,7 +97,8 @@ export function proxySettingsView( }, fonts: null, windowControlsSetting: false, - updates: null, + // The proxy fetches no notifications, so its Updates section has no row for them + updates: updates && { ...updates, notificationsSetting: false }, feedbackPrompts: false, license: null, // Not acted on in the proxy yet: its editors take no keymap or wrap setting, its list draws diff --git a/apps/yaak-proxy/lib/tauri.ts b/apps/yaak-proxy/lib/tauri.ts index b53365e1a..5d778dd0c 100644 --- a/apps/yaak-proxy/lib/tauri.ts +++ b/apps/yaak-proxy/lib/tauri.ts @@ -24,6 +24,19 @@ export function subscribe(event: string, callback: (payload: T) => void): () return subscription.unsubscribe; } +/** + * Subscribe to a Tauri event sent to this window, such as the update prompt, which belongs to + * the window that asked. Returns an unsubscribe function, as `subscribe` does. + */ +export function subscribeHere(event: string, callback: (payload: T) => void): () => void { + const subscription = unsubscribeWhenReady( + getCurrentWebviewWindow().listen(event, (e) => { + if (subscription.active()) callback(e.payload); + }), + ); + return subscription.unsubscribe; +} + /** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */ export function onWindowFocus(callback: () => void): () => void { const subscription = unsubscribeWhenReady( diff --git a/apps/yaak-proxy/lib/updates.test.ts b/apps/yaak-proxy/lib/updates.test.ts new file mode 100644 index 000000000..d4faea47e --- /dev/null +++ b/apps/yaak-proxy/lib/updates.test.ts @@ -0,0 +1,87 @@ +import { toastsAtom } from "@yaakapp-internal/ui/toast"; +import { atom, createStore } from "jotai"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { DEFAULT_SETTINGS_VALUES } from "./settings"; + +const command = vi.fn<(cmd: string, args?: Record) => Promise>(); + +vi.mock("./tauri", () => ({ + command: (cmd: string, args?: Record) => command(cmd, args), + broadcast: vi.fn(), + onWindowFocus: vi.fn(), + subscribeHere: vi.fn(), +})); +vi.mock("./opener", () => ({ openLink: vi.fn() })); +// Outside the app there's no Tauri for the shared components' platform +vi.mock("@yaakapp-internal/platform", () => ({ platform: { osType: () => "macos" } })); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "2026.11.0" })); +// The real atom reads local storage as it loads, which tests have none of +vi.mock("./interfaceSettings", () => ({ settingsValuesAtom: atom(DEFAULT_SETTINGS_VALUES) })); + +const { checkArgs, checkForUpdatesNow, updateCheckingAtom } = await import("./updates"); + +async function messages(store: ReturnType) { + // Toasts are added on the next tick + await new Promise((resolve) => setTimeout(resolve, 0)); + return store.get(toastsAtom).map((t) => ({ color: t.color, message: t.message })); +} + +describe("checkArgs", () => { + test("sends the channel and download choice the settings make", () => { + expect(checkArgs(DEFAULT_SETTINGS_VALUES, false)).toEqual({ + channel: "beta", + background: false, + autoDownload: true, + }); + expect( + checkArgs( + { ...DEFAULT_SETTINGS_VALUES, updateChannel: "stable", autoDownloadUpdates: false }, + true, + ), + ).toEqual({ channel: "stable", background: true, autoDownload: false }); + }); +}); + +describe("checkForUpdatesNow", () => { + beforeEach(() => { + command.mockReset(); + }); + + test("says when there is nothing newer", async () => { + command.mockResolvedValue(false); + const store = createStore(); + await checkForUpdatesNow(store); + expect(command).toHaveBeenCalledWith("cmd_check_for_updates", { + channel: "beta", + background: false, + autoDownload: true, + }); + expect(await messages(store)).toEqual([ + { + color: "info", + message: "No update available. You are on the latest version, 2026.11.0.", + }, + ]); + expect(store.get(updateCheckingAtom)).toBe(false); + }); + + test("leaves an update found to the prompt the backend sends", async () => { + command.mockResolvedValue(true); + const store = createStore(); + await checkForUpdatesNow(store); + expect(await messages(store)).toEqual([]); + }); + + test("says, in a toast rather than a dialog, when the server can't be reached", async () => { + command.mockRejectedValue("Updater error: error sending request"); + const store = createStore(); + await checkForUpdatesNow(store); + expect(await messages(store)).toEqual([ + { + color: "danger", + message: "Could not check for updates: Updater error: error sending request", + }, + ]); + expect(store.get(updateCheckingAtom)).toBe(false); + }); +}); diff --git a/apps/yaak-proxy/lib/updates.ts b/apps/yaak-proxy/lib/updates.ts new file mode 100644 index 000000000..0fe129f3c --- /dev/null +++ b/apps/yaak-proxy/lib/updates.ts @@ -0,0 +1,134 @@ +import { getVersion } from "@tauri-apps/api/app"; +import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater"; +import { + minPromiseMillis, + type SettingsValues, + showToast, + showUpdateAvailableToast, + showUpdateInstalledToast, + showUpdateStateToast, +} from "@yaakapp-internal/ui"; +import { atom, type createStore } from "jotai"; +import { settingsValuesAtom } from "./interfaceSettings"; +import { openLink } from "./opener"; +import { broadcast, command, onWindowFocus, subscribeHere } from "./tauri"; + +type Store = ReturnType; + +const APP_NAME = "Yaak Proxy"; + +/** The events the updater sends the window that checked, as Yaak's are named */ +const UPDATE_AVAILABLE_EVENT = "update_available"; +const UPDATE_INSTALLED_EVENT = "update_installed"; +/** Where an update already in hand stands, for a check asked for meanwhile */ +const UPDATE_STATE_EVENT = "update_state"; + +/** The app menu's Check for Updates…, sent to the window it was chosen in */ +const CHECK_FOR_UPDATES_EVENT = "menu_check_for_updates"; + +/** How long after launch, or the window coming forward, a background check waits, as Yaak's */ +const BACKGROUND_CHECK_DELAY_MS = 3000; + +/** A check the user asked for is running, for the Settings button's spinner */ +export const updateCheckingAtom = atom(false); + +/** Whether this build has the updater; development builds don't */ +export function updaterEnabled(): Promise { + return command("cmd_updater_enabled"); +} + +/** What a check sends the backend: the channel and behavior the settings choose */ +export function checkArgs(values: SettingsValues, background: boolean) { + return { + channel: values.updateChannel, + background, + autoDownload: values.autoDownloadUpdates, + }; +} + +/** + * Checks now, as the user asked, and says so when there is nothing newer or the server can't + * be reached. An update found is offered by the prompt the backend sends this window. + */ +export async function checkForUpdatesNow(store: Store): Promise { + if (store.get(updateCheckingAtom)) return; + store.set(updateCheckingAtom, true); + try { + const args = checkArgs(store.get(settingsValuesAtom), false); + const found = await minPromiseMillis(command("cmd_check_for_updates", args), 500); + if (!found) { + const version = await getVersion().catch(() => null); + showToast(store, { + id: "no-updates", + color: "info", + message: + version == null + ? "No update available. You are on the latest version." + : `No update available. You are on the latest version, ${version}.`, + }); + } + } catch (err) { + showToast(store, { + id: "no-updates", + color: "danger", + message: `Could not check for updates: ${String(err)}`, + }); + } finally { + store.set(updateCheckingAtom, false); + } +} + +/** A quiet check, as the settings allow; the backend keeps to the channel's interval */ +function checkInBackground(store: Store) { + const values = store.get(settingsValuesAtom); + if (!values.autoupdate) return; + command("cmd_check_for_updates", checkArgs(values, true)).catch((err) => + console.warn("Failed to check for updates", err), + ); +} + +/** Offers an update the backend found, as Yaak does, and answers it */ +async function offerUpdate(store: Store, info: UpdateInfo) { + const { replyEventId } = info; + const reply = (response: UpdateResponse) => broadcast(replyEventId, response); + // Acknowledged at once, so the backend doesn't fall back to its native dialog + if (info.install === "integrated") await reply({ type: "ack" }); + showUpdateAvailableToast(store, { + appName: APP_NAME, + update: info, + onOpenUrl: (url) => openLink(url, (message) => showToast(store, { message, color: "danger" })), + onInstall: () => reply({ type: "action", action: "install" }), + }); +} + +/** + * Hears the updater in this window: the prompt for an update it found, the offer to relaunch + * once one installed, and the menu's Check for Updates…. With `background`, as the main window + * does, also checks shortly after launch and each time the window comes forward. + */ +export async function syncUpdates(store: Store, { background }: { background: boolean }) { + if (!(await updaterEnabled().catch(() => false))) return; + subscribeHere(UPDATE_AVAILABLE_EVENT, (info) => { + offerUpdate(store, info).catch(console.error); + }); + subscribeHere(UPDATE_STATE_EVENT, (state) => { + showUpdateStateToast(store, { appName: APP_NAME, state }); + }); + subscribeHere(UPDATE_INSTALLED_EVENT, (version) => { + showUpdateInstalledToast(store, { + appName: APP_NAME, + version, + onRelaunch: () => { + command("cmd_restart").catch(console.error); + }, + }); + }); + subscribeHere(CHECK_FOR_UPDATES_EVENT, () => { + checkForUpdatesNow(store).catch(console.error); + }); + if (background) { + const later = () => setTimeout(() => checkInBackground(store), BACKGROUND_CHECK_DELAY_MS); + later(); + onWindowFocus(later); + } +} diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 846ba5405..2c8d693f9 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -11,6 +11,7 @@ import { syncSettingsValues } from "./lib/interfaceSettings"; import { syncNavigation, syncSidebarSelection } from "./lib/navigation"; import { syncFlows, syncProxyState } from "./lib/store"; import { currentWindowLabel } from "./lib/tauri"; +import { syncUpdates } from "./lib/updates"; import { syncFromOtherWindows } from "./lib/windowSync"; import { windowKind } from "./lib/windows"; import "./main.css"; @@ -22,6 +23,8 @@ const settingsWindow = windowKind(currentWindowLabel()) === "settings"; syncProxyState(jotaiStore); syncSettingsValues(jotaiStore); +// Either window shows the updates it checks for; the app's window also checks in the background +syncUpdates(jotaiStore, { background: !settingsWindow }).catch(console.error); syncFromOtherWindows(jotaiStore, queryClient); // Only the app's window lists traffic and navigates it if (!settingsWindow) { diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index 5465d98cd..b6038dd56 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -99,5 +99,6 @@ yaak-system-appearance = { workspace = true } yaak-sync = { workspace = true } yaak-templates = { workspace = true } yaak-tls = { workspace = true } +yaak-updater = { workspace = true } yaak-window = { workspace = true } yaak-ws = { workspace = true } diff --git a/crates-tauri/yaak-app-client/bindings/index.ts b/crates-tauri/yaak-app-client/bindings/index.ts index 55e7db086..3ae010bd5 100644 --- a/crates-tauri/yaak-app-client/bindings/index.ts +++ b/crates-tauri/yaak-app-client/bindings/index.ts @@ -6,26 +6,6 @@ export type PluginUpdateInfo = { name: string; currentVersion: string; latestVer export type PluginUpdateNotification = { updateCount: number; plugins: Array }; -export type UpdateInfo = { - replyEventId: string; - version: string; - downloaded: boolean; - /** - * How this update gets applied. Anything but `Integrated` means the app can't do it - * itself and the user is told how to update instead. - */ - install: UpdateInstall; -}; - -/** - * How an update can be applied to this install. - */ -export type UpdateInstall = "integrated" | "flatpak" | "manual"; - -export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction }; - -export type UpdateResponseAction = "install" | "skip"; - export type YaakNotification = { timestamp: string; timeout: number | null; diff --git a/crates-tauri/yaak-app-client/index.ts b/crates-tauri/yaak-app-client/index.ts index 09eab9366..be3482333 100644 --- a/crates-tauri/yaak-app-client/index.ts +++ b/crates-tauri/yaak-app-client/index.ts @@ -1,4 +1,5 @@ // ts-rs owns bindings/index.ts and rewrites it on export. What remains here // after the RPC schema moved to @yaakapp-internal/rpc-schema is the -// desktop-only surface: updater and notification types. +// desktop-only surface: plugin update and notification types. The app updater's +// types are @yaakapp-internal/tauri-updater's, shared with Yaak Proxy. export * from "./bindings/index"; diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 6619569de..dc998b3ad 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -52,6 +52,7 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_templates::strip_json_comments::strip_json_comments; use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_tls::find_client_certificate; +use yaak_updater::restart; mod encoding; mod error; @@ -67,7 +68,6 @@ mod notifications; mod plugin_events; mod plugins_ext; mod render; -mod restart; mod rpc_ext; mod sync_ext; mod updates; @@ -1333,6 +1333,8 @@ pub fn run() { // Add updater let yaak_updater = YaakUpdater::new(); app.manage(Mutex::new(yaak_updater)); + // The one update this process runs at a time, whichever window or trigger asks + app.manage(yaak_updater::UpdateGate::default()); // Add notifier let yaak_notifier = YaakNotifier::new(); diff --git a/crates-tauri/yaak-app-client/src/updates.rs b/crates-tauri/yaak-app-client/src/updates.rs index ffef3a004..385a09d9c 100644 --- a/crates-tauri/yaak-app-client/src/updates.rs +++ b/crates-tauri/yaak-app-client/src/updates.rs @@ -1,68 +1,24 @@ -use std::fmt::{Display, Formatter}; -use std::path::PathBuf; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::error::Result; use crate::models_ext::QueryManagerExt; -use crate::restart; -use log::{debug, error, info, warn}; -use serde::{Deserialize, Serialize}; -use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow}; -use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; +use log::info; +use tauri::{Manager, Runtime, WebviewWindow}; use tauri_plugin_updater::{Update, UpdaterExt}; use tokio::task::block_in_place; -use tokio::time::sleep; -use ts_rs::TS; -use yaak_models::util::generate_id; use url::Url; use yaak_api::get_system_proxy_url; +use yaak_updater::{UpdateGate, UpdateInstall}; +pub use yaak_updater::{UpdateMode, UpdateTrigger}; -use crate::error::Error::GenericError; use crate::is_dev; -const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12; -const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3; -const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1; - // Create updater struct pub struct YaakUpdater { last_check: Option, } -pub enum UpdateMode { - Stable, - Beta, - Alpha, -} - -impl Display for UpdateMode { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let s = match self { - UpdateMode::Stable => "stable", - UpdateMode::Beta => "beta", - UpdateMode::Alpha => "alpha", - }; - write!(f, "{}", s) - } -} - -impl UpdateMode { - pub fn new(mode: &str) -> UpdateMode { - match mode { - "beta" => UpdateMode::Beta, - "alpha" => UpdateMode::Alpha, - _ => UpdateMode::Stable, - } - } -} - -#[derive(PartialEq)] -pub enum UpdateTrigger { - Background, - User, -} - impl YaakUpdater { pub fn new() -> Self { Self { last_check: None } @@ -75,6 +31,20 @@ impl YaakUpdater { auto_download: bool, update_trigger: UpdateTrigger, ) -> Result { + // One check and one update at a time, whichever window or trigger asks. The user who + // asks while one is in hand sees where it stands, so there's no "no update" to show. + let token = match window.state::().begin_check() { + Ok(token) => token, + Err(busy) => { + info!("Not checking for updates: the update is {:?}", busy.state); + if update_trigger == UpdateTrigger::User { + busy.show(window); + return Ok(true); + } + return Ok(false); + } + }; + let settings = window.db().get_settings(); let update_key = format!("{:x}", md5::compute(settings.id)); self.last_check = Some(Instant::now()); @@ -105,13 +75,7 @@ impl YaakUpdater { }) .header("X-Update-Mode", mode.to_string())? .header("X-Update-Key", update_key)? - .header( - "X-Update-Trigger", - match update_trigger { - UpdateTrigger::Background => "background", - UpdateTrigger::User => "user", - }, - )? + .header("X-Update-Trigger", update_trigger.header_value())? .header("X-Install-Mode", detect_install_mode().unwrap_or("unknown"))? .build()? .check() @@ -120,57 +84,8 @@ impl YaakUpdater { let result = match update_check_result? { None => false, Some(update) => { - let w = window.clone(); - tauri::async_runtime::spawn(async move { - // Only hand the artifact to the updater plugin when this install can - // apply it itself; otherwise tell the user how to update instead - let install = update_install_method(&update); - if install != UpdateInstall::Integrated { - info!( - "{} available, but this install updates via {install:?}", - update.version - ); - notify_external_update(&w, &update, install); - return; - } - - // Force native updater if specified (useful if a release broke the UI) - let native_install_mode = - update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default() - == Some("native"); - if native_install_mode { - start_native_update(&w, &update).await; - return; - } - - // If it's a background update, try downloading it first - if update_trigger == UpdateTrigger::Background && auto_download { - info!("Downloading update {} in background", update.version); - if let Err(e) = download_update_idempotent(&w, &update).await { - error!("Failed to download {}: {}", update.version, e); - } - } - - match start_integrated_update(&w, &update).await { - Ok(UpdateResponseAction::Skip) => { - info!("Confirmed {}: skipped", update.version); - } - Ok(UpdateResponseAction::Install) => { - info!("Confirmed {}: install", update.version); - if let Err(e) = install_update_maybe_download(&w, &update).await { - error!("Failed to install: {e}"); - return; - }; - - info!("Installed {}", update.version); - finish_integrated_update(&w, &update).await; - } - Err(e) => { - warn!("Failed to notify frontend, falling back: {e}",); - start_native_update(&w, &update).await; - } - }; - }); + let install = update_install_method(&update); + token.offer(window, update, update_trigger, auto_download, install); true } }; @@ -183,14 +98,8 @@ impl YaakUpdater { auto_download: bool, mode: UpdateMode, ) -> Result { - let update_period_seconds = match mode { - UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE, - UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA, - UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA, - } * (60 * 60); - if let Some(i) = self.last_check - && i.elapsed().as_secs() < update_period_seconds + && i.elapsed() < mode.check_interval() { return Ok(false); } @@ -204,208 +113,6 @@ impl YaakUpdater { } } -#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)] -#[serde(default, rename_all = "camelCase")] -#[ts(export, export_to = "index.ts")] -struct UpdateInfo { - reply_event_id: String, - version: String, - downloaded: bool, - /// How this update gets applied. Anything but `Integrated` means the app can't do it - /// itself and the user is told how to update instead. - install: UpdateInstall, -} - -/// How an update can be applied to this install. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export, export_to = "index.ts")] -enum UpdateInstall { - /// The app downloads and installs it itself - #[default] - Integrated, - /// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark) - Flatpak, - /// Nothing can install it in-app (distro package, Nix, unknown); download by hand - Manual, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "camelCase", tag = "type")] -#[ts(export, export_to = "index.ts")] -enum UpdateResponse { - Ack, - Action { action: UpdateResponseAction }, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export, export_to = "index.ts")] -enum UpdateResponseAction { - Install, - Skip, -} - -async fn finish_integrated_update(window: &WebviewWindow, update: &Update) { - if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) { - warn!("Failed to notify frontend of update install: {}", e); - } -} - -async fn start_integrated_update( - window: &WebviewWindow, - update: &Update, -) -> Result { - let download_path = ensure_download_dir(window)?.join(download_file_name(update)); - debug!("Download path: {}", download_path.display()); - let downloaded = download_path.exists(); - let ack_wait = Duration::from_secs(3); - let reply_id = generate_id(); - - // 1) Start listening BEFORE emitting to avoid missing a fast reply - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - let w_for_listener = window.clone(); - - let event_id = w_for_listener.listen(reply_id.clone(), move |ev| { - match serde_json::from_str::(ev.payload()) { - Ok(UpdateResponse::Ack) => { - let _ = tx.send(UpdateResponse::Ack); - } - Ok(UpdateResponse::Action { action }) => { - let _ = tx.send(UpdateResponse::Action { action }); - } - Err(e) => { - warn!("Failed to parse update reply from frontend: {e:?}"); - } - } - }); - - // Make sure we always unlisten - struct Unlisten<'a, R: Runtime> { - win: &'a WebviewWindow, - id: tauri::EventId, - } - impl<'a, R: Runtime> Drop for Unlisten<'a, R> { - fn drop(&mut self) { - self.win.unlisten(self.id); - } - } - let _guard = Unlisten { win: window, id: event_id }; - - // 2) Emit the event now that listener is in place - let info = UpdateInfo { - version: update.version.to_string(), - downloaded, - install: UpdateInstall::Integrated, - reply_event_id: reply_id, - }; - window - .emit_to(window.label(), "update_available", &info) - .map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?; - - // 3) Two-stage timeout: first wait for ack, then wait for final action - // --- Phase 1: wait for ACK with timeout --- - let ack_timer = sleep(ack_wait); - tokio::pin!(ack_timer); - - loop { - tokio::select! { - msg = rx.recv() => match msg { - Some(UpdateResponse::Ack) => break, // proceed to Phase 2 - Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast - None => return Err(GenericError("frontend channel closed before ack".into())), - }, - _ = &mut ack_timer => { - return Err(GenericError("timed out waiting for frontend ack".into())); - } - } - } - - // --- Phase 2: wait forever for final action --- - loop { - match rx.recv().await { - Some(UpdateResponse::Action { action }) => return Ok(action), - Some(UpdateResponse::Ack) => { /* ignore extra acks */ } - None => return Err(GenericError("frontend channel closed before action".into())), - } - } -} - -/// Tell the frontend about an update this install can't apply itself, so the user can be -/// told how to get it. Unlike the integrated flow, there is nothing to reply to. -fn notify_external_update( - window: &WebviewWindow, - update: &Update, - install: UpdateInstall, -) { - let info = UpdateInfo { - version: update.version.to_string(), - downloaded: false, - install, - reply_event_id: generate_id(), - }; - if let Err(e) = window.emit_to(window.label(), "update_available", &info) { - warn!("Failed to emit update_available: {e}"); - } -} - -async fn start_native_update(window: &WebviewWindow, update: &Update) { - // If the frontend doesn't respond, fallback to native dialogs - let confirmed = window - .dialog() - .message(format!( - "{} is available. Would you like to download and install it now?", - update.version - )) - .buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string())) - .title("Update Available") - .blocking_show(); - if !confirmed { - return; - } - - match update.download_and_install(|_, _| {}, || {}).await { - Ok(()) => { - if window - .dialog() - .message("Would you like to restart the app?") - .title("Update Installed") - .buttons(MessageDialogButtons::OkCancelCustom( - "Restart".to_string(), - "Later".to_string(), - )) - .blocking_show() - { - restart::request_restart(window.app_handle()); - } - } - Err(e) => { - window.dialog().message(format!("The update failed to install: {}", e)); - } - } -} - -pub async fn download_update_idempotent( - window: &WebviewWindow, - update: &Update, -) -> Result { - let dl_path = ensure_download_dir(window)?.join(download_file_name(update)); - - if dl_path.exists() { - info!("{} already downloaded to {}", update.version, dl_path.display()); - return Ok(dl_path); - } - - info!("{} downloading: {}", update.version, dl_path.display()); - let dl_bytes = update.download(|_, _| {}, || {}).await?; - std::fs::write(&dl_path, dl_bytes) - .map_err(|e| GenericError(format!("Failed to write update: {e}")))?; - - info!("{} downloaded", update.version); - - Ok(dl_path) -} - /// Detect the installer type so the update server can serve the correct artifact. fn detect_install_mode() -> Option<&'static str> { #[cfg(target_os = "windows")] @@ -551,44 +258,3 @@ mod tests { } } } - -pub async fn install_update_maybe_download( - window: &WebviewWindow, - update: &Update, -) -> Result<()> { - let dl_path = download_update_idempotent(window, update).await?; - let update_bytes = std::fs::read(&dl_path)?; - update.install(update_bytes.as_slice())?; - delete_download_dir(window); - Ok(()) -} - -pub fn download_dir(window: &WebviewWindow) -> Result { - Ok(window.path().app_cache_dir()?.join("updates")) -} - -pub fn ensure_download_dir(window: &WebviewWindow) -> Result { - let base_dir = download_dir(window)?; - std::fs::create_dir_all(&base_dir)?; - Ok(base_dir) -} - -pub fn download_file_name(update: &Update) -> String { - let sig_digest = md5::compute(&update.signature); - format!("yaak-{}-{:x}", update.version, sig_digest) -} - -pub fn delete_download_dir(window: &WebviewWindow) { - let base_dir = match download_dir(window) { - Ok(dir) => dir, - Err(e) => { - warn!("Failed to locate update downloads dir: {}", e); - return; - } - }; - match std::fs::remove_dir_all(&base_dir) { - Ok(()) => info!("Removed update downloads dir {}", base_dir.display()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e), - } -} diff --git a/crates-tauri/yaak-app-proxy/Cargo.toml b/crates-tauri/yaak-app-proxy/Cargo.toml index b9101b213..f291d48b9 100644 --- a/crates-tauri/yaak-app-proxy/Cargo.toml +++ b/crates-tauri/yaak-app-proxy/Cargo.toml @@ -15,6 +15,8 @@ tauri-build = { version = "2.6.1", features = [] } [features] default = ["wry"] wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"] +# Checks for and installs updates, as release builds do (`tauri.release.conf.json`). +updater = ["dep:tauri-plugin-updater"] [dependencies] log = { workspace = true } @@ -23,7 +25,15 @@ tauri = { workspace = true, features = ["devtools"] } tauri-plugin-dialog = { workspace = true } tauri-plugin-opener = "2.5.4" tauri-plugin-os = "2.3.2" +tauri-plugin-updater = { version = "2.10.1", optional = true } +tokio = { workspace = true, features = ["sync"] } yaak-mac-window = { workspace = true } yaak-proxy-lib = { workspace = true } yaak-rpc = { workspace = true } +yaak-updater = { workspace = true } yaak-window = { workspace = true } + +[dev-dependencies] +# A mock app for the update checks' tests +tauri = { workspace = true, features = ["test"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index 6b6042059..5d01ab001 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -6,6 +6,7 @@ use yaak_proxy_lib::{MasterKeySource, ProxyCtx}; use yaak_rpc::{RpcEventEmitter, RpcRouter}; use yaak_window::window::CreateWindowConfig; +mod updates; mod window_menu; fn setup_window_menu(win: &WebviewWindow) { @@ -47,6 +48,14 @@ fn handle_menu_events(win: &WebviewWindow) { error!("Failed to emit the HAR export: {e}"); } } + // To the window it was chosen in alone, which checks and shows what it finds + window_menu::CHECK_FOR_UPDATES_ID => { + if let Err(e) = + webview_window.emit_to(w.label(), window_menu::CHECK_FOR_UPDATES_EVENT, ()) + { + error!("Failed to emit {}: {e}", window_menu::CHECK_FOR_UPDATES_EVENT); + } + } window_menu::GO_BACK_ID | window_menu::GO_FORWARD_ID => { let event = if event_id == window_menu::GO_BACK_ID { window_menu::GO_BACK_EVENT @@ -165,11 +174,22 @@ fn set_go_menu(app: tauri::AppHandle, back: bool, forward: bool) { } pub fn run() { - tauri::Builder::default() + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) - .plugin(yaak_mac_window::init()) + .plugin(yaak_mac_window::init()); + + #[cfg(feature = "updater")] + { + builder = builder.plugin(tauri_plugin_updater::Builder::default().build()); + } + + builder + .manage(tokio::sync::Mutex::new(updates::ProxyUpdater::default())) + // The one update this process runs at a time, whichever window or trigger asks + .manage(yaak_updater::UpdateGate::default()) .setup(|app| { let data_dir = app.path().app_data_dir().expect("no app data dir"); std::fs::create_dir_all(&data_dir).expect("failed to create app data dir"); @@ -217,7 +237,14 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![rpc, set_go_menu, cmd_open_settings]) + .invoke_handler(tauri::generate_handler![ + rpc, + set_go_menu, + cmd_open_settings, + updates::cmd_updater_enabled, + updates::cmd_check_for_updates, + updates::cmd_restart + ]) .build(tauri::generate_context!()) .expect("error while building yaak proxy tauri application") .run(|app_handle, event| { @@ -226,6 +253,8 @@ pub fn run() { let ctx = app_handle.state::(); ctx.shutdown(); ctx.stop_control_api(); + // Relaunches after an update, once the system proxy is put back + yaak_updater::restart::relaunch_if_requested(); return; } if let RunEvent::Ready = event { diff --git a/crates-tauri/yaak-app-proxy/src/updates.rs b/crates-tauri/yaak-app-proxy/src/updates.rs new file mode 100644 index 000000000..05b1b0a4b --- /dev/null +++ b/crates-tauri/yaak-app-proxy/src/updates.rs @@ -0,0 +1,592 @@ +//! Yaak Proxy's update check. The prompt, download, install and relaunch are Yaak's, shared +//! through `yaak-updater`; what differs is where the proxy asks and what it accepts. +//! +//! The channel and update behavior are the shared settings tabs' values, which the window +//! keeps, so the window passes them with each check. +//! +//! A build without the `updater` feature, as development builds are, keeps the commands, which +//! say there is no updater, but uses none of the checking itself. +#![cfg_attr(not(feature = "updater"), allow(dead_code, unused_imports))] + +use std::hash::BuildHasher; +use std::time::Instant; + +use log::{info, warn}; +use tauri::{Manager, Runtime, State, WebviewWindow}; +use tokio::sync::Mutex; +use yaak_updater::{UpdateGate, UpdateInstall, UpdateMode, UpdateTrigger}; + +/// Where Yaak Proxy asks for updates. The `/proxy/` segment tells the update server which app +/// is asking; Yaak asks at `/check/...` and its answers are unaffected. A server that doesn't +/// know the proxy yet answers 404, which is a failed check here, never Yaak's update. +pub const UPDATE_ENDPOINT: &str = + "https://update.yaak.app/proxy/check/{{target}}/{{arch}}/{{current_version}}"; + +/// How every Yaak Proxy updater artifact's file name starts, as the release workflow uploads +/// it. An update whose file is named otherwise, such as Yaak's own `Yaak_aarch64.app.tar.gz`, +/// is refused: it is signed by the same key, so the signature alone would let it replace the +/// proxy with Yaak. +pub const ARTIFACT_PREFIX: &str = "Yaak.Proxy_"; + +/// The file in the app's data directory holding the random key the update server buckets +/// gradual rollouts by, so a Mac keeps its place in a rollout. +const UPDATE_KEY_FILE: &str = "update-key"; + +/// When the last check ran, so background checks keep to their channel's interval. +#[derive(Default)] +pub struct ProxyUpdater { + last_check: Option, +} + +/// Whether this build has the updater, so the window shows its settings and menu item. +#[tauri::command] +pub fn cmd_updater_enabled() -> bool { + cfg!(feature = "updater") +} + +/// Asks the update server for a newer Yaak Proxy on `channel` and, when there is one, offers +/// it in this window. True when an update was found. A background check returns false at once +/// when the channel's interval since the last check hasn't passed. +#[tauri::command] +pub async fn cmd_check_for_updates( + window: WebviewWindow, + updater: State<'_, Mutex>, + channel: String, + background: bool, + auto_download: bool, +) -> Result { + let mode = UpdateMode::new(&channel); + let trigger = if background { UpdateTrigger::Background } else { UpdateTrigger::User }; + let mut updater = updater.lock().await; + if trigger == UpdateTrigger::Background + && let Some(last) = updater.last_check + && last.elapsed() < mode.check_interval() + { + return Ok(false); + } + updater.last_check = Some(Instant::now()); + drop(updater); + check(&window, mode, trigger, auto_download).await +} + +/// Relaunches the app after an update installed, through LaunchServices on macOS. +#[tauri::command] +pub fn cmd_restart(app_handle: tauri::AppHandle) { + yaak_updater::restart::request_restart(&app_handle); +} + +#[cfg(feature = "updater")] +async fn check( + window: &WebviewWindow, + mode: UpdateMode, + trigger: UpdateTrigger, + auto_download: bool, +) -> Result { + use tauri_plugin_updater::UpdaterExt; + + // One check and one update at a time, whichever window or trigger asks + let token = match window.state::().begin_check() { + Ok(token) => token, + Err(busy) => { + info!("Not checking for updates: the update is {:?}", busy.state); + if trigger == UpdateTrigger::User { + busy.show(window); + return Ok(true); + } + return Ok(false); + } + }; + + info!("Checking for updates mode={mode} trigger={}", trigger.header_value()); + let endpoint = tauri::Url::parse(&endpoint()).map_err(|e| e.to_string())?; + let update = window + .updater_builder() + .endpoints(vec![endpoint]) + .and_then(|b| b.header("X-Update-Mode", mode.to_string())) + .and_then(|b| b.header("X-Update-Key", update_key(window))) + .and_then(|b| b.header("X-Update-Trigger", trigger.header_value())) + .and_then(|b| b.header("X-Install-Mode", "unknown")) + .and_then(|b| b.build()) + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string())?; + + let Some(update) = update else { + return Ok(false); + }; + let file = artifact_file_name(update.download_url.path()); + if !is_proxy_artifact(file) { + warn!( + "Refused update {} at {}: not a Yaak Proxy artifact", + update.version, update.download_url + ); + return Err(format!("The update server offered {file}, which is not a Yaak Proxy update")); + } + + token.offer(window, update, trigger, auto_download, UpdateInstall::Integrated); + Ok(true) +} + +#[cfg(not(feature = "updater"))] +async fn check( + _window: &WebviewWindow, + _mode: UpdateMode, + _trigger: UpdateTrigger, + _auto_download: bool, +) -> Result { + Err("This build of Yaak Proxy has no updater".to_string()) +} + +/// Where checks go: [`UPDATE_ENDPOINT`], or, in a debug build only, `YAAK_PROXY_UPDATE_ENDPOINT` +/// when it is set, to try the flow against a local server. +#[cfg(feature = "updater")] +fn endpoint() -> String { + #[cfg(debug_assertions)] + if let Ok(url) = std::env::var("YAAK_PROXY_UPDATE_ENDPOINT") + && !url.is_empty() + { + warn!("Checking for updates at {url}, from YAAK_PROXY_UPDATE_ENDPOINT"); + return url; + } + UPDATE_ENDPOINT.to_string() +} + +/// The last segment of a download URL's path. +fn artifact_file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +/// Whether a file is one of the proxy's updater artifacts, by the name the release gives it. +fn is_proxy_artifact(file: &str) -> bool { + file.starts_with(ARTIFACT_PREFIX) +} + +/// This Mac's update key, made once and kept in the data directory. Without one, the server +/// buckets by address, which is still deterministic, so a failure here is only logged. +fn update_key(window: &WebviewWindow) -> String { + // Tests leave the data directory alone; a mock app's would be Application Support itself + if cfg!(test) { + return "test".to_string(); + } + let Ok(dir) = window.path().app_data_dir() else { + return String::new(); + }; + let path = dir.join(UPDATE_KEY_FILE); + if let Ok(key) = std::fs::read_to_string(&path) + && !key.trim().is_empty() + { + return key.trim().to_string(); + } + // Random per process: each `RandomState` is seeded afresh + let random = || std::collections::hash_map::RandomState::new().hash_one(std::process::id()); + let key = format!("{:016x}{:016x}", random(), random()); + if let Err(e) = std::fs::create_dir_all(&dir) { + warn!("Failed to create the data directory for the update key: {e}"); + } + if let Err(e) = std::fs::write(&path, &key) { + warn!("Failed to save the update key: {e}"); + } + key +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The endpoint is the update server's, with the proxy's own path, so Yaak's is untouched. + #[test] + fn the_endpoint_is_the_proxys_own() { + let url = tauri::Url::parse(UPDATE_ENDPOINT).unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("update.yaak.app")); + assert!(url.path().starts_with("/proxy/check/"), "{}", url.path()); + } + + /// Only the proxy's artifacts install, never Yaak's, which the same key signs. + #[test] + fn only_proxy_artifacts_are_accepted() { + let base = "/mountain-loop/yaak/releases/download/v2026.11.0/"; + for (file, ok) in [ + ("Yaak.Proxy_aarch64.app.tar.gz", true), + ("Yaak.Proxy_x64.app.tar.gz", true), + ("Yaak_aarch64.app.tar.gz", false), + ("Yaak_x64.app.tar.gz", false), + ("Yaak.app.tar.gz", false), + ] { + let name = artifact_file_name(&format!("{base}{file}")).to_string(); + assert_eq!(name, file); + assert_eq!(is_proxy_artifact(&name), ok, "{file}"); + } + } + + /// The release config carries the updater's key but not its endpoint, which is + /// [`UPDATE_ENDPOINT`] alone, and builds the updater artifacts; it never turns on a license. + #[test] + fn the_release_config_has_the_updater_and_no_license() { + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap(); + let features: Vec<&str> = config["build"]["features"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f.as_str()) + .collect(); + assert!(features.contains(&"updater"), "{features:?}"); + assert!(!features.contains(&"license"), "{features:?}"); + assert!(config["plugins"]["updater"]["pubkey"].as_str().is_some_and(|k| !k.is_empty())); + assert!(config["plugins"]["updater"].get("endpoints").is_none()); + assert_eq!(config["bundle"]["createUpdaterArtifacts"], serde_json::json!(true)); + } + + /// The proxy verifies updates with the key Yaak's release signs them with. + #[test] + fn the_updater_key_is_yaaks() { + let proxy: serde_json::Value = + serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap(); + let client: serde_json::Value = + serde_json::from_str(include_str!("../../yaak-app-client/tauri.release.conf.json")) + .unwrap(); + assert_eq!(proxy["plugins"]["updater"]["pubkey"], client["plugins"]["updater"]["pubkey"]); + } +} + +/// The checks every trigger makes (launch, focus, the menu, Settings), raced against a local +/// update server through a mock app with the real updater plugin. +#[cfg(all(test, feature = "updater"))] +mod race_tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::time::Duration; + use tauri::test::{MockRuntime, mock_builder, mock_context, noop_assets}; + use tauri::{Emitter, Listener, WebviewUrl, WebviewWindowBuilder}; + + /// A local update server that always offers 99.0.0 as a proxy artifact. Returns its port. + fn serve_update() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut request = [0u8; 4096]; + let _ = stream.read(&mut request); + if String::from_utf8_lossy(&request).contains(".app.tar.gz") { + // A slow download, to check while it runs. Not a real archive, so the + // install then fails its signature check and the run ends. + std::thread::sleep(Duration::from_millis(1500)); + let body = "not an archive"; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + continue; + } + let version = served_version().clone(); + let body = format!( + r#"{{"version":"{version}","url":"http://127.0.0.1:{port}/Yaak.Proxy_aarch64.app.tar.gz","signature":"unused"}}"# + ); + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + port + } + + /// Where a test app keeps its downloads, removed after each test. + const TEST_IDENTIFIER: &str = "app.yaak.proxy.updates-test"; + + /// The version the test server offers, 99.0.0 unless a test publishes a newer one. + fn served_version() -> std::sync::MutexGuard<'static, String> { + static VERSION: std::sync::Mutex = std::sync::Mutex::new(String::new()); + let mut version = VERSION.lock().unwrap_or_else(|e| e.into_inner()); + if version.is_empty() { + *version = "99.0.0".to_string(); + } + version + } + + /// One test at a time, since they share the endpoint variable, the served version and the + /// downloads directory. + fn serial() -> std::sync::MutexGuard<'static, ()> { + static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + *served_version() = "99.0.0".to_string(); + guard + } + + fn mock_app(port: u16) -> tauri::App { + // SAFETY: only this module's tests read or write it, one at a time + unsafe { + std::env::set_var( + "YAAK_PROXY_UPDATE_ENDPOINT", + format!( + "http://127.0.0.1:{port}/proxy/check/{{{{target}}}}/{{{{arch}}}}/{{{{current_version}}}}" + ), + ); + } + let mut context = mock_context(noop_assets()); + context.config_mut().identifier = TEST_IDENTIFIER.to_string(); + context.config_mut().plugins.0.insert( + "updater".to_string(), + serde_json::json!({ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K" }), + ); + mock_builder() + .plugin(tauri_plugin_updater::Builder::new().build()) + .manage(Mutex::new(ProxyUpdater::default())) + .manage(UpdateGate::default()) + .build(context) + .unwrap() + } + + /// Removes the test app's downloads once the test ends, passing or not. + struct CleanUp(tauri::AppHandle); + + impl Drop for CleanUp { + fn drop(&mut self) { + if let Ok(dir) = self.0.path().app_cache_dir() + && dir.ends_with(TEST_IDENTIFIER) + { + let _ = std::fs::remove_dir_all(dir); + } + } + } + + fn window(app: &tauri::App, label: &str) -> WebviewWindow { + WebviewWindowBuilder::new(app, label, WebviewUrl::default()).build().unwrap() + } + + /// Something the app showed a window about its update. + #[derive(Debug, Clone)] + struct Shown { + window: String, + event: &'static str, + payload: serde_json::Value, + } + + /// Records what the app shows each of `windows` about its update. + fn record(windows: &[&WebviewWindow]) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + for w in windows { + for event in [ + yaak_updater::UPDATE_AVAILABLE_EVENT, + yaak_updater::UPDATE_STATE_EVENT, + yaak_updater::UPDATE_INSTALLED_EVENT, + ] { + let tx = tx.clone(); + let window = w.label().to_string(); + w.listen(event, move |e| { + let payload = serde_json::from_str(e.payload()).unwrap(); + let _ = tx.send(Shown { window: window.clone(), event, payload }); + }); + } + } + rx + } + + /// Everything shown until nothing more comes for a moment, well inside the 3 seconds a + /// prompt waits for its window to answer. + fn drain(rx: &mpsc::Receiver) -> Vec { + let mut all = Vec::new(); + while let Ok(shown) = rx.recv_timeout(Duration::from_millis(700)) { + all.push(shown); + } + all + } + + fn prompts(shown: &[Shown]) -> Vec<&Shown> { + shown.iter().filter(|s| s.event == yaak_updater::UPDATE_AVAILABLE_EVENT).collect() + } + + fn reply_id(prompt: &Shown) -> String { + prompt.payload["replyEventId"].as_str().unwrap().to_string() + } + + /// Answers a prompt as its window's buttons do. + fn answer(app: &tauri::App, reply: &str, response: serde_json::Value) { + app.emit(reply, response).unwrap(); + } + + fn ack(app: &tauri::App, reply: &str) { + answer(app, reply, serde_json::json!({ "type": "ack" })); + } + + fn user_check(window: &WebviewWindow) -> Result { + tauri::async_runtime::block_on(check(window, UpdateMode::Beta, UpdateTrigger::User, false)) + } + + fn background_check(window: &WebviewWindow) -> Result { + tauri::async_runtime::block_on(check( + window, + UpdateMode::Beta, + UpdateTrigger::Background, + false, + )) + } + + /// Waits for the gate to come back to idle, as a run ends. + fn wait_for_idle(app: &tauri::App) { + let gate = app.state::(); + for _ in 0..100 { + if gate.state() == yaak_updater::UpdateState::Idle { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("the update run never ended: {:?}", gate.state()); + } + + /// The main window and Settings each check, as their Check for Updates… and Settings button + /// do, at the same moment: only one prompt, and so one download and install, may start. The + /// other window is told a check is under way. + #[test] + fn overlapping_checks_from_two_windows_start_one_update() { + let _serial = serial(); + let app = mock_app(serve_update()); + let _clean = CleanUp(app.handle().clone()); + let main = window(&app, "main_0"); + let settings = window(&app, "settings"); + let rx = record(&[&main, &settings]); + + let state = app.state::>(); + let (a, b) = tauri::async_runtime::block_on(async { + tokio::join!( + cmd_check_for_updates(main.clone(), state.clone(), "beta".into(), false, false), + cmd_check_for_updates(settings.clone(), state.clone(), "beta".into(), false, false), + ) + }); + let shown = drain(&rx); + // Settle every prompt, so no task is left waiting on a window + for prompt in prompts(&shown) { + answer( + &app, + &reply_id(prompt), + serde_json::json!({ "type": "action", "action": "skip" }), + ); + } + assert_eq!(a, Ok(true)); + assert_eq!(b, Ok(true)); + let started = prompts(&shown); + assert_eq!(started.len(), 1, "two checks started {} prompts: {shown:?}", started.len()); + let told: Vec<_> = + shown.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect(); + assert_eq!(told.len(), 1, "{shown:?}"); + assert_eq!(told[0].payload, serde_json::json!({ "state": "checking" })); + assert_ne!(told[0].window, started[0].window, "the window that lost the race is told"); + wait_for_idle(&app); + } + + /// A check from another window, or a later background check, while a prompt waits on the + /// user shows that same prompt, answered by the same run, rather than starting another. + #[test] + fn a_check_while_a_prompt_waits_shows_the_same_prompt() { + let _serial = serial(); + let app = mock_app(serve_update()); + let _clean = CleanUp(app.handle().clone()); + let main = window(&app, "main_0"); + let settings = window(&app, "settings"); + let rx = record(&[&main, &settings]); + + assert_eq!(user_check(&main), Ok(true)); + let first = drain(&rx); + assert_eq!(prompts(&first).len(), 1, "{first:?}"); + let reply = reply_id(prompts(&first)[0]); + ack(&app, &reply); + + // The Settings button, then a background check as the main window comes forward + assert_eq!(user_check(&settings), Ok(true)); + assert_eq!(background_check(&main), Ok(true)); + let again = drain(&rx); + let shown_again = prompts(&again); + assert_eq!(shown_again.len(), 2, "{again:?}"); + assert_eq!(shown_again[0].window, "settings"); + assert_eq!(shown_again[1].window, "main_0"); + for prompt in &shown_again { + assert_eq!(reply_id(prompt), reply, "the same run answers it"); + } + + answer(&app, &reply, serde_json::json!({ "type": "action", "action": "skip" })); + wait_for_idle(&app); + } + + /// A newer version found from Settings, while the main window's prompt for the older one + /// waits, replaces that prompt in both windows, so neither keeps an Install button whose + /// run is gone. + #[test] + fn a_newer_version_replaces_the_prompt_in_every_window() { + let _serial = serial(); + let app = mock_app(serve_update()); + let _clean = CleanUp(app.handle().clone()); + let main = window(&app, "main_0"); + let settings = window(&app, "settings"); + let rx = record(&[&main, &settings]); + + assert_eq!(user_check(&main), Ok(true)); + let first = drain(&rx); + let old_reply = reply_id(prompts(&first)[0]); + ack(&app, &old_reply); + + *served_version() = "99.1.0".to_string(); + assert_eq!(user_check(&settings), Ok(true)); + let replaced = drain(&rx); + let new_prompts = prompts(&replaced); + assert_eq!(new_prompts.len(), 2, "{replaced:?}"); + let mut windows: Vec<_> = new_prompts.iter().map(|p| p.window.as_str()).collect(); + windows.sort(); + assert_eq!(windows, vec!["main_0", "settings"]); + let new_reply = reply_id(new_prompts[0]); + for prompt in &new_prompts { + assert_eq!(prompt.payload["version"], "99.1.0"); + assert_eq!(reply_id(prompt), new_reply, "one run answers both"); + } + assert_ne!(new_reply, old_reply); + + ack(&app, &new_reply); + answer(&app, &new_reply, serde_json::json!({ "type": "action", "action": "skip" })); + wait_for_idle(&app); + } + + /// While the update downloads (and so installs), no check starts another: the user who asks + /// is told where it stands, and a background check shows nothing. + #[test] + fn checks_while_downloading_start_nothing() { + let _serial = serial(); + let app = mock_app(serve_update()); + let _clean = CleanUp(app.handle().clone()); + let main = window(&app, "main_0"); + let settings = window(&app, "settings"); + let rx = record(&[&main, &settings]); + + assert_eq!(user_check(&main), Ok(true)); + let first = drain(&rx); + let reply = reply_id(prompts(&first)[0]); + ack(&app, &reply); + answer(&app, &reply, serde_json::json!({ "type": "action", "action": "install" })); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + app.state::().state(), + yaak_updater::UpdateState::Downloading { version: "99.0.0".into() } + ); + + assert_eq!(user_check(&settings), Ok(true)); + assert_eq!(background_check(&main), Ok(false)); + let during = drain(&rx); + assert!(prompts(&during).is_empty(), "no new prompt: {during:?}"); + let told: Vec<_> = + during.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect(); + assert_eq!(told.len(), 1, "only the user who asked is told: {during:?}"); + assert_eq!(told[0].window, "settings"); + assert_eq!( + told[0].payload, + serde_json::json!({ "state": "downloading", "version": "99.0.0" }) + ); + + // The download isn't a signed archive, so the install fails and the run ends + wait_for_idle(&app); + } +} diff --git a/crates-tauri/yaak-app-proxy/src/window_menu.rs b/crates-tauri/yaak-app-proxy/src/window_menu.rs index 6fbea22ff..df9f90765 100644 --- a/crates-tauri/yaak-app-proxy/src/window_menu.rs +++ b/crates-tauri/yaak-app-proxy/src/window_menu.rs @@ -11,6 +11,12 @@ pub const EXPORT_HAR_ID: &str = "file.export_har"; /// The event the window hears File's Export All Flows as HAR by. pub const EXPORT_HAR_EVENT: &str = "menu_export_har"; +/// The app menu's Check for Updates…, in a build with the updater. +pub const CHECK_FOR_UPDATES_ID: &str = "app.check_for_updates"; + +/// The event the window hears Check for Updates… by, and checks on. +pub const CHECK_FOR_UPDATES_EVENT: &str = "menu_check_for_updates"; + /// Go's Back and Forward, which the window carries out along its own places, and enables while /// there is somewhere to go. pub const GO_BACK_ID: &str = "go.back"; @@ -98,6 +104,9 @@ pub fn app_menu(app_handle: &AppHandle) -> tauri::Result> true, &[ &PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?, + #[cfg(feature = "updater")] + &MenuItemBuilder::with_id(CHECK_FOR_UPDATES_ID, "Check for Updates…") + .build(app_handle)?, &PredefinedMenuItem::separator(app_handle)?, &PredefinedMenuItem::services(app_handle, None)?, &PredefinedMenuItem::separator(app_handle)?, diff --git a/crates-tauri/yaak-app-proxy/tauri.release.conf.json b/crates-tauri/yaak-app-proxy/tauri.release.conf.json index b1ec76628..cd7e9c34a 100644 --- a/crates-tauri/yaak-app-proxy/tauri.release.conf.json +++ b/crates-tauri/yaak-app-proxy/tauri.release.conf.json @@ -1,5 +1,17 @@ { "build": { - "features": [] + "features": ["updater"] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K" + } + }, + "bundle": { + "publisher": "Yaak", + "copyright": "Yaak", + "homepage": "https://yaak.app", + "category": "DeveloperTool", + "createUpdaterArtifacts": true } } diff --git a/crates-tauri/yaak-updater/Cargo.toml b/crates-tauri/yaak-updater/Cargo.toml new file mode 100644 index 000000000..2824b1549 --- /dev/null +++ b/crates-tauri/yaak-updater/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "yaak-updater" +version = "0.1.0" +edition = "2024" +publish = false + +# The update prompt, download and install that Yaak and Yaak Proxy share, and the macOS-safe +# restart that follows an install. Each app checks for updates its own way and hands what it +# finds to this crate. + +[dependencies] +log = { workspace = true } +md5 = "0.8.0" +nanoid = "0.5.0" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tauri = { workspace = true } +tauri-plugin-dialog = { workspace = true } +tauri-plugin-updater = "2.10.1" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "macros"] } +ts-rs = { workspace = true } diff --git a/crates-tauri/yaak-updater/bindings/index.ts b/crates-tauri/yaak-updater/bindings/index.ts new file mode 100644 index 000000000..21e34f91b --- /dev/null +++ b/crates-tauri/yaak-updater/bindings/index.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateInfo = { + replyEventId: string; + version: string; + downloaded: boolean; + /** + * How this update gets applied. Anything but `Integrated` means the app can't do it + * itself and the user is told how to update instead. + */ + install: UpdateInstall; +}; + +/** + * How an update can be applied to this install. + */ +export type UpdateInstall = "integrated" | "flatpak" | "manual"; + +export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction }; + +export type UpdateResponseAction = "install" | "skip"; + +/** + * Where this process's update stands. + */ +export type UpdateState = + | { state: "idle" } + | { state: "checking" } + | { state: "offered"; version: string } + | { state: "downloading"; version: string } + | { state: "installing"; version: string } + | { state: "installed"; version: string }; diff --git a/crates-tauri/yaak-updater/package.json b/crates-tauri/yaak-updater/package.json new file mode 100644 index 000000000..02d6d46c0 --- /dev/null +++ b/crates-tauri/yaak-updater/package.json @@ -0,0 +1,6 @@ +{ + "name": "@yaakapp-internal/tauri-updater", + "version": "1.0.0", + "private": true, + "main": "bindings/index.ts" +} diff --git a/crates-tauri/yaak-updater/src/gate.rs b/crates-tauri/yaak-updater/src/gate.rs new file mode 100644 index 000000000..c33ad4061 --- /dev/null +++ b/crates-tauri/yaak-updater/src/gate.rs @@ -0,0 +1,569 @@ +//! One update at a time per process. Every trigger (launch, focus, a menu item, a Settings +//! button, in any window) goes through the app's [`UpdateGate`], which allows one check at a +//! time and one update run (offer, download, install) at a time, and never downloads an update +//! again once it is installed. +//! +//! A check may run while an offer waits on the user: finding the same version shows that offer +//! again, wired to the same run, and finding a newer one replaces it. Anything further along, +//! downloading, installing or installed, refuses a new check, and a check the user asked for +//! is shown where things stand instead. + +use std::sync::{Arc, Mutex, MutexGuard}; + +use serde::Serialize; +use ts_rs::TS; + +use crate::UpdateInfo; + +/// Where this process's update stands. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase", tag = "state")] +#[ts(export, export_to = "index.ts")] +pub enum UpdateState { + #[default] + Idle, + /// Asking the update server + Checking, + /// Offered, and waiting for the user to choose + Offered { + version: String, + }, + Downloading { + version: String, + }, + Installing { + version: String, + }, + /// Installed; it runs once the app relaunches + Installed { + version: String, + }, +} + +/// The app's one update gate, kept in its state and shared by every window. +#[derive(Clone, Default)] +pub struct UpdateGate(Arc>); + +impl UpdateGate { + /// Where the update stands now. + pub fn state(&self) -> UpdateState { + self.lock().current() + } + + /// Claims the gate for a check, or says why not. The claim ends when the token is dropped, + /// or hands over to a run with [`crate::offer_update`]. + pub fn begin_check(&self) -> Result { + let mut gate = self.lock(); + match gate.begin_check() { + Ok(()) => Ok(CheckToken { gate: self.clone(), ended: false }), + Err(state) => Err(Busy::new(state, gate.offer.clone()).with_gate(self.clone())), + } + } + + pub(crate) fn lock(&self) -> MutexGuard<'_, Gate> { + // A panic mid-transition leaves the last state set, which is still the truth + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +/// A check refused, with where the update stands, to show the user who asked. +#[derive(Clone)] +pub struct Busy { + pub state: UpdateState, + /// The prompt on offer, to show again, wired to the run that waits for it + pub(crate) offer: Option, + /// The gate, to note the windows the prompt is shown in again + pub(crate) gate: Option, +} + +impl Busy { + pub(crate) fn new(state: UpdateState, offer: Option) -> Self { + Self { state, offer, gate: None } + } + + pub(crate) fn with_gate(mut self, gate: UpdateGate) -> Self { + self.gate = Some(gate); + self + } +} + +impl std::fmt::Debug for Busy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Busy").field("state", &self.state).field("offer", &self.offer).finish() + } +} + +impl PartialEq for Busy { + fn eq(&self, other: &Self) -> bool { + self.state == other.state && self.offer == other.offer + } +} + +/// The one check in flight. Dropping it, as a failed or empty check does, frees the gate. +pub struct CheckToken { + pub(crate) gate: UpdateGate, + /// Ended by an offer already, so dropping it mustn't end the next check + pub(crate) ended: bool, +} + +impl Drop for CheckToken { + fn drop(&mut self) { + if !self.ended { + self.gate.lock().checking = false; + } + } +} + +/// What a check that found `version` does. +#[derive(Debug, PartialEq)] +pub(crate) enum OfferDecision { + /// Start run `run`; the one it replaces, if any, was cancelled + Start { run: u64 }, + /// The same version is on offer already: show its prompt again + Reshow(UpdateInfo), + /// The run moved on (the user chose to install meanwhile): start nothing + Busy(Busy), +} + +/// The gate's state, behind its lock. +#[derive(Default)] +pub(crate) struct Gate { + /// A check is in flight + checking: bool, + /// The current run's id; a run that is no longer current changes nothing + run: u64, + /// The current run's state, Idle when there is none + state: UpdateState, + /// The prompt the current run shows, while it waits on the user + offer: Option, + /// Cancels the current run while it waits on the user, so a newer version can replace it. + /// Kept through a background download, which returns to waiting on the user. + cancel: Option>, + /// The windows showing the current run's prompt, each told when it installs. A run that + /// replaces another takes over its windows, so none is left with a stale prompt. + windows: Vec, + /// The current run's prompt was withdrawn for a native dialog, which nothing may cancel + withdrawn: bool, +} + +impl Gate { + /// What the user is shown for "where does the update stand". + pub(crate) fn current(&self) -> UpdateState { + if self.checking && self.state == UpdateState::Idle { + UpdateState::Checking + } else { + self.state.clone() + } + } + + pub(crate) fn begin_check(&mut self) -> Result<(), UpdateState> { + if self.checking { + return Err(self.current()); + } + match self.state { + UpdateState::Idle | UpdateState::Offered { .. } => { + self.checking = true; + Ok(()) + } + ref state => Err(state.clone()), + } + } + + /// A check from window `label` found `version`. Ends the check, and decides whether a run + /// starts. + pub(crate) fn offer(&mut self, version: &str, label: &str) -> OfferDecision { + self.checking = false; + let windows = match &self.state { + UpdateState::Idle => Vec::new(), + UpdateState::Offered { version: offered } if offered == version => { + if let Some(info) = self.offer.clone() { + self.add_window(label); + return OfferDecision::Reshow(info); + } + // Not shown yet (the run is still preparing its prompt): nothing to add + return OfferDecision::Busy(Busy::new(self.state.clone(), None)); + } + UpdateState::Offered { .. } if self.cancel.is_some() => { + // A newer version replaces the prompt the user hasn't taken, in every window + // showing it + if let Some(cancel) = self.cancel.take() { + cancel(); + } + std::mem::take(&mut self.windows) + } + state => { + return OfferDecision::Busy(Busy::new(state.clone(), None)); + } + }; + self.run += 1; + self.state = UpdateState::Offered { version: version.to_string() }; + self.offer = None; + self.cancel = None; + self.withdrawn = false; + self.windows = windows; + self.add_window(label); + OfferDecision::Start { run: self.run } + } + + /// Moves run `run` to `state`. False, changing nothing, when the run is no longer current. + pub(crate) fn advance(&mut self, run: u64, state: UpdateState) -> bool { + if run != self.run { + return false; + } + if !matches!(state, UpdateState::Offered { .. }) { + // Not waiting on the user now: no prompt to show again until it waits once more + self.offer = None; + } + self.state = state; + true + } + + /// Run `run` shows `info` in window `label` and waits on the user. + pub(crate) fn offered(&mut self, run: u64, info: UpdateInfo, label: &str) -> bool { + if !self.advance(run, UpdateState::Offered { version: info.version.clone() }) { + return false; + } + self.offer = Some(info); + self.add_window(label); + true + } + + /// The current run's prompt is showing in window `label` too. + pub(crate) fn add_window(&mut self, label: &str) { + if !self.windows.iter().any(|w| w == label) { + self.windows.push(label.to_string()); + } + } + + /// The windows showing run `run`'s prompt. + pub(crate) fn windows(&self, run: u64) -> Vec { + if run == self.run { self.windows.clone() } else { Vec::new() } + } + + /// The prompt is gone (the window didn't answer, so a native dialog asks instead): nothing + /// can show it again, and a newer version mustn't cancel the dialog's install. + pub(crate) fn withdraw_offer(&mut self, run: u64) { + if run == self.run { + self.offer = None; + self.cancel = None; + self.withdrawn = true; + } + } + + /// Registers how to cancel run `run`. Its task may already have moved on, into a background + /// download, by the time this is called; the handle is kept all the same, and only used + /// while the run waits on the user. + pub(crate) fn set_cancel(&mut self, run: u64, cancel: Box) { + if run == self.run && !self.withdrawn { + self.cancel = Some(cancel); + } + } + + /// Run `run` ended. Unless it installed, which stays until the app relaunches, the gate is + /// free again. + pub(crate) fn finish(&mut self, run: u64) { + if run != self.run || matches!(self.state, UpdateState::Installed { .. }) { + return; + } + self.state = UpdateState::Idle; + self.offer = None; + self.cancel = None; + self.windows.clear(); + } +} + +/// The current run's hold on the gate, kept by its task. Dropping it ends the run. +pub(crate) struct RunGuard { + pub(crate) gate: UpdateGate, + pub(crate) run: u64, +} + +impl RunGuard { + /// Moves the run on. False when a newer run replaced it, and it should stop. + pub(crate) fn advance(&self, state: UpdateState) -> bool { + self.gate.lock().advance(self.run, state) + } + + pub(crate) fn offered(&self, info: UpdateInfo, label: &str) -> bool { + self.gate.lock().offered(self.run, info, label) + } + + /// The windows showing this run's prompt. + pub(crate) fn windows(&self) -> Vec { + self.gate.lock().windows(self.run) + } + + pub(crate) fn withdraw_offer(&self) { + self.gate.lock().withdraw_offer(self.run); + } +} + +impl Drop for RunGuard { + fn drop(&mut self) { + self.gate.lock().finish(self.run); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn version(v: &str) -> String { + v.to_string() + } + + fn info(v: &str, reply: &str) -> UpdateInfo { + UpdateInfo { reply_event_id: reply.into(), version: v.into(), ..Default::default() } + } + + fn started(decision: OfferDecision) -> u64 { + match decision { + OfferDecision::Start { run } => run, + other => panic!("expected a run to start, got {other:?}"), + } + } + + #[test] + fn overlapping_checks_run_one_at_a_time() { + let mut gate = Gate::default(); + assert_eq!(gate.begin_check(), Ok(())); + assert_eq!(gate.current(), UpdateState::Checking); + // A second trigger, background or the user's, from any window + assert_eq!(gate.begin_check(), Err(UpdateState::Checking)); + } + + #[test] + fn a_check_finding_nothing_frees_the_gate() { + let gate = UpdateGate::default(); + let token = gate.begin_check().unwrap(); + assert!(gate.begin_check().is_err()); + drop(token); + assert_eq!(gate.state(), UpdateState::Idle); + assert!(gate.begin_check().is_ok()); + } + + #[test] + fn checks_wait_while_downloading_and_installing() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.offered(run, info("2.0.0", "r1"), "main_0")); + + assert!(gate.advance(run, UpdateState::Downloading { version: version("2.0.0") })); + assert_eq!(gate.begin_check(), Err(UpdateState::Downloading { version: version("2.0.0") })); + assert!(gate.advance(run, UpdateState::Installing { version: version("2.0.0") })); + assert_eq!(gate.begin_check(), Err(UpdateState::Installing { version: version("2.0.0") })); + } + + #[test] + fn an_installed_update_is_never_downloaded_again() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.advance(run, UpdateState::Installed { version: version("2.0.0") })); + // The run's task ends; the install stands until the app relaunches + gate.finish(run); + assert_eq!(gate.current(), UpdateState::Installed { version: version("2.0.0") }); + assert_eq!(gate.begin_check(), Err(UpdateState::Installed { version: version("2.0.0") })); + } + + #[test] + fn the_same_version_found_again_shows_the_same_prompt() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.offered(run, info("2.0.0", "r1"), "main_0")); + + // Another window's check, while the prompt waits + assert_eq!(gate.begin_check(), Ok(())); + assert_eq!(gate.offer("2.0.0", "main_0"), OfferDecision::Reshow(info("2.0.0", "r1"))); + // Still the one run, still waiting + assert_eq!(gate.current(), UpdateState::Offered { version: version("2.0.0") }); + assert!(gate.advance(run, UpdateState::Downloading { version: version("2.0.0") })); + } + + #[test] + fn a_newer_version_replaces_an_offer_not_taken() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let first = started(gate.offer("2.0.0", "main_0")); + assert!(gate.offered(first, info("2.0.0", "r1"), "main_0")); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = cancelled.clone(); + gate.set_cancel(first, Box::new(move || flag.store(true, Ordering::SeqCst))); + + gate.begin_check().unwrap(); + let second = started(gate.offer("2.1.0", "main_0")); + assert!(cancelled.load(Ordering::SeqCst), "the first run is cancelled"); + assert_ne!(first, second); + + // The replaced run can no longer move the gate, nor free it as it ends + assert!(!gate.advance(first, UpdateState::Downloading { version: version("2.0.0") })); + gate.finish(first); + assert_eq!(gate.current(), UpdateState::Offered { version: version("2.1.0") }); + } + + #[test] + fn a_newer_version_replaces_an_offer_after_its_background_download() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let first = started(gate.offer("2.0.0", "main_0")); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = cancelled.clone(); + gate.set_cancel(first, Box::new(move || flag.store(true, Ordering::SeqCst))); + // A background check downloads first, then waits on the user with its prompt + assert!(gate.advance(first, UpdateState::Downloading { version: version("2.0.0") })); + assert!(gate.advance(first, UpdateState::Offered { version: version("2.0.0") })); + assert!(gate.offered(first, info("2.0.0", "r1"), "main_0")); + + gate.begin_check().unwrap(); + let second = started(gate.offer("2.1.0", "main_0")); + assert!(cancelled.load(Ordering::SeqCst), "the downloaded offer is replaced"); + assert_ne!(first, second); + } + + #[test] + fn every_window_showing_the_prompt_hears_it_installed() { + let gate = UpdateGate::default(); + let token = gate.begin_check().unwrap(); + let run = started(gate.lock().offer("2.0.0", "main_0")); + drop(token); + assert!(gate.lock().offered(run, info("2.0.0", "r1"), "main_0")); + + // Settings checks and is shown the same prompt; so is a window refused mid-check + gate.begin_check().unwrap(); + assert!(matches!(gate.lock().offer("2.0.0", "settings"), OfferDecision::Reshow(_))); + let in_flight = gate.begin_check().unwrap(); + let busy = gate.begin_check().err().unwrap(); + if let Some(g) = &busy.gate { + g.lock().add_window("main_1"); + } + drop(in_flight); + // Shown again in the main window adds nothing new + gate.lock().add_window("main_0"); + assert_eq!(gate.lock().windows(run), vec!["main_0", "settings", "main_1"]); + + // A newer run takes over every window showing the old prompt, and the one that found it + gate.begin_check().unwrap(); + gate.lock().set_cancel(run, Box::new(|| {})); + let next = started(gate.lock().offer("2.1.0", "main_2")); + assert_eq!(gate.lock().windows(next), vec!["main_0", "settings", "main_1", "main_2"]); + assert!(gate.lock().windows(run).is_empty(), "the old run has no windows left"); + } + + #[test] + fn a_fresh_run_starts_with_the_window_that_found_it() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "settings")); + assert_eq!(gate.windows(run), vec!["settings"]); + // Its task fails or the user skips: the next run starts afresh + gate.finish(run); + gate.begin_check().unwrap(); + let next = started(gate.offer("2.0.0", "main_0")); + assert_eq!(gate.windows(next), vec!["main_0"]); + } + + #[test] + fn a_cancel_registered_after_a_background_download_began_is_kept() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let first = started(gate.offer("2.0.0", "main_0")); + // The task starts downloading on another worker before its cancel is registered + assert!(gate.advance(first, UpdateState::Downloading { version: version("2.0.0") })); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = cancelled.clone(); + gate.set_cancel(first, Box::new(move || flag.store(true, Ordering::SeqCst))); + assert!(gate.advance(first, UpdateState::Offered { version: version("2.0.0") })); + assert!(gate.offered(first, info("2.0.0", "r1"), "main_0")); + + gate.begin_check().unwrap(); + started(gate.offer("2.1.0", "main_0")); + assert!(cancelled.load(Ordering::SeqCst), "a newer version still replaces it"); + } + + #[test] + fn a_cancel_registered_after_the_prompt_was_withdrawn_is_refused() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + gate.withdraw_offer(run); + gate.set_cancel(run, Box::new(|| panic!("the native dialog's run was cancelled"))); + gate.begin_check().unwrap(); + assert!(matches!(gate.offer("2.1.0", "main_0"), OfferDecision::Busy(_))); + } + + #[test] + fn a_check_that_finds_the_user_installing_starts_nothing() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.offered(run, info("2.0.0", "r1"), "main_0")); + + // A check starts while the prompt waits, then the user chooses Install + gate.begin_check().unwrap(); + assert!(gate.advance(run, UpdateState::Downloading { version: version("2.0.0") })); + match gate.offer("2.0.0", "main_0") { + OfferDecision::Busy(busy) => { + assert_eq!(busy.state, UpdateState::Downloading { version: version("2.0.0") }); + assert_eq!(busy.offer, None); + } + other => panic!("expected busy, got {other:?}"), + } + assert!(!gate.checking); + } + + #[test] + fn a_run_that_ends_without_installing_frees_the_gate() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.advance(run, UpdateState::Downloading { version: version("2.0.0") })); + // The download or install failed, or the user skipped it + gate.finish(run); + assert_eq!(gate.current(), UpdateState::Idle); + assert_eq!(gate.begin_check(), Ok(())); + } + + #[test] + fn a_withdrawn_prompt_is_not_shown_again() { + let mut gate = Gate::default(); + gate.begin_check().unwrap(); + let run = started(gate.offer("2.0.0", "main_0")); + assert!(gate.offered(run, info("2.0.0", "r1"), "main_0")); + gate.set_cancel(run, Box::new(|| panic!("the dialog's run was cancelled"))); + // The window never answered, so the native dialog took over + gate.withdraw_offer(run); + gate.begin_check().unwrap(); + match gate.offer("2.0.0", "main_0") { + OfferDecision::Busy(busy) => { + assert_eq!(busy.state, UpdateState::Offered { version: version("2.0.0") }) + } + other => panic!("expected busy, got {other:?}"), + } + // Nor does a newer version cancel the dialog, which may be installing + gate.begin_check().unwrap(); + assert!(matches!(gate.offer("2.1.0", "main_0"), OfferDecision::Busy(_))); + } + + #[test] + fn a_busy_check_carries_the_prompt_to_show_again() { + let gate = UpdateGate::default(); + let token = gate.begin_check().unwrap(); + let run = started(gate.lock().offer("2.0.0", "main_0")); + drop(token); + assert!(gate.lock().offered(run, info("2.0.0", "r1"), "main_0")); + + // One check in flight while the prompt waits; a second trigger is refused, carrying + // the prompt so the user who asked sees it rather than a new one + let first = gate.begin_check().unwrap(); + let busy = match gate.begin_check() { + Err(busy) => busy, + Ok(_) => panic!("a second check in flight"), + }; + assert_eq!(busy.state, UpdateState::Offered { version: version("2.0.0") }); + assert_eq!(busy.offer, Some(info("2.0.0", "r1"))); + drop(first); + } +} diff --git a/crates-tauri/yaak-updater/src/lib.rs b/crates-tauri/yaak-updater/src/lib.rs new file mode 100644 index 000000000..e1a957ba0 --- /dev/null +++ b/crates-tauri/yaak-updater/src/lib.rs @@ -0,0 +1,564 @@ +//! The update flow Yaak and Yaak Proxy share. Each app claims its [`UpdateGate`] before +//! asking its own update endpoint, with its own headers, and hands an [`Update`] it finds to +//! [`CheckToken::offer`]. That runs at most one update per process: it asks the window (or a +//! native dialog, when the window doesn't answer), downloads, installs and tells the window it +//! can relaunch through [`restart`]. + +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; +use tauri_plugin_updater::Update; +use tokio::time::sleep; +use ts_rs::TS; + +mod gate; +pub mod restart; + +pub use gate::{Busy, CheckToken, UpdateGate, UpdateState}; +use gate::{OfferDecision, RunGuard}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Updater error: {0}")] + UpdaterError(#[from] tauri_plugin_updater::Error), + + #[error("Tauri error: {0}")] + TauriError(#[from] tauri::Error), + + #[error("I/O error: {0}")] + IOError(#[from] std::io::Error), + + #[error("{0}")] + GenericError(String), +} + +pub type Result = std::result::Result; + +/// The event the window hears of an available update by, with an [`UpdateInfo`]. +pub const UPDATE_AVAILABLE_EVENT: &str = "update_available"; + +/// The event the window hears of an installed update by, with its version. +pub const UPDATE_INSTALLED_EVENT: &str = "update_installed"; + +/// The event a window hears where the update stands by, with an [`UpdateState`], when the user +/// asks for a check while one is already in hand. +pub const UPDATE_STATE_EVENT: &str = "update_state"; + +const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12; +const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3; +const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1; + +/// The release channel an app follows, sent to the update server as `X-Update-Mode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateMode { + Stable, + Beta, + Alpha, +} + +impl Display for UpdateMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = match self { + UpdateMode::Stable => "stable", + UpdateMode::Beta => "beta", + UpdateMode::Alpha => "alpha", + }; + write!(f, "{}", s) + } +} + +impl UpdateMode { + pub fn new(mode: &str) -> UpdateMode { + match mode { + "beta" => UpdateMode::Beta, + "alpha" => UpdateMode::Alpha, + _ => UpdateMode::Stable, + } + } + + /// How long a background check waits after the last one on this channel. + pub fn check_interval(&self) -> Duration { + let hours = match self { + UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE, + UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA, + UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA, + }; + Duration::from_secs(hours * 60 * 60) + } +} + +/// Who asked for a check, sent to the update server as `X-Update-Trigger`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateTrigger { + Background, + User, +} + +impl UpdateTrigger { + pub fn header_value(&self) -> &'static str { + match self { + UpdateTrigger::Background => "background", + UpdateTrigger::User => "user", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "index.ts")] +pub struct UpdateInfo { + pub reply_event_id: String, + pub version: String, + pub downloaded: bool, + /// How this update gets applied. Anything but `Integrated` means the app can't do it + /// itself and the user is told how to update instead. + pub install: UpdateInstall, +} + +/// How an update can be applied to this install. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "index.ts")] +pub enum UpdateInstall { + /// The app downloads and installs it itself + #[default] + Integrated, + /// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark) + Flatpak, + /// Nothing can install it in-app (distro package, Nix, unknown); download by hand + Manual, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export, export_to = "index.ts")] +pub enum UpdateResponse { + Ack, + Action { action: UpdateResponseAction }, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "index.ts")] +pub enum UpdateResponseAction { + Install, + Skip, +} + +impl CheckToken { + /// The check found `update`: offer it in `window` and, if the user takes it, download and + /// install it, in a task of its own so the check can answer first. At most one such run + /// exists per process. Finding the version already on offer shows that same prompt again; + /// finding it while it downloads, installs or has installed starts nothing, and shows the + /// user where it stands when they asked. + /// + /// `install` says whether this install can apply the update itself; when it can't, the + /// window is only told how to update. A background check downloads first when + /// `auto_download` is set, so the prompt can offer to install at once. + pub fn offer( + mut self, + window: &WebviewWindow, + update: Update, + trigger: UpdateTrigger, + auto_download: bool, + install: UpdateInstall, + ) { + let gate = self.gate.clone(); + let decision = { + let mut locked = gate.lock(); + self.ended = true; + locked.offer(&update.version, window.label()) + }; + match decision { + OfferDecision::Start { run } => { + let w = window.clone(); + let guard = RunGuard { gate: gate.clone(), run }; + let task = tauri::async_runtime::spawn(async move { + prompt_and_install(&w, &update, trigger, auto_download, install, guard).await; + }); + gate.lock().set_cancel(run, Box::new(move || task.abort())); + } + OfferDecision::Reshow(info) => { + debug!("{} is on offer already; showing its prompt again", info.version); + if let Err(e) = window.emit_to(window.label(), UPDATE_AVAILABLE_EVENT, &info) { + warn!("Failed to emit update_available: {e}"); + } + } + OfferDecision::Busy(busy) => { + info!("Found {}, but the update is {:?} already", update.version, busy.state); + if trigger == UpdateTrigger::User { + busy.show(window); + } + } + } + } +} + +impl Busy { + /// Shows the user who asked for a check where the update stands, rather than a new prompt: + /// the prompt on offer, the relaunch offer once installed, or the state in between. + pub fn show(&self, window: &WebviewWindow) { + let label = window.label(); + let shown = match (&self.offer, &self.state) { + (Some(info), _) => { + // So this window hears when the update installs, whichever window installs it + if let Some(gate) = &self.gate { + gate.lock().add_window(label); + } + window.emit_to(label, UPDATE_AVAILABLE_EVENT, info) + } + (None, UpdateState::Installed { version }) => { + window.emit_to(label, UPDATE_INSTALLED_EVENT, version) + } + (None, state) => window.emit_to(label, UPDATE_STATE_EVENT, state), + }; + if let Err(e) = shown { + warn!("Failed to show where the update stands: {e}"); + } + } +} + +/// Offers `update` in `window` and, if the user takes it, downloads and installs it, as run +/// `run` of the gate. Returns once that is settled; the run ends as `run` is dropped. +async fn prompt_and_install( + window: &WebviewWindow, + update: &Update, + trigger: UpdateTrigger, + auto_download: bool, + install: UpdateInstall, + run: RunGuard, +) { + let version = update.version.to_string(); + + // Only hand the artifact to the updater plugin when this install can apply it itself; + // otherwise tell the user how to update instead + if install != UpdateInstall::Integrated { + info!("{} available, but this install updates via {install:?}", update.version); + notify_external_update(window, update, install); + return; + } + + // Force native updater if specified (useful if a release broke the UI) + let native_install_mode = + update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default() + == Some("native"); + if native_install_mode { + start_native_update(window, update, &run).await; + return; + } + + // If it's a background update, try downloading it first + if trigger == UpdateTrigger::Background && auto_download { + if !run.advance(UpdateState::Downloading { version: version.clone() }) { + return; + } + info!("Downloading update {} in background", update.version); + if let Err(e) = download_update_idempotent(window, update).await { + error!("Failed to download {}: {}", update.version, e); + } + if !run.advance(UpdateState::Offered { version: version.clone() }) { + return; + } + } + + match start_integrated_update(window, update, &run).await { + Ok(UpdateResponseAction::Skip) => { + info!("Confirmed {}: skipped", update.version); + } + Ok(UpdateResponseAction::Install) => { + info!("Confirmed {}: install", update.version); + if !run.advance(UpdateState::Downloading { version: version.clone() }) { + return; + } + let downloaded = match download_update_idempotent(window, update).await { + Ok(path) => path, + Err(e) => { + error!("Failed to install: {e}"); + return; + } + }; + if !run.advance(UpdateState::Installing { version: version.clone() }) { + return; + } + if let Err(e) = install_downloaded(window, update, &downloaded) { + error!("Failed to install: {e}"); + return; + }; + run.advance(UpdateState::Installed { version }); + + info!("Installed {}", update.version); + finish_integrated_update(window, update, &run.windows()).await; + } + Err(e) => { + warn!("Failed to notify frontend, falling back: {e}",); + // Nothing can show the window's prompt again, nor replace it, from here + run.withdraw_offer(); + start_native_update(window, update, &run).await; + } + }; +} + +/// Tells each window that showed the prompt, `windows`, that the update installed, so any of +/// them offers the relaunch; the run's own window when no other showed it. +async fn finish_integrated_update( + window: &WebviewWindow, + update: &Update, + windows: &[String], +) { + let own = [window.label().to_string()]; + let windows = if windows.is_empty() { &own[..] } else { windows }; + for label in windows { + if let Err(e) = window.emit_to(label, UPDATE_INSTALLED_EVENT, update.version.to_string()) { + warn!("Failed to notify frontend of update install: {}", e); + } + } +} + +async fn start_integrated_update( + window: &WebviewWindow, + update: &Update, + run: &RunGuard, +) -> Result { + let download_path = ensure_download_dir(window)?.join(download_file_name(update)); + debug!("Download path: {}", download_path.display()); + let downloaded = download_path.exists(); + let ack_wait = Duration::from_secs(3); + let reply_id = generate_reply_id(); + + // 1) Start listening BEFORE emitting to avoid missing a fast reply + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let w_for_listener = window.clone(); + + let event_id = w_for_listener.listen(reply_id.clone(), move |ev| { + match serde_json::from_str::(ev.payload()) { + Ok(UpdateResponse::Ack) => { + let _ = tx.send(UpdateResponse::Ack); + } + Ok(UpdateResponse::Action { action }) => { + let _ = tx.send(UpdateResponse::Action { action }); + } + Err(e) => { + warn!("Failed to parse update reply from frontend: {e:?}"); + } + } + }); + + // Make sure we always unlisten + struct Unlisten<'a, R: Runtime> { + win: &'a WebviewWindow, + id: tauri::EventId, + } + impl<'a, R: Runtime> Drop for Unlisten<'a, R> { + fn drop(&mut self) { + self.win.unlisten(self.id); + } + } + let _guard = Unlisten { win: window, id: event_id }; + + // 2) Emit the event now that listener is in place + let info = UpdateInfo { + version: update.version.to_string(), + downloaded, + install: UpdateInstall::Integrated, + reply_event_id: reply_id, + }; + window + .emit_to(window.label(), UPDATE_AVAILABLE_EVENT, &info) + .map_err(|e| Error::GenericError(format!("Failed to emit update_available: {e}")))?; + // So a later check, from any window, shows this prompt again rather than a new one + run.offered(info.clone(), window.label()); + // Windows still showing a prompt this run replaced show this one in its place + for label in run.windows().iter().filter(|l| *l != window.label()) { + if let Err(e) = window.emit_to(label, UPDATE_AVAILABLE_EVENT, &info) { + warn!("Failed to replace the update prompt in {label}: {e}"); + } + } + + // 3) Two-stage timeout: first wait for ack, then wait for final action + // --- Phase 1: wait for ACK with timeout --- + tokio::select! { + msg = rx.recv() => match msg { + Some(UpdateResponse::Ack) => {} // proceed to Phase 2 + Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast + None => return Err(Error::GenericError("frontend channel closed before ack".into())), + }, + _ = sleep(ack_wait) => { + return Err(Error::GenericError("timed out waiting for frontend ack".into())); + } + } + + // --- Phase 2: wait forever for final action --- + loop { + match rx.recv().await { + Some(UpdateResponse::Action { action }) => return Ok(action), + Some(UpdateResponse::Ack) => { /* ignore extra acks */ } + None => { + return Err(Error::GenericError("frontend channel closed before action".into())); + } + } + } +} + +/// Tell the frontend about an update this install can't apply itself, so the user can be +/// told how to get it. Unlike the integrated flow, there is nothing to reply to. +fn notify_external_update( + window: &WebviewWindow, + update: &Update, + install: UpdateInstall, +) { + let info = UpdateInfo { + version: update.version.to_string(), + downloaded: false, + install, + reply_event_id: generate_reply_id(), + }; + if let Err(e) = window.emit_to(window.label(), UPDATE_AVAILABLE_EVENT, &info) { + warn!("Failed to emit update_available: {e}"); + } +} + +async fn start_native_update( + window: &WebviewWindow, + update: &Update, + run: &RunGuard, +) { + // If the frontend doesn't respond, fallback to native dialogs + let confirmed = window + .dialog() + .message(format!( + "{} is available. Would you like to download and install it now?", + update.version + )) + .buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string())) + .title("Update Available") + .blocking_show(); + if !confirmed { + return; + } + + let version = update.version.to_string(); + if !run.advance(UpdateState::Installing { version: version.clone() }) { + return; + } + match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => { + run.advance(UpdateState::Installed { version }); + if window + .dialog() + .message("Would you like to restart the app?") + .title("Update Installed") + .buttons(MessageDialogButtons::OkCancelCustom( + "Restart".to_string(), + "Later".to_string(), + )) + .blocking_show() + { + restart::request_restart(window.app_handle()); + } + } + Err(e) => { + window.dialog().message(format!("The update failed to install: {}", e)); + } + } +} + +pub async fn download_update_idempotent( + window: &WebviewWindow, + update: &Update, +) -> Result { + let dl_path = ensure_download_dir(window)?.join(download_file_name(update)); + + if dl_path.exists() { + info!("{} already downloaded to {}", update.version, dl_path.display()); + return Ok(dl_path); + } + + info!("{} downloading: {}", update.version, dl_path.display()); + let dl_bytes = update.download(|_, _| {}, || {}).await?; + std::fs::write(&dl_path, dl_bytes) + .map_err(|e| Error::GenericError(format!("Failed to write update: {e}")))?; + + info!("{} downloaded", update.version); + + Ok(dl_path) +} + +/// Installs `update` from the file it was downloaded to, then removes the downloads. +fn install_downloaded( + window: &WebviewWindow, + update: &Update, + dl_path: &Path, +) -> Result<()> { + let update_bytes = std::fs::read(dl_path)?; + update.install(update_bytes.as_slice())?; + delete_download_dir(window); + Ok(()) +} + +pub fn download_dir(window: &WebviewWindow) -> Result { + Ok(window.path().app_cache_dir()?.join("updates")) +} + +pub fn ensure_download_dir(window: &WebviewWindow) -> Result { + let base_dir = download_dir(window)?; + std::fs::create_dir_all(&base_dir)?; + Ok(base_dir) +} + +pub fn download_file_name(update: &Update) -> String { + let sig_digest = md5::compute(&update.signature); + format!("yaak-{}-{:x}", update.version, sig_digest) +} + +pub fn delete_download_dir(window: &WebviewWindow) { + let base_dir = match download_dir(window) { + Ok(dir) => dir, + Err(e) => { + warn!("Failed to locate update downloads dir: {}", e); + return; + } + }; + match std::fs::remove_dir_all(&base_dir) { + Ok(()) => info!("Removed update downloads dir {}", base_dir.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e), + } +} + +/// A fresh event name for the window's replies to one prompt, as Yaak's model ids are made. +fn generate_reply_id() -> String { + let alphabet: [char; 57] = [ + '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', + 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', + 'X', 'Y', 'Z', + ]; + nanoid::nanoid!(10, &alphabet) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modes_read_as_their_channel_names() { + assert_eq!(UpdateMode::new("beta"), UpdateMode::Beta); + assert_eq!(UpdateMode::new("alpha"), UpdateMode::Alpha); + assert_eq!(UpdateMode::new("stable"), UpdateMode::Stable); + // Anything unknown is stable + assert_eq!(UpdateMode::new(""), UpdateMode::Stable); + assert_eq!(UpdateMode::Beta.to_string(), "beta"); + } + + #[test] + fn beta_checks_more_often_than_stable() { + assert_eq!(UpdateMode::Stable.check_interval(), Duration::from_secs(12 * 60 * 60)); + assert_eq!(UpdateMode::Beta.check_interval(), Duration::from_secs(3 * 60 * 60)); + assert_eq!(UpdateMode::Alpha.check_interval(), Duration::from_secs(60 * 60)); + } +} diff --git a/crates-tauri/yaak-app-client/src/restart.rs b/crates-tauri/yaak-updater/src/restart.rs similarity index 100% rename from crates-tauri/yaak-app-client/src/restart.rs rename to crates-tauri/yaak-updater/src/restart.rs diff --git a/package-lock.json b/package-lock.json index 754dd06ca..33aa18be8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "crates-tauri/yaak-fonts", "crates-tauri/yaak-license", "crates-tauri/yaak-mac-window", + "crates-tauri/yaak-updater", "crates/common/yaak-rpc-schema", "crates/yaak-crypto", "crates/yaak-git", @@ -305,6 +306,10 @@ "name": "@yaakapp-internal/mac-window", "version": "1.0.0" }, + "crates-tauri/yaak-updater": { + "name": "@yaakapp-internal/tauri-updater", + "version": "1.0.0" + }, "crates/common/yaak-rpc-schema": { "name": "@yaakapp-internal/rpc-schema", "version": "1.0.0" @@ -5279,6 +5284,10 @@ "resolved": "crates-tauri/yaak-app-proxy", "link": true }, + "node_modules/@yaakapp-internal/tauri-updater": { + "resolved": "crates-tauri/yaak-updater", + "link": true + }, "node_modules/@yaakapp-internal/templates": { "resolved": "crates/yaak-templates", "link": true diff --git a/package.json b/package.json index 9361af88a..3b3df39d4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "crates-tauri/yaak-fonts", "crates-tauri/yaak-license", "crates-tauri/yaak-mac-window", + "crates-tauri/yaak-updater", "crates/common/yaak-rpc-schema", "crates/yaak-crypto", "crates/yaak-git", diff --git a/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx b/packages/ui/src/components/ButtonInfiniteLoading.tsx similarity index 100% rename from apps/yaak-client/components/core/ButtonInfiniteLoading.tsx rename to packages/ui/src/components/ButtonInfiniteLoading.tsx diff --git a/packages/ui/src/components/settings/SettingsGeneral.tsx b/packages/ui/src/components/settings/SettingsGeneral.tsx index ed69ddf31..da9fc3c26 100644 --- a/packages/ui/src/components/settings/SettingsGeneral.tsx +++ b/packages/ui/src/components/settings/SettingsGeneral.tsx @@ -145,12 +145,14 @@ export function SettingsUpdatesSection({ onChange={(autoDownloadUpdates) => callbacks.onChange({ autoDownloadUpdates })} /> - callbacks.onChange({ checkNotifications })} - /> + {view.updates?.notificationsSetting !== false && ( + callbacks.onChange({ checkNotifications })} + /> + )} { expect(withFeedback).toContain("Prompt for feedback"); }); + test("leaves out the notifications row only when the app asks", () => { + const shown = text(render()); + expect(shown).toContain("Check for notifications"); + + const hidden = text( + render( + , + ), + ); + expect(hidden).toContain("Update Channel"); + expect(hidden).not.toContain("Check for notifications"); + }); + test("puts the app's banner under the heading and its own sections before App Info", () => { const t = text( render( diff --git a/packages/ui/src/components/settings/types.ts b/packages/ui/src/components/settings/types.ts index 72e71aa6a..e45d79071 100644 --- a/packages/ui/src/components/settings/types.ts +++ b/packages/ui/src/components/settings/types.ts @@ -69,6 +69,8 @@ export interface SettingsFontsView { export interface SettingsUpdatesView { /** A check for updates is running */ checking: boolean; + /** Show the row that turns notification checks off; shown unless false */ + notificationsSetting?: boolean; } /** Where a license stands, with what each state's copy needs */ diff --git a/packages/ui/src/components/updates/updateToasts.test.tsx b/packages/ui/src/components/updates/updateToasts.test.tsx new file mode 100644 index 000000000..8f4ec5b26 --- /dev/null +++ b/packages/ui/src/components/updates/updateToasts.test.tsx @@ -0,0 +1,128 @@ +import { createStore } from "jotai"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { toastsAtom } from "../../lib/toast"; +import { + showUpdateAvailableToast, + showUpdateInstalledToast, + showUpdateStateToast, + UPDATE_STATE_TOAST_ID, + UPDATE_TOAST_ID, + updateStateMessage, +} from "./updateToasts"; + +function text(node: ReactNode) { + return renderToStaticMarkup(<>{node}) + .replace(/<[^>]+>/g, " ") + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); +} + +/** The toast shown, with its message and its action as they read */ +function shown(store: ReturnType) { + vi.runAllTimers(); + const toasts = store.get(toastsAtom); + expect(toasts).toHaveLength(1); + const toast = toasts[0]!; + return { + toast, + message: text(toast.message), + action: text(toast.action?.({ hide: () => {} }) ?? null), + }; +} + +const noop = () => {}; + +describe("update toasts", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("offers an update the app installs, in the app's name", () => { + const store = createStore(); + showUpdateAvailableToast(store, { + appName: "Yaak Proxy", + update: { version: "2026.11.0", downloaded: false, install: "integrated" }, + onOpenUrl: noop, + onInstall: noop, + }); + const { toast, message, action } = shown(store); + expect(toast.id).toBe(UPDATE_TOAST_ID); + expect(toast.timeout).toBeNull(); + expect(message).toBe("Yaak Proxy 2026.11.0 is available Download and install the update?"); + // No release notes given, so no What's New + expect(action).toBe("Download and Install"); + }); + + test("offers What's New and installing a downloaded update, as Yaak's does", () => { + const store = createStore(); + showUpdateAvailableToast(store, { + appName: "Yaak", + update: { version: "2026.11.0", downloaded: true, install: "integrated" }, + whatsNewUrl: "https://yaak.app/changelog/2026.11.0", + downloadUrl: "https://yaak.app/download", + onOpenUrl: noop, + onInstall: noop, + }); + const { message, action } = shown(store); + expect(message).toBe("Yaak 2026.11.0 is available Do you want to install the update?"); + expect(action).toBe("Install Now What's New"); + }); + + test("says how to update an install the app can't update itself", () => { + const store = createStore(); + showUpdateAvailableToast(store, { + appName: "Yaak", + update: { version: "2026.11.0", downloaded: false, install: "manual" }, + downloadUrl: "https://yaak.app/download", + onOpenUrl: noop, + onInstall: noop, + }); + const { message, action } = shown(store); + expect(message).toBe("Yaak 2026.11.0 is available Download the new version to upgrade."); + expect(action).toBe("Download"); + }); + + test("offers to relaunch into an installed update", () => { + const store = createStore(); + const onRelaunch = vi.fn(); + showUpdateInstalledToast(store, { appName: "Yaak Proxy", version: "2026.11.0", onRelaunch }); + const { message, action } = shown(store); + expect(message).toBe("Yaak Proxy 2026.11.0 was installed Start using the new version now?"); + expect(action).toBe("Relaunch Yaak Proxy"); + }); + + test("says where an update already in hand stands, apart from its prompt", () => { + const store = createStore(); + showUpdateStateToast(store, { + appName: "Yaak Proxy", + state: { state: "downloading", version: "2026.11.0" }, + }); + const { toast, message } = shown(store); + expect(toast.id).toBe(UPDATE_STATE_TOAST_ID); + expect(toast.id).not.toBe(UPDATE_TOAST_ID); + expect(message).toBe("Yaak Proxy 2026.11.0 is downloading."); + }); + + test("words each state, and shows nothing for idle", () => { + expect(updateStateMessage("Yaak", { state: "checking" })).toBe("Already checking for updates."); + expect(updateStateMessage("Yaak", { state: "offered", version: "1.0.0" })).toBe( + "Yaak 1.0.0 is available.", + ); + expect(updateStateMessage("Yaak", { state: "installing", version: "1.0.0" })).toBe( + "Yaak 1.0.0 is installing.", + ); + expect(updateStateMessage("Yaak", { state: "installed", version: "1.0.0" })).toBe( + "Yaak 1.0.0 is installed. Relaunch to start using it.", + ); + const store = createStore(); + expect(showUpdateStateToast(store, { appName: "Yaak", state: { state: "idle" } })).toBeNull(); + vi.runAllTimers(); + expect(store.get(toastsAtom)).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/updates/updateToasts.tsx b/packages/ui/src/components/updates/updateToasts.tsx new file mode 100644 index 000000000..61d4555c0 --- /dev/null +++ b/packages/ui/src/components/updates/updateToasts.tsx @@ -0,0 +1,211 @@ +import type { createStore } from "jotai"; +import { showToast } from "../../lib/toast"; +import { Button } from "../Button"; +import { ButtonInfiniteLoading } from "../ButtonInfiniteLoading"; +import { Icon } from "../Icon"; +import { InlineCode } from "../InlineCode"; +import { HStack, VStack } from "../Stacks"; + +type Store = ReturnType; + +/** The one toast an app's update shows, replaced as it moves from available to installed */ +export const UPDATE_TOAST_ID = "update-info"; + +/** An available update, as the app's updater describes it */ +export interface UpdateToastInfo { + version: string; + /** Downloaded already, so installing needs no download */ + downloaded: boolean; + /** `integrated` when the app installs it itself; otherwise the user is told how to update */ + install: "integrated" | "flatpak" | "manual"; +} + +export interface ShowUpdateAvailableToastArgs { + /** As the copy says it: `Yaak`, `Yaak Proxy` */ + appName: string; + update: UpdateToastInfo; + /** The release notes for this version; no What's New button without them */ + whatsNewUrl?: string; + /** Where to download the app by hand, for an install that can't update itself */ + downloadUrl?: string; + onOpenUrl: (url: string) => void | Promise; + /** The user chose to install an `integrated` update */ + onInstall: () => void | Promise; +} + +/** Offers an available update: to install it, or, where the app can't, says how to get it */ +export function showUpdateAvailableToast( + store: Store, + { appName, update, whatsNewUrl, downloadUrl, onOpenUrl, onInstall }: ShowUpdateAvailableToastArgs, +) { + const { version, downloaded, install } = update; + + const whatsNewButton = whatsNewUrl != null && ( + + ); + + if (install !== "integrated") { + const flatpak = install === "flatpak"; + return showToast(store, { + id: UPDATE_TOAST_ID, + color: "info", + timeout: null, + message: ( + +

+ {appName} {version} is available +

+

+ {flatpak ? ( + <> + Update with flatpak update or your software center. + + ) : ( + "Download the new version to upgrade." + )} +

+
+ ), + action: () => ( + + {!flatpak && downloadUrl != null && ( + + )} + {whatsNewButton} + + ), + }); + } + + return showToast(store, { + id: UPDATE_TOAST_ID, + color: "info", + timeout: null, + message: ( + +

+ {appName} {version} is available +

+

+ {downloaded ? "Do you want to install" : "Download and install"} the update? +

+
+ ), + action: () => ( + + { + await onInstall(); + }} + > + {downloaded ? "Install Now" : "Download and Install"} + + {whatsNewButton} + + ), + }); +} + +/** The toast saying where an update already in hand stands, apart from its prompt */ +export const UPDATE_STATE_TOAST_ID = "update-state"; + +/** Where an app's update stands, as its updater reports it */ +export type UpdateToastState = + | { state: "idle" } + | { state: "checking" } + | { state: "offered" | "downloading" | "installing" | "installed"; version: string }; + +/** What the user who asked for a check is told while an update is already in hand */ +export function updateStateMessage(appName: string, state: UpdateToastState): string | null { + switch (state.state) { + case "idle": + return null; + case "checking": + return "Already checking for updates."; + case "offered": + return `${appName} ${state.version} is available.`; + case "downloading": + return `${appName} ${state.version} is downloading.`; + case "installing": + return `${appName} ${state.version} is installing.`; + case "installed": + return `${appName} ${state.version} is installed. Relaunch to start using it.`; + } +} + +/** + * Tells the user who asked for a check where the update already in hand stands, instead of + * starting another. The prompt it belongs to, if any, keeps its own toast. + */ +export function showUpdateStateToast( + store: Store, + { appName, state }: { appName: string; state: UpdateToastState }, +) { + const message = updateStateMessage(appName, state); + if (message == null) return null; + return showToast(store, { id: UPDATE_STATE_TOAST_ID, color: "info", message }); +} + +export interface ShowUpdateInstalledToastArgs { + appName: string; + version: string; + /** Relaunch the app into the installed version */ + onRelaunch: () => void; +} + +/** Says an update installed and offers to relaunch into it */ +export function showUpdateInstalledToast( + store: Store, + { appName, version, onRelaunch }: ShowUpdateInstalledToastArgs, +) { + return showToast(store, { + id: UPDATE_TOAST_ID, + color: "primary", + timeout: null, + message: ( + +

+ {appName} {version} was installed +

+

Start using the new version now?

+
+ ), + action: ({ hide }) => ( + { + hide(); + setTimeout(onRelaunch, 200); + }} + > + Relaunch {appName} + + ), + }); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5eff1cfab..04dbdd8d0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ export type { BannerProps } from "./components/Banner"; export { Banner } from "./components/Banner"; export type { ButtonProps } from "./components/Button"; export { Button, CONTROL_TOOLTIP_DELAY } from "./components/Button"; +export { ButtonInfiniteLoading } from "./components/ButtonInfiniteLoading"; export type { CheckboxProps } from "./components/Checkbox"; export { Checkbox } from "./components/Checkbox"; export type { CopyIconButtonProps } from "./components/CopyIconButton"; @@ -298,3 +299,17 @@ export { getNodeText } from "./lib/getNodeText"; export { minPromiseMillis } from "./lib/minPromiseMillis"; export type { ShowToastArgs, ToastInstance } from "./lib/toast"; export { hideToast, hideToastById, showToast, toastsAtom } from "./lib/toast"; +export type { + ShowUpdateAvailableToastArgs, + ShowUpdateInstalledToastArgs, + UpdateToastInfo, + UpdateToastState, +} from "./components/updates/updateToasts"; +export { + showUpdateAvailableToast, + showUpdateInstalledToast, + showUpdateStateToast, + UPDATE_STATE_TOAST_ID, + UPDATE_TOAST_ID, + updateStateMessage, +} from "./components/updates/updateToasts"; diff --git a/scripts/replace-version.cjs b/scripts/replace-version.cjs index 9a739d9ca..6bb76c794 100644 --- a/scripts/replace-version.cjs +++ b/scripts/replace-version.cjs @@ -6,7 +6,18 @@ if (!version) { throw new Error("YAAK_VERSION environment variable not set"); } -const tauriConfigPath = path.join(__dirname, "../crates-tauri/yaak-app-client/tauri.conf.json"); +// The app to stamp, Yaak's client unless another is named: `npm run replace-version -- proxy` +const apps = { + client: "yaak-app-client", + proxy: "yaak-app-proxy", +}; +const appName = process.argv[2] ?? "client"; +const appDir = apps[appName]; +if (appDir == null) { + throw new Error(`Unknown app "${appName}", expected one of ${Object.keys(apps).join(", ")}`); +} + +const tauriConfigPath = path.join(__dirname, "../crates-tauri", appDir, "tauri.conf.json"); const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, "utf8")); tauriConfig.version = version; diff --git a/scripts/sign-proxy-vendored.cjs b/scripts/sign-proxy-vendored.cjs index 1735a1941..74de47f6f 100644 --- a/scripts/sign-proxy-vendored.cjs +++ b/scripts/sign-proxy-vendored.cjs @@ -1,24 +1,51 @@ #!/usr/bin/env node /** - * Signs the executables Yaak Proxy's bundle takes from Yaak's vendored tools with hardened - * runtime, as notarization requires of every executable in the app. Runs after vendoring, in - * the bundle's before-build step, with the identity `scripts/bundle-proxy.mjs` chose. Without - * one, or off macOS, the bundle is unsigned anyway and this does nothing. + * Signs the executables Yaak Proxy's bundle takes from outside its own build with hardened + * runtime, as notarization requires of every executable in the app: the privileged helper, + * which `scripts/vendor-proxy-helper.cjs` built for this target, and the `yaakprotoc` from + * Yaak's vendored tools. Runs after vendoring, in the bundle's before-build step, with the + * identity `scripts/bundle-proxy.mjs` chose. Without one, or off macOS, the bundle is unsigned + * anyway and this does nothing. */ const { execFileSync } = require("child_process"); -const { existsSync } = require("fs"); +const { existsSync, readdirSync } = require("fs"); const path = require("path"); const rootDir = path.join(__dirname, ".."); const identity = process.env.APPLE_SIGNING_IDENTITY; if (process.platform !== "darwin" || !identity) { - console.log("No signing identity, so the vendored protoc is left unsigned"); + console.log("No signing identity, so the vendored helper and protoc are left unsigned"); process.exit(0); } +// A secure timestamp, as notarization requires; an ad-hoc (`-`) local build can't have one +const timestamp = identity === "-" ? [] : ["--timestamp"]; + +function sign(file, extraArgs) { + execFileSync( + "codesign", + ["--force", "--options", "runtime", ...timestamp, ...extraArgs, "--sign", identity, file], + { stdio: "inherit" }, + ); + console.log(`Signed ${path.relative(rootDir, file)}`); +} + +// The helper, under the launchd label it runs as +const helperDir = path.join(rootDir, "crates-tauri", "yaak-app-proxy", "vendored", "helper"); +const helpers = existsSync(helperDir) + ? readdirSync(helperDir).filter((name) => name.startsWith("yaak-proxy-helper-")) + : []; +if (helpers.length === 0) { + console.error(`No helper in ${helperDir}: run \`npm run proxy:vendor-helper\` first`); + process.exit(1); +} +for (const name of helpers) { + sign(path.join(helperDir, name), ["--identifier", "app.yaak.proxy.helper"]); +} + const clientDir = path.join(rootDir, "crates-tauri", "yaak-app-client"); const protoc = path.join(clientDir, "vendored", "protoc", "yaakprotoc"); const entitlements = path.join(clientDir, "macos", "entitlements.yaakprotoc.plist"); @@ -28,9 +55,4 @@ if (!existsSync(protoc)) { process.exit(1); } -execFileSync( - "codesign", - ["--force", "--options", "runtime", "--entitlements", entitlements, "--sign", identity, protoc], - { stdio: "inherit" }, -); -console.log(`Signed ${path.relative(rootDir, protoc)}`); +sign(protoc, ["--entitlements", entitlements]); diff --git a/scripts/verify-proxy-bundle.mjs b/scripts/verify-proxy-bundle.mjs new file mode 100644 index 000000000..48e43b6f9 --- /dev/null +++ b/scripts/verify-proxy-bundle.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +/** + * Checks a built Yaak Proxy bundle before it ships: its version and architecture, the + * privileged helper and its LaunchDaemon plist where `SMAppService` looks for them, and every + * signature. With `--signed`, also that the app and helper are signed by the same Developer ID + * team with the hardened runtime, that the app meets the requirement the helper holds callers + * to, and that Gatekeeper accepts the notarized, stapled app. Without it (an ad-hoc local + * build) the signatures need only be intact. + * + * node scripts/verify-proxy-bundle.mjs "/Yaak Proxy.app" \ + * [--version 2026.11.0] [--arch arm64|x86_64] [--signed] [--team ABCDE12345] + * + * Registers nothing: `--helper-status` only asks `SMAppService` what it knows. + */ + +import { spawnSync } from "child_process"; +import { existsSync, statSync } from "fs"; +import path from "path"; +import { parseArgs } from "util"; + +const APP_IDENTIFIER = "app.yaak.proxy"; +const HELPER_LABEL = "app.yaak.proxy.helper"; +const HELPER_PROGRAM = "Contents/MacOS/yaak-proxy-helper"; +const HELPER_PLIST = `Contents/Library/LaunchDaemons/${HELPER_LABEL}.plist`; +const MAIN_PROGRAM = "Contents/MacOS/yaak-app-proxy"; +const PROTOC = "Contents/Resources/vendored/protoc/yaakprotoc"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + version: { type: "string" }, + arch: { type: "string" }, + signed: { type: "boolean", default: false }, + team: { type: "string" }, + }, +}); + +const app = positionals[0]; +if (app == null || !existsSync(app)) { + console.error(`No app bundle at ${app ?? "(none given)"}`); + process.exit(2); +} + +const failures = []; +function check(ok, what) { + console.log(`${ok ? "ok " : "FAIL"} ${what}`); + if (!ok) failures.push(what); +} + +/** Runs a tool and returns its exit status and output, stderr included, as codesign writes there */ +function run(cmd, args) { + const result = spawnSync(cmd, args, { encoding: "utf8" }); + const error = result.error ? `${result.error.message}\n` : ""; + return { status: result.status, out: `${error}${result.stdout ?? ""}${result.stderr ?? ""}` }; +} + +function plistValue(file, key, format = "raw") { + const { status, out } = run("plutil", ["-extract", key, format, "-o", "-", file]); + return status === 0 ? out.trim() : null; +} + +/** What `codesign -dv` says of a signature, by its keys: TeamIdentifier, Identifier, flags… */ +function signature(file) { + const { status, out } = run("codesign", ["-dvvv", file]); + const info = { status, authorities: /** @type {string[]} */ ([]) }; + for (const line of out.split("\n")) { + const match = line.match(/^(\w+)=(.*)$/); + if (match == null) continue; + if (match[1] === "Authority") info.authorities.push(match[2]); + else info[match[1]] = match[2]; + } + const flags = out.match(/flags=0x[0-9a-f]+\(([^)]*)\)/); + info.flags = flags ? flags[1].split(",") : []; + return info; +} + +const at = (relative) => path.join(app, relative); +const infoPlist = at("Contents/Info.plist"); + +// The app itself +const identifier = plistValue(infoPlist, "CFBundleIdentifier"); +check(identifier === APP_IDENTIFIER, `bundle identifier is ${APP_IDENTIFIER} (${identifier})`); +const version = plistValue(infoPlist, "CFBundleShortVersionString"); +if (values.version != null) { + const expected = values.version.replace(/^v/, ""); + // macOS takes the release part of a prerelease version as its short version + check( + version === expected || version === expected.split("-")[0], + `version is ${expected} (${version})`, + ); +} +check(version !== "0.0.0", `version is not the unset 0.0.0 (${version})`); + +// The helper and its plist, where SMAppService.daemon(plistName:) expects them +const helper = at(HELPER_PROGRAM); +const helperPlist = at(HELPER_PLIST); +check(existsSync(helper) && (statSync(helper).mode & 0o111) !== 0, `helper at ${HELPER_PROGRAM}`); +check(existsSync(helperPlist), `helper plist at ${HELPER_PLIST}`); +if (existsSync(helperPlist)) { + check(plistValue(helperPlist, "Label") === HELPER_LABEL, `plist Label is ${HELPER_LABEL}`); + check( + plistValue(helperPlist, "BundleProgram") === HELPER_PROGRAM, + `plist BundleProgram is ${HELPER_PROGRAM}`, + ); + const associated = JSON.parse( + plistValue(helperPlist, "AssociatedBundleIdentifiers", "json") ?? "[]", + ); + check( + associated.includes(identifier), + `plist AssociatedBundleIdentifiers includes the app's ${identifier}`, + ); +} + +// Built for the architecture asked for +if (values.arch != null) { + for (const file of [MAIN_PROGRAM, HELPER_PROGRAM]) { + const { out } = run("lipo", ["-archs", at(file)]); + check( + out.split(/\s+/).includes(values.arch), + `${file} is built for ${values.arch} (${out.trim()})`, + ); + } +} + +// What SMAppService makes of the bundled plist, without registering anything. An Intel build +// on an Apple silicon runner without Rosetta can't run, so it is only reported as skipped. +const helperStatus = run(at(MAIN_PROGRAM), ["--helper-status"]); +if (helperStatus.status !== 0 && /bad cpu type|EBADARCH/i.test(helperStatus.out)) { + console.log(`skip --helper-status: this Mac can't run ${values.arch ?? "this"} code`); +} else { + console.log(helperStatus.out.trim().replace(/^/gm, " ")); + check(helperStatus.status === 0, "--helper-status runs"); + check(!/^plist: none/m.test(helperStatus.out), "the app finds its helper plist"); + if (values.signed) { + check( + /^state: (NotRegistered|RequiresApproval|Enabled)$/m.test(helperStatus.out), + "SMAppService knows the helper", + ); + } +} + +// Signatures +const deep = run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", app]); +check(deep.status === 0, `codesign --verify --deep --strict\n${deep.out.trim()}`); + +if (values.signed) { + const appSig = signature(app); + const helperSig = signature(helper); + const protocSig = signature(at(PROTOC)); + const team = appSig.TeamIdentifier; + check( + team != null && team !== "not set" && /^[A-Z0-9]{10}$/.test(team), + `app is signed by a team (${team})`, + ); + if (values.team != null) { + check(team === values.team, `app team is the helper's built-in team ${values.team} (${team})`); + } + check( + appSig.authorities.some((a) => a.startsWith("Developer ID Application:")), + `app is signed with a Developer ID Application identity (${appSig.authorities[0] ?? "none"})`, + ); + check(appSig.flags.includes("runtime"), "app has the hardened runtime"); + check( + helperSig.TeamIdentifier === team, + `helper is signed by the app's team (${helperSig.TeamIdentifier})`, + ); + check(helperSig.flags.includes("runtime"), "helper has the hardened runtime"); + check( + protocSig.TeamIdentifier === team, + `protoc is signed by the app's team (${protocSig.TeamIdentifier})`, + ); + check(protocSig.flags.includes("runtime"), "protoc has the hardened runtime"); + + // The requirement the helper holds its callers to (yaak-proxy-helper's code_requirement) + const requirement = `anchor apple generic and (identifier "${APP_IDENTIFIER}") and certificate leaf[subject.OU] = "${values.team ?? team}"`; + const meets = run("codesign", ["--verify", `-R=${requirement}`, app]); + check(meets.status === 0, `app meets the helper's caller requirement\n${meets.out.trim()}`); + + const gatekeeper = run("spctl", ["--assess", "--type", "execute", "--verbose=4", app]); + check(gatekeeper.status === 0, `spctl --assess accepts the app\n${gatekeeper.out.trim()}`); + const stapled = run("xcrun", ["stapler", "validate", app]); + check(stapled.status === 0, `notarization ticket is stapled\n${stapled.out.trim()}`); +} + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed`); + process.exit(1); +} +console.log("\nAll checks passed");