mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Yaak Proxy: release build, dry run and updater (#836)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cc9f7a5b8
commit
6e39955f17
40 files changed
+3024
-536
No files matched your search
@@ -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
|
||||
Generated
+21
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<UpdateState>("update_state", (state) => {
|
||||
showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state });
|
||||
});
|
||||
|
||||
platform.listen<YaakNotification>("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: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} was installed</h2>
|
||||
<p className="text-text-subtle text-sm">Start using the new version now?</p>
|
||||
</VStack>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
className="mr-auto min-w-20"
|
||||
color="primary"
|
||||
loadingChildren="Restarting..."
|
||||
onClick={() => {
|
||||
hide();
|
||||
setTimeout(() => rpc("cmd_restart", {}), 200);
|
||||
}}
|
||||
>
|
||||
Relaunch Yaak
|
||||
</ButtonInfiniteLoading>
|
||||
),
|
||||
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 = (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
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: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl("https://yaak.app/download");
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
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: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{downloaded ? "Do you want to install" : "Download and install"} the update?
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
color="info"
|
||||
className="min-w-40"
|
||||
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
|
||||
onClick={async () => {
|
||||
await platform.emit(replyEventId, {
|
||||
type: "action",
|
||||
action: "install",
|
||||
} satisfies UpdateResponse);
|
||||
}}
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SettingsCallbacks>(() => {
|
||||
@@ -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 (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,19 @@ export function subscribe<T>(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<T>(event: string, callback: (payload: T) => void): () => void {
|
||||
const subscription = unsubscribeWhenReady(
|
||||
getCurrentWebviewWindow().listen<T>(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(
|
||||
|
||||
@@ -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<string, unknown>) => Promise<unknown>>();
|
||||
|
||||
vi.mock("./tauri", () => ({
|
||||
command: (cmd: string, args?: Record<string, unknown>) => 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<typeof createStore>) {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createStore>;
|
||||
|
||||
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<boolean> {
|
||||
return command<boolean>("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<void> {
|
||||
if (store.get(updateCheckingAtom)) return;
|
||||
store.set(updateCheckingAtom, true);
|
||||
try {
|
||||
const args = checkArgs(store.get(settingsValuesAtom), false);
|
||||
const found = await minPromiseMillis(command<boolean>("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<boolean>("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<UpdateInfo>(UPDATE_AVAILABLE_EVENT, (info) => {
|
||||
offerUpdate(store, info).catch(console.error);
|
||||
});
|
||||
subscribeHere<UpdateState>(UPDATE_STATE_EVENT, (state) => {
|
||||
showUpdateStateToast(store, { appName: APP_NAME, state });
|
||||
});
|
||||
subscribeHere<string>(UPDATE_INSTALLED_EVENT, (version) => {
|
||||
showUpdateInstalledToast(store, {
|
||||
appName: APP_NAME,
|
||||
version,
|
||||
onRelaunch: () => {
|
||||
command<void>("cmd_restart").catch(console.error);
|
||||
},
|
||||
});
|
||||
});
|
||||
subscribeHere<void>(CHECK_FOR_UPDATES_EVENT, () => {
|
||||
checkForUpdatesNow(store).catch(console.error);
|
||||
});
|
||||
if (background) {
|
||||
const later = () => setTimeout(() => checkInBackground(store), BACKGROUND_CHECK_DELAY_MS);
|
||||
later();
|
||||
onWindowFocus(later);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
-20
@@ -6,26 +6,6 @@ export type PluginUpdateInfo = { name: string; currentVersion: string; latestVer
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
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<bool> {
|
||||
// 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::<UpdateGate>().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<bool> {
|
||||
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<R: Runtime>(window: &WebviewWindow<R>, 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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
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::<UpdateResponse>();
|
||||
let w_for_listener = window.clone();
|
||||
|
||||
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
|
||||
match serde_json::from_str::<UpdateResponse>(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<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(window: &WebviewWindow<R>, 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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
Ok(window.path().app_cache_dir()?.join("updates"))
|
||||
}
|
||||
|
||||
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
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<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
@@ -47,6 +48,14 @@ fn handle_menu_events<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
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::<ProxyCtx>();
|
||||
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 {
|
||||
|
||||
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
/// 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<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
updater: State<'_, Mutex<ProxyUpdater>>,
|
||||
channel: String,
|
||||
background: bool,
|
||||
auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
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<R: Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||
yaak_updater::restart::request_restart(&app_handle);
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
async fn check<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
mode: UpdateMode,
|
||||
trigger: UpdateTrigger,
|
||||
auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
// One check and one update at a time, whichever window or trigger asks
|
||||
let token = match window.state::<UpdateGate>().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<R: Runtime>(
|
||||
_window: &WebviewWindow<R>,
|
||||
_mode: UpdateMode,
|
||||
_trigger: UpdateTrigger,
|
||||
_auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
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<R: Runtime>(window: &WebviewWindow<R>) -> 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<String> = 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<MockRuntime> {
|
||||
// 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<MockRuntime>);
|
||||
|
||||
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<MockRuntime>, label: &str) -> WebviewWindow<MockRuntime> {
|
||||
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<MockRuntime>]) -> mpsc::Receiver<Shown> {
|
||||
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<Shown>) -> Vec<Shown> {
|
||||
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<MockRuntime>, reply: &str, response: serde_json::Value) {
|
||||
app.emit(reply, response).unwrap();
|
||||
}
|
||||
|
||||
fn ack(app: &tauri::App<MockRuntime>, reply: &str) {
|
||||
answer(app, reply, serde_json::json!({ "type": "ack" }));
|
||||
}
|
||||
|
||||
fn user_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
|
||||
tauri::async_runtime::block_on(check(window, UpdateMode::Beta, UpdateTrigger::User, false))
|
||||
}
|
||||
|
||||
fn background_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
|
||||
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<MockRuntime>) {
|
||||
let gate = app.state::<UpdateGate>();
|
||||
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::<Mutex<ProxyUpdater>>();
|
||||
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::<UpdateGate>().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);
|
||||
}
|
||||
}
|
||||
@@ -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<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
|
||||
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)?,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
+32
@@ -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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/tauri-updater",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "bindings/index.ts"
|
||||
}
|
||||
@@ -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<Mutex<Gate>>);
|
||||
|
||||
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<CheckToken, Busy> {
|
||||
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<UpdateInfo>,
|
||||
/// The gate, to note the windows the prompt is shown in again
|
||||
pub(crate) gate: Option<UpdateGate>,
|
||||
}
|
||||
|
||||
impl Busy {
|
||||
pub(crate) fn new(state: UpdateState, offer: Option<UpdateInfo>) -> 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<UpdateInfo>,
|
||||
/// 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<Box<dyn FnOnce() + Send>>,
|
||||
/// 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<String>,
|
||||
/// 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<String> {
|
||||
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<dyn FnOnce() + Send>) {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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<R: Runtime>(
|
||||
mut self,
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(&self, window: &WebviewWindow<R>) {
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
run: &RunGuard,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
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::<UpdateResponse>();
|
||||
let w_for_listener = window.clone();
|
||||
|
||||
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
|
||||
match serde_json::from_str::<UpdateResponse>(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<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
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<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
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<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
Ok(window.path().app_cache_dir()?.join("updates"))
|
||||
}
|
||||
|
||||
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
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<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
File renamed without changes.
Generated
+9
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
File renamed without changes.
@@ -145,12 +145,14 @@ export function SettingsUpdatesSection({
|
||||
onChange={(autoDownloadUpdates) => callbacks.onChange({ autoDownloadUpdates })}
|
||||
/>
|
||||
|
||||
<SettingRowBoolean
|
||||
title="Check for notifications"
|
||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
||||
checked={values.checkNotifications}
|
||||
onChange={(checkNotifications) => callbacks.onChange({ checkNotifications })}
|
||||
/>
|
||||
{view.updates?.notificationsSetting !== false && (
|
||||
<SettingRowBoolean
|
||||
title="Check for notifications"
|
||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
||||
checked={values.checkNotifications}
|
||||
onChange={(checkNotifications) => callbacks.onChange({ checkNotifications })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRowBoolean
|
||||
title="Send anonymous usage statistics"
|
||||
|
||||
@@ -129,6 +129,22 @@ describe("SettingsGeneral", () => {
|
||||
expect(withFeedback).toContain("Prompt for feedback");
|
||||
});
|
||||
|
||||
test("leaves out the notifications row only when the app asks", () => {
|
||||
const shown = text(render(<SettingsGeneral view={stubView()} callbacks={callbacks} />));
|
||||
expect(shown).toContain("Check for notifications");
|
||||
|
||||
const hidden = text(
|
||||
render(
|
||||
<SettingsGeneral
|
||||
view={stubView({ updates: { checking: false, notificationsSetting: false } })}
|
||||
callbacks={callbacks}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<typeof createStore>) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createStore>;
|
||||
|
||||
/** 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<void>;
|
||||
/** The user chose to install an `integrated` update */
|
||||
onInstall: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** 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 && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await onOpenUrl(whatsNewUrl);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (install !== "integrated") {
|
||||
const flatpak = install === "flatpak";
|
||||
return showToast(store, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">
|
||||
{appName} {version} is available
|
||||
</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && downloadUrl != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await onOpenUrl(downloadUrl);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return showToast(store, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">
|
||||
{appName} {version} is available
|
||||
</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{downloaded ? "Do you want to install" : "Download and install"} the update?
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
color="info"
|
||||
className="min-w-40"
|
||||
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
|
||||
onClick={async () => {
|
||||
await onInstall();
|
||||
}}
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 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: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">
|
||||
{appName} {version} was installed
|
||||
</h2>
|
||||
<p className="text-text-subtle text-sm">Start using the new version now?</p>
|
||||
</VStack>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
className="mr-auto min-w-20"
|
||||
color="primary"
|
||||
loadingChildren="Restarting..."
|
||||
onClick={() => {
|
||||
hide();
|
||||
setTimeout(onRelaunch, 200);
|
||||
}}
|
||||
>
|
||||
Relaunch {appName}
|
||||
</ButtonInfiniteLoading>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
@@ -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 "<path>/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");
|
||||
Reference in new issue
Block a user