From ec27142f697e979950cdc385b84d78671cbd08ae Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 09:14:18 +0200 Subject: [PATCH 1/4] feat(update): in-place updates on Windows, macOS and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update metadata was already being generated on every build and thrown away at the upload step. This publishes it, adds the updater that consumes it, and — first — decides who is allowed to update at all. install-channel.ts is the load-bearing part. On the Microsoft Store, Flathub, Snap and Nix the package manager already updates the app, and a second updater there is not merely redundant: the MSIX install directory is read-only, and a "download the .exe" prompt walks a Store user into a SECOND, parallel installation that then drifts forever. Those channels get no update affordance at all. `dev` and `unknown` builds cannot self-update either but keep the release-page link, which is why "may we self-update" and "does a package manager own this" are two predicates rather than one. Artifacts, per platform: - Windows: latest.yml and the .exe.blockmap were built by every run and discarded, because the upload glob only matched the .exe. Differential updates were one glob line away. - Linux: app-update.yml and the package-type marker already ship INSIDE every deb/rpm/pacman, and the AppImage already carries its blockmap. Only latest-linux.yml was missing; one feed serves all four formats. - macOS: nothing was generated, because the pack step passes --dir, which skips every target. So the ZIP Squirrel.Mac requires is built with ditto from the signed .app, and named for the instruction set — electron-updater matches the literal substring "arm64", while our DMGs are named Apple-Silicon/Intel for the user. Getting that wrong serves Apple Silicon the Intel build with no error anywhere, so it is asserted in CI. The two macOS arches build on different runners, so neither job can write the feed: electron-builder would have each emit its own latest-mac.yml and the second upload would overwrite the first (electron-builder#5592, closed as not-planned). Each job emits a JSON sidecar; publish-release folds them into one feed listing both. The ordering and fallback rules live in a pure function with tests, because nothing else here can exercise them. The publish block is declared rather than inferred: app-builder-lib was falling back to reading .git/config's origin, which works in CI and silently stops working for anyone building from a source tarball — and a missing package-type marker makes electron-updater treat a .deb as an AppImage. Guards, all of which are the difference between an update and a lost take: autoDownload and autoInstallOnAppQuit are both off (window-all-closed quits this app and the HUD is a window, so the default would fire an installer on closing the HUD); installing is vetoed while recording, which on Windows is also a hard requirement because the capture helpers spawn from inside the install directory and NSIS cannot overwrite a running .exe; and on macOS an app outside /Applications is refused, because App Translocation runs it from a read-only image Squirrel cannot replace. The release-page fallback from #313 is kept rather than replaced. Every macOS install up to v1.9.0-rc.1 is ad-hoc signed, and Squirrel validates an update against the INSTALLED app's designated requirement — those users can never be reached by any updater and need one manual reinstall. Also fixes the lifecycle of the manual check itself: it was `void`ed, and main-process-errors re-throws every unhandled rejection, so a check that settled after quit could take the process down. Stacks on #313. --- .github/workflows/build.yml | 111 ++++++++++++++++++++-- electron-builder.json5 | 29 ++++++ electron/auto-updater.test.ts | 45 +++++++++ electron/auto-updater.ts | 92 ++++++++++++++++++ electron/install-channel.test.ts | 136 +++++++++++++++++++++++++++ electron/install-channel.ts | 146 +++++++++++++++++++++++++++++ electron/main.ts | 135 ++++++++++++++++++++++++-- package-lock.json | 91 ++++++++++++++++-- package.json | 5 + scripts/mac-update-feed.mjs | 105 +++++++++++++++++++++ scripts/mac-update-feed.test.mjs | 63 +++++++++++++ src/i18n/locales/ar/common.json | 5 + src/i18n/locales/en/common.json | 5 + src/i18n/locales/es/common.json | 5 + src/i18n/locales/fr/common.json | 5 + src/i18n/locales/it/common.json | 5 + src/i18n/locales/ja-JP/common.json | 5 + src/i18n/locales/ko-KR/common.json | 5 + src/i18n/locales/pt-BR/common.json | 5 + src/i18n/locales/ru/common.json | 5 + src/i18n/locales/tr/common.json | 5 + src/i18n/locales/vi/common.json | 5 + src/i18n/locales/zh-CN/common.json | 5 + src/i18n/locales/zh-TW/common.json | 5 + 24 files changed, 998 insertions(+), 25 deletions(-) create mode 100644 electron/auto-updater.test.ts create mode 100644 electron/auto-updater.ts create mode 100644 electron/install-channel.test.ts create mode 100644 electron/install-channel.ts create mode 100644 scripts/mac-update-feed.mjs create mode 100644 scripts/mac-update-feed.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71e95f54d..6925c8a61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,10 +56,27 @@ jobs: uses: actions/upload-artifact@v7 with: name: openscreen-windows - path: release/**/Openscreen.Setup.*.exe + # latest.yml is the update feed electron-updater reads; the .blockmap is what lets it + # download a delta instead of the full ~243 MB installer. Both were already produced + # by every build and thrown away here, because this glob only matched the .exe. + path: | + release/**/Openscreen.Setup.*.exe + release/**/Openscreen.Setup.*.exe.blockmap + release/**/latest.yml if-no-files-found: error retention-days: 30 + # `if-no-files-found: error` evaluates the UNION of the globs above, so a dead pattern + # among live ones never fails — that is exactly how the *.zsync glob rotted unnoticed on + # the Linux job. Assert the update feed specifically. + - name: Verify the update feed was produced + shell: bash + run: | + test -f "$(find release -name latest.yml | head -1)" \ + || { echo "::error::latest.yml missing — electron-updater has no feed to read"; exit 1; } + test -f "$(find release -name 'Openscreen.Setup.*.exe.blockmap' | head -1)" \ + || { echo "::error::blockmap missing — differential updates would silently degrade"; exit 1; } + build-windows-store: name: Windows Store package runs-on: windows-latest @@ -431,6 +448,49 @@ jobs: exit 1 fi + # Squirrel.Mac installs from a ZIP and nothing else — `MacUpdater` looks for one and + # throws ERR_UPDATER_ZIP_FILE_NOT_FOUND when the feed offers only a DMG. Built here, from + # the same signed bundle the DMG is about to be built from, so the two can never diverge. + # + # `ditto`, never `zip`: `zip` flattens the symlinks inside Contents/Frameworks and drops + # extended attributes, producing an archive whose .app fails signature validation on + # arrival — and Squirrel validates the downloaded bundle against the INSTALLED app's + # designated requirement before swapping it. + # + # ponytail: the .app inside this ZIP is signed but not stapled — only the DMG is notarized + # today. Squirrel does not check notarization, and a ZIP fetched by the app's own process + # carries no com.apple.quarantine, so Gatekeeper never re-scans the swapped bundle. + # Notarizing the .app as well would cost a second notarytool round trip per arch; revisit + # if Apple ever tightens this. + - name: Create update ZIP + id: update_zip + run: | + VERSION="${{ steps.version.outputs.version }}" + APP="${{ steps.find_app.outputs.app_bundle }}" + RELEASE_DIR="release/${VERSION}" + # The instruction set, NOT the marketing name used for the DMG: electron-updater picks + # the file for an Apple Silicon client by matching the literal substring "arm64". + ZIP="${RELEASE_DIR}/Openscreen-Mac-${{ matrix.arch }}-${VERSION}.zip" + ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + node scripts/mac-update-feed.mjs describe "$ZIP" "$VERSION" \ + "${RELEASE_DIR}/update-info-${{ matrix.arch }}.json" + echo "zip_path=${ZIP}" >> "$GITHUB_OUTPUT" + echo "info_path=${RELEASE_DIR}/update-info-${{ matrix.arch }}.json" >> "$GITHUB_OUTPUT" + + # A ZIP whose .app is unreadable, or which lost the bundle root, downloads fine and then + # fails at install time on a user's machine. Nothing else in CI can catch that. + - name: Verify update ZIP + run: | + ZIP="${{ steps.update_zip.outputs.zip_path }}" + unzip -l "$ZIP" | grep -q "Openscreen.app/Contents/MacOS/" \ + || { echo "::error::${ZIP} does not contain Openscreen.app/Contents/MacOS — ditto lost the bundle root"; exit 1; } + if [ "${{ matrix.arch }}" = "arm64" ]; then + case "$ZIP" in + *arm64*) ;; + *) echo "::error::the arm64 ZIP must carry 'arm64' in its name or Apple Silicon clients silently receive the Intel build"; exit 1 ;; + esac + fi + - name: Create DMG id: dmg run: | @@ -522,7 +582,13 @@ jobs: uses: actions/upload-artifact@v7 with: name: openscreen-mac-${{ matrix.arch }} - path: ${{ steps.dmg.outputs.dmg_path }} + # The DMG is what a new user downloads; the ZIP is what an existing install updates + # from. The JSON sidecar is folded into a single latest-mac.yml in publish-release — + # each arch is built on a different runner, so neither job can write the feed alone. + path: | + ${{ steps.dmg.outputs.dmg_path }} + ${{ steps.update_zip.outputs.zip_path }} + ${{ steps.update_zip.outputs.info_path }} if-no-files-found: error retention-days: 30 @@ -659,15 +725,24 @@ jobs: release/**/*.deb release/**/*.pacman release/**/*.rpm - # No *.zsync: nothing produces one. zsync is electron-updater's delta format, - # this repo has no updater (no electron-updater, no autoUpdater, no - # latest-linux.yml), and app-builder-lib 26.x dropped zsync entirely in favour - # of the block map it embeds in the AppImage. The glob had matched nothing - # since the dependency bump, silently — `if-no-files-found: error` evaluates - # the union of these patterns, so one dead glob among live ones never fails. + release/**/latest-linux.yml + # Still no *.zsync, and there never will be: zsync was electron-updater's old delta + # format and app-builder-lib 26.x dropped it in favour of the block map it embeds + # directly in the AppImage. That glob had matched nothing since the dependency bump, + # silently — `if-no-files-found: error` evaluates the union of these patterns, so one + # dead glob among live ones never fails. Hence the explicit assertion below. + # + # latest-linux.yml serves all four formats from one file: each updater picks its own + # extension out of the `files:` list. app-update.yml and the `package-type` marker are + # already inside the deb/rpm/pacman payloads — only this feed was missing. if-no-files-found: error retention-days: 30 + - name: Verify the update feed was produced + run: | + test -f "$(find release -name latest-linux.yml | head -1)" \ + || { echo "::error::latest-linux.yml missing — electron-updater has no feed to read"; exit 1; } + publish-release: name: Publish GitHub release runs-on: ubuntu-latest @@ -799,6 +874,26 @@ jobs: name: openscreen-linux path: artifacts/linux + # The two arches are built on different runners, so neither macOS job can write the feed: + # electron-builder would have each emit its own latest-mac.yml and the second upload would + # overwrite the first, serving one architecture the wrong build (electron-builder#5592). + # Fold the two JSON sidecars into ONE latest-mac.yml listing both, then drop the sidecars + # so they never reach the release. + - name: Build the macOS update feed + run: | + node scripts/mac-update-feed.mjs merge \ + artifacts/mac-arm64/update-info-arm64.json \ + artifacts/mac-x64/update-info-x64.json \ + artifacts/latest-mac.yml + rm -f artifacts/mac-arm64/update-info-arm64.json artifacts/mac-x64/update-info-x64.json + test -f artifacts/latest-mac.yml + # Exactly two `- url:` entries. One means an arch is being served the other's build. + ENTRIES="$(grep -c '^ - url:' artifacts/latest-mac.yml)" + [ "$ENTRIES" -eq 2 ] \ + || { echo "::error::latest-mac.yml lists ${ENTRIES} builds, expected 2 (one per arch)"; exit 1; } + grep -q 'arm64' artifacts/latest-mac.yml \ + || { echo "::error::latest-mac.yml has no arm64 entry — Apple Silicon would update onto the Intel build"; exit 1; } + - name: Publish release assets env: GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} diff --git a/electron-builder.json5 b/electron-builder.json5 index 86dbad7c2..8110c9b47 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -10,6 +10,27 @@ "**/*.node" ], "productName": "Openscreen", + // Declared explicitly, NOT to start publishing from CI — every build still passes + // `--publish never`, which suppresses uploading only. What this turns on is the update + // METADATA: `latest.yml` / `latest-mac.yml` / `latest-linux.yml`, the `.blockmap` used for + // differential downloads, `app-update.yml` inside resources, and the `package-type` marker + // in deb/rpm/pacman that tells electron-updater which installer to run. + // + // Some of that was already being produced, because app-builder-lib falls back to inferring + // the provider from `.git/config`'s origin when no publish config is declared — which works + // in CI and silently stops working for anyone building from a source tarball. Declaring it + // (plus `repository` in package.json) makes it deterministic instead of incidental. + // + // The Store (appx) target deliberately gets NO publish config: `isSuitableWindowsTarget()` + // only writes `app-update.yml` for nsis, so the MSIX build cannot arm an updater that its + // read-only container could never run. Do not add `appx.electronUpdaterAware`. + "publish": [ + { + "provider": "github", + "owner": "getopenscreen", + "repo": "openscreen" + } + ], // Fails the build when compositor_view.node is older than the crates/ Rust sources. Plain // `npm run build` does not rebuild the addon, and a stale one fails SILENTLY at runtime // (unknown scene fields are #[serde(default)]) rather than erroring. See the script header @@ -78,6 +99,14 @@ "hardenedRuntime": true, "entitlements": "macos.entitlements", "entitlementsInherit": "macos.entitlements", + // Squirrel.Mac can only install from a ZIP, so auto-update needs one beside each DMG. + // It is NOT listed here on purpose: the macOS job packs with `electron-builder --mac --dir`, + // and `--dir` skips every target — so adding "zip" (or anything else) to this list would be + // dead config that reads as if it worked. The DMG below is likewise hand-rolled with + // `hdiutil` in build.yml, which is why the release assets are named `-macOS-Apple-Silicon-` + // rather than following `artifactName`. The update ZIP is produced with `ditto` in the same + // job, from the signed and stapled .app. Same trap as `linux.target` below, where the CLI + // target list replaces this one. "target": [ { "target": "dmg", diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts new file mode 100644 index 000000000..276155ef6 --- /dev/null +++ b/electron/auto-updater.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { blockedFromInstalling, type InstallReadiness } from "./auto-updater"; + +function state(overrides: Partial = {}): InstallReadiness { + return { recording: false, inApplicationsFolder: true, platform: "linux", ...overrides }; +} + +describe("blockedFromInstalling", () => { + it("allows an install when nothing is in the way", () => { + for (const platform of ["win32", "darwin", "linux"] as const) { + expect(blockedFromInstalling(state({ platform }))).toBeNull(); + } + }); + + // Quitting mid-recording loses the take. On Windows it is worse than losing it: the capture + // helpers spawn from inside the install directory, and NSIS cannot overwrite a running .exe. + it("refuses while a recording is in progress, on every platform", () => { + for (const platform of ["win32", "darwin", "linux"] as const) { + expect(blockedFromInstalling(state({ platform, recording: true }))).toBe("recording"); + } + }); + + // App Translocation: a quarantined app runs from a read-only image where Squirrel cannot + // replace the bundle. Pinned per-platform because CI is Linux-only and an unpinned branch + // would be green here and wrong on the one platform it applies to. + it("refuses a macOS install running outside /Applications", () => { + expect(blockedFromInstalling(state({ platform: "darwin", inApplicationsFolder: false }))).toBe( + "not-in-applications", + ); + }); + + it("does not apply the Applications-folder rule off macOS", () => { + for (const platform of ["win32", "linux"] as const) { + expect(blockedFromInstalling(state({ platform, inApplicationsFolder: false }))).toBeNull(); + } + }); + + it("reports the recording veto first when both apply", () => { + expect( + blockedFromInstalling( + state({ platform: "darwin", recording: true, inApplicationsFolder: false }), + ), + ).toBe("recording"); + }); +}); diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts new file mode 100644 index 000000000..9dbe17c89 --- /dev/null +++ b/electron/auto-updater.ts @@ -0,0 +1,92 @@ +// In-place updates for the artifacts we build and can replace: NSIS, the macOS .app, AppImage, +// deb, rpm and pacman. Everything else — Microsoft Store, Flathub, Snap, Nix — is filtered out +// before we get here by `platformOwnsUpdates`; see install-channel.ts for why that is a hard +// rule rather than a best effort. +// +// electron-updater replaces the WHOLE payload, not just the JS: the Swift/C++ capture helpers, +// the Rust compositor addon, the ffmpeg libraries and the whisper binaries all ship inside the +// bundle and come along. What does NOT come along is anything under userData — recordings, +// projects, and the ~500 MB STT model — which is exactly what we want. + +import { app } from "electron"; +import type { InstallChannel } from "./install-channel"; +import { ownsItsUpdates } from "./install-channel"; + +export type UpdateOutcome = + | { kind: "unsupported" } + | { kind: "current" } + | { kind: "downloaded"; version: string } + | { kind: "failed"; error: Error }; + +/** Reasons an install must not start right now. Kept separate from "can this channel update at + * all" because these are transient — the answer changes as the user works. */ +export interface InstallReadiness { + recording: boolean; + /** macOS only. Gatekeeper runs a quarantined app from a read-only image (App Translocation), + * where Squirrel cannot replace the bundle; worse, an app can BECOME translocated after an + * update and then never update again. */ + inApplicationsFolder: boolean; + platform: NodeJS.Platform; +} + +/** Pure so the veto can be tested without an updater, a Mac, or a recording. */ +export function blockedFromInstalling(state: InstallReadiness): string | null { + if (state.recording) return "recording"; + if (state.platform === "darwin" && !state.inApplicationsFolder) return "not-in-applications"; + return null; +} + +let configured = false; + +/** Loaded lazily: importing electron-updater costs startup time on every launch, and the + * channels that cannot use it must not pay for it at all. */ +async function getUpdater() { + const { autoUpdater } = await import("electron-updater"); + if (!configured) { + // Never surprise the user with a 240 MB download or an install they did not ask for. + // `window-all-closed` quits this app, and the HUD is a window — with the default + // `autoInstallOnAppQuit` simply closing the HUD would kick off an installer. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.logger = null; + configured = true; + } + return autoUpdater; +} + +/** Is an update available, and can this install apply it itself? */ +export async function checkForSelfUpdate(channel: InstallChannel): Promise { + if (!ownsItsUpdates(channel) || !app.isPackaged) return { kind: "unsupported" }; + try { + const autoUpdater = await getUpdater(); + const result = await autoUpdater.checkForUpdates(); + // null when no feed resolved; equal versions come back with no downloadPromise. + const version = result?.updateInfo?.version; + if (!version || version === app.getVersion()) return { kind: "current" }; + return { kind: "downloaded", version }; + } catch (error) { + // A missing or malformed feed is the expected failure on any release published before + // this shipped. The caller falls back to opening the release page, which still works. + return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +/** Download the pending update. Separate from the check so the user approves the transfer. */ +export async function downloadSelfUpdate(): Promise { + try { + const autoUpdater = await getUpdater(); + await autoUpdater.downloadUpdate(); + return { kind: "downloaded", version: app.getVersion() }; + } catch (error) { + return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +/** Quit and hand over to the installer. Callers MUST have checked `blockedFromInstalling`. */ +export async function installSelfUpdate(): Promise { + const autoUpdater = await getUpdater(); + // isSilent=false so a per-machine Windows install can show its elevation prompt: a silent + // upgrade of a Program Files install hits UAC and, if the user dismisses it, quits having + // done nothing. isForceRunAfter=true so the app comes back up. + autoUpdater.quitAndInstall(false, true); +} diff --git a/electron/install-channel.test.ts b/electron/install-channel.test.ts new file mode 100644 index 000000000..b34861f7a --- /dev/null +++ b/electron/install-channel.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + classifyInstall, + type InstallProbe, + ownsItsUpdates, + platformOwnsUpdates, +} from "./install-channel"; + +/** A packaged Linux install with no channel markers at all. Every case overrides only the + * fields it is about, so a test never accidentally depends on the host it runs on. */ +function probe(overrides: Partial = {}): InstallProbe { + return { + platform: "linux", + isPackaged: true, + execPath: "/opt/Openscreen/openscreen", + windowsStore: false, + env: {}, + hasFlatpakInfo: false, + packageType: null, + ...overrides, + }; +} + +describe("classifyInstall", () => { + it("reports dev for an unpacked build regardless of platform", () => { + for (const platform of ["win32", "darwin", "linux"] as const) { + expect(classifyInstall(probe({ platform, isPackaged: false }))).toBe("dev"); + } + }); + + it("classifies the installers we build and own", () => { + expect(classifyInstall(probe({ platform: "win32" }))).toBe("nsis"); + expect(classifyInstall(probe({ platform: "darwin" }))).toBe("dmg"); + expect(classifyInstall(probe({ env: { APPIMAGE: "/home/u/Apps/Openscreen.AppImage" } }))).toBe( + "appimage", + ); + for (const packageType of ["deb", "rpm", "pacman"] as const) { + expect(classifyInstall(probe({ packageType }))).toBe(packageType); + } + }); + + it("classifies the channels a package manager owns", () => { + expect(classifyInstall(probe({ platform: "win32", windowsStore: true }))).toBe("store"); + expect(classifyInstall(probe({ env: { FLATPAK_ID: "com.getopenscreen.OpenScreen" } }))).toBe( + "flatpak", + ); + expect(classifyInstall(probe({ hasFlatpakInfo: true }))).toBe("flatpak"); + expect( + classifyInstall(probe({ env: { SNAP: "/snap/openscreen/42", SNAP_REVISION: "42" } })), + ).toBe("snap"); + expect(classifyInstall(probe({ execPath: "/nix/store/abc-openscreen/bin/openscreen" }))).toBe( + "nix", + ); + }); + + // The ordering cases. Each of these markers coexists with a self-owned one, and getting the + // order wrong is what produces a second parallel installation. + it("prefers the platform owner when both kinds of marker are present", () => { + expect(classifyInstall(probe({ env: { FLATPAK_ID: "x" }, packageType: "deb" }))).toBe( + "flatpak", + ); + expect( + classifyInstall(probe({ env: { SNAP: "/snap/x", SNAP_REVISION: "1" }, packageType: "deb" })), + ).toBe("snap"); + expect( + classifyInstall( + probe({ execPath: "/nix/store/x/bin/openscreen", env: { APPIMAGE: "/x.AppImage" } }), + ), + ).toBe("nix"); + // The Microsoft Store build is still a win32 packaged app; without the check it is "nsis". + expect(classifyInstall(probe({ platform: "win32", windowsStore: true }))).toBe("store"); + }); + + it("does not mistake a stray SNAP variable for a snap install", () => { + expect(classifyInstall(probe({ env: { SNAP: "/snap/something" } }))).toBe("unknown"); + }); + + it("returns unknown rather than guessing when a Linux build carries no marker", () => { + // package-type is only written when a publish config resolves. Guessing "appimage" here + // would hand a .deb to the AppImage updater, which fails on the missing APPIMAGE var. + expect(classifyInstall(probe())).toBe("unknown"); + expect(classifyInstall(probe({ packageType: "tar.gz" }))).toBe("unknown"); + }); +}); + +describe("ownsItsUpdates", () => { + it("allows self-update only for the artifacts we build and can replace", () => { + for (const channel of ["nsis", "dmg", "appimage", "deb", "rpm", "pacman"] as const) { + expect(ownsItsUpdates(channel)).toBe(true); + } + }); + + it("refuses self-update wherever a package manager owns it", () => { + for (const channel of ["store", "flatpak", "snap", "nix", "dev", "unknown"] as const) { + expect(ownsItsUpdates(channel)).toBe(false); + } + }); +}); + +describe("platformOwnsUpdates", () => { + it("is true exactly where a package manager keeps the app current", () => { + for (const channel of ["store", "flatpak", "snap", "nix"] as const) { + expect(platformOwnsUpdates(channel)).toBe(true); + } + }); + + // The distinction that matters: these cannot self-update either, but they should still be + // able to point the user at the release page. Collapsing the two predicates into one would + // silently remove the only update affordance a dev or unclassified build has. + it("is false for builds that merely cannot self-update", () => { + for (const channel of ["dev", "unknown"] as const) { + expect(ownsItsUpdates(channel)).toBe(false); + expect(platformOwnsUpdates(channel)).toBe(false); + } + }); + + it("never claims both ownerships for the same channel", () => { + const all = [ + "nsis", + "dmg", + "appimage", + "deb", + "rpm", + "pacman", + "store", + "flatpak", + "snap", + "nix", + "dev", + "unknown", + ] as const; + for (const channel of all) { + expect(ownsItsUpdates(channel) && platformOwnsUpdates(channel)).toBe(false); + } + }); +}); diff --git a/electron/install-channel.ts b/electron/install-channel.ts new file mode 100644 index 000000000..2e007c588 --- /dev/null +++ b/electron/install-channel.ts @@ -0,0 +1,146 @@ +// How this copy of OpenScreen was installed, and therefore WHO is allowed to update it. +// +// On the Microsoft Store, Flathub, Snap and Nix the package manager already updates the app. +// A second updater there is not merely redundant: inside an MSIX container the install +// directory is read-only, and on the Store a "download the .exe" prompt walks the user into a +// SECOND, parallel installation that then drifts from the Store copy forever. So the rule is +// not "try and fail gracefully", it is "do not offer it at all". +// +// One definition, so "may we update ourselves?" can never be asked two different ways. + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { app } from "electron"; + +/** Where the running binary came from. */ +export type InstallChannel = + /** We own the update: an installer/bundle we built and can replace in place. */ + | "nsis" + | "dmg" + | "appimage" + | "deb" + | "rpm" + | "pacman" + /** A package manager owns the update; we must stay out of its way. */ + | "store" + | "flatpak" + | "snap" + | "nix" + /** Unpacked `npm run dev`, or a build we cannot classify. */ + | "dev" + | "unknown"; + +/** The facts the classification needs. Passed in rather than read from globals so the + * decision table can be tested on every platform from any platform — CI is Linux-only, + * and an unpinned `process.platform` branch is green there and wrong everywhere else. */ +export interface InstallProbe { + platform: NodeJS.Platform; + /** `false` for `npm run dev` — but NOT sufficient on its own: Flatpak and Snap are packaged. */ + isPackaged: boolean; + execPath: string; + /** `process.windowsStore` is `true` or **undefined**, never `false`. Normalise before passing. */ + windowsStore: boolean; + /** `FLATPAK_ID`, `SNAP`, `SNAP_REVISION`, `APPIMAGE`. Deliberately not `NodeJS.ProcessEnv`: + * this repo augments that type with required app variables, which a test fixture has no + * business supplying just to name one marker. */ + env: Readonly>; + /** `/.flatpak-info` exists. Checked as well as `FLATPAK_ID`, which is inherited by child + * processes and so can be present outside the sandbox; the file only exists inside it. */ + hasFlatpakInfo: boolean; + /** Contents of `/package-type`, or null. electron-builder writes this for + * deb/rpm/pacman only, and only when a publish config resolves — see `probeInstall`. */ + packageType: string | null; +} + +const SELF_UPDATING: ReadonlySet = new Set([ + "nsis", + "dmg", + "appimage", + "deb", + "rpm", + "pacman", +]); + +const PLATFORM_OWNED: ReadonlySet = new Set([ + "store", + "flatpak", + "snap", + "nix", +]); + +/** Pure decision table. Order matters: the platform-owned markers are checked FIRST because + * they coexist with the self-owned ones — a Flatpak build still carries a `package-type` + * file, and a Snap still looks like a plain Linux install from the inside. */ +export function classifyInstall(probe: InstallProbe): InstallChannel { + if (!probe.isPackaged) return "dev"; + + // --- platform-owned --- + if (probe.windowsStore) return "store"; + if (probe.env.FLATPAK_ID || probe.hasFlatpakInfo) return "flatpak"; + // Two markers, not one: a bare `SNAP` is a plausible collision with an unrelated variable. + if (probe.env.SNAP && probe.env.SNAP_REVISION) return "snap"; + if (probe.execPath.startsWith("/nix/store/")) return "nix"; + + // --- self-owned --- + // electron-updater reads this same variable; if it is missing it refuses to update an + // AppImage, so treating it as the marker keeps us consistent with the thing doing the work. + if (probe.env.APPIMAGE) return "appimage"; + if ( + probe.packageType === "deb" || + probe.packageType === "rpm" || + probe.packageType === "pacman" + ) { + return probe.packageType; + } + if (probe.platform === "win32") return "nsis"; + if (probe.platform === "darwin") return "dmg"; + + // A packaged Linux build with no APPIMAGE and no package-type. Most likely `package-type` + // went missing (it is only written when a publish config resolves), and guessing wrong + // means handing a .deb to the AppImage updater, which then fails on the absent env var. + return "unknown"; +} + +/** May this copy download and install a new version over itself? */ +export function ownsItsUpdates(channel: InstallChannel): boolean { + return SELF_UPDATING.has(channel); +} + +/** Does a package manager already keep this copy up to date? When true the app must show no + * update affordance at all — not a disabled one, not a "go download it" link. Distinct from + * `!ownsItsUpdates`: a `dev` or `unknown` build cannot self-update either, but pointing its + * user at the release page is still useful, whereas doing so on the Store walks them into a + * second parallel install. */ +export function platformOwnsUpdates(channel: InstallChannel): boolean { + return PLATFORM_OWNED.has(channel); +} + +/** Read the real environment. `process.windowsStore` is typed as an optional `true`, so it is + * normalised to a boolean here rather than at each call site. */ +export function probeInstall(): InstallProbe { + return { + platform: process.platform, + isPackaged: app.isPackaged, + execPath: process.execPath, + windowsStore: process.windowsStore === true, + env: process.env, + hasFlatpakInfo: process.platform === "linux" && existsSync("/.flatpak-info"), + packageType: readPackageType(), + }; +} + +function readPackageType(): string | null { + try { + return readFileSync(path.join(process.resourcesPath, "package-type"), "utf8").trim(); + } catch { + // Absent on every platform except a deb/rpm/pacman install. Not an error. + return null; + } +} + +// ponytail: a distro that REPACKAGES our .pacman (the AUR `openscreen` package does exactly +// this) inherits our `package-type` marker, so we would classify it as a pacman install we own +// and try to `pacman -U` a file the AUR helper knows nothing about. Distinguishing them needs +// `pacman -Qoq $(readlink -f /proc/self/exe)` and a package-name comparison — a subprocess on +// every launch. Not worth it while that package is stuck at 1.7.0 and uninstallable; revisit +// if AUR is ever revived (see the AUR publishing issue). diff --git a/electron/main.ts b/electron/main.ts index f719ed4dd..f51bcafa5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -15,6 +15,12 @@ import { Tray, } from "electron"; import { ShortcutBinding } from "../src/lib/shortcuts"; +import { + blockedFromInstalling, + checkForSelfUpdate, + downloadSelfUpdate, + installSelfUpdate, +} from "./auto-updater"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer"; @@ -24,6 +30,12 @@ import { unregisterAllGlobalShortcuts, } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; +import { + classifyInstall, + type InstallChannel, + platformOwnsUpdates, + probeInstall, +} from "./install-channel"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; import { installMainProcessErrorGuards } from "./main-process-errors"; import { registerSttIpc } from "./stt"; @@ -345,15 +357,76 @@ function getTrayIcon(filename: string, size: number) { } let updateCheckInFlight = false; +let installChannel: InstallChannel | null = null; +/** Aborted on quit so a pending check cannot outlive the app and pop a dialog on the way out — + * or reject into `main-process-errors`, which re-throws and would take the process with it. */ +let updateCheckAbort: AbortController | null = null; + +function getInstallChannel(): InstallChannel { + if (installChannel === null) installChannel = classifyInstall(probeInstall()); + return installChannel; +} + +/** Mirrors the flag that already drives the tray icon. An update must never interrupt a take — + * and on Windows it physically cannot, because the capture helpers spawn from inside the + * install directory and NSIS cannot overwrite a running .exe. */ +let isRecording = false; + +async function downloadAndInstall(latestVersion: string) { + const downloaded = await downloadSelfUpdate(); + if (downloaded.kind === "failed") { + await dialog.showMessageBox({ + type: "error", + title: app.name, + message: mainT("common", "updates.failed"), + detail: downloaded.error.message, + }); + return; + } + + const blocked = blockedFromInstalling({ + recording: isRecording, + // macOS-only API; absent elsewhere, and irrelevant there. + inApplicationsFolder: + process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, + platform: process.platform, + }); + if (blocked) { + await dialog.showMessageBox({ + type: "info", + title: app.name, + message: mainT( + "common", + blocked === "recording" ? "updates.blockedRecording" : "updates.blockedLocation", + ), + }); + return; + } + + const restart = await dialog.showMessageBox({ + type: "info", + title: app.name, + message: mainT("common", "updates.readyToInstall", { latestVersion }), + buttons: [ + mainT("common", "actions.restartNow") || "Restart Now", + mainT("common", "actions.cancel") || "Cancel", + ], + defaultId: 0, + cancelId: 1, + }); + if (restart.response === 0) await installSelfUpdate(); +} async function checkForUpdates() { if (updateCheckInFlight) return; updateCheckInFlight = true; + updateCheckAbort = new AbortController(); + const signal = AbortSignal.any([updateCheckAbort.signal, AbortSignal.timeout(10_000)]); try { const result = await checkLatestRelease({ currentVersion: app.getVersion(), fetchLatest: (url, init) => net.fetch(url, init), - signal: AbortSignal.timeout(10_000), + signal, }); if (result.kind === "current") { await dialog.showMessageBox({ @@ -366,6 +439,21 @@ async function checkForUpdates() { return; } + // An install we built can replace itself; everything else — dev builds, an unclassified + // payload, and every macOS install predating Developer ID signing, which Squirrel can + // never update — can only be pointed at the download page. Ask the updater first so the + // buttons offered match what this install can actually do. + const selfUpdate = await checkForSelfUpdate(getInstallChannel()); + const canSelfUpdate = selfUpdate.kind === "downloaded"; + if (selfUpdate.kind === "failed") { + // A release published before the update feeds existed has no latest*.yml. Not worth a + // dialog — the download page below still works — but it must not vanish silently. + console.warn("[updates] self-update unavailable, falling back to the release page", { + channel: getInstallChannel(), + error: selfUpdate.error.message, + }); + } + const choice = await dialog.showMessageBox({ type: "info", title: app.name, @@ -374,14 +462,24 @@ async function checkForUpdates() { latestVersion: result.latestVersion, }), buttons: [ - mainT("common", "actions.viewRelease") || "View Release", + canSelfUpdate + ? mainT("common", "actions.downloadUpdate") || "Download Update" + : mainT("common", "actions.viewRelease") || "View Release", mainT("common", "actions.cancel") || "Cancel", ], defaultId: 0, cancelId: 1, }); - if (choice.response === 0) await shell.openExternal(result.releaseUrl); + if (choice.response !== 0) return; + if (!canSelfUpdate) { + await shell.openExternal(result.releaseUrl); + return; + } + await downloadAndInstall(result.latestVersion); } catch (error) { + // Quitting is not a failure, and the app is already on its way out — there is nothing + // left to show the dialog on. + if (signal.aborted && updateCheckAbort?.signal.aborted) return; await dialog.showMessageBox({ type: "error", title: app.name, @@ -390,6 +488,7 @@ async function checkForUpdates() { }); } finally { updateCheckInFlight = false; + updateCheckAbort = null; } } @@ -419,12 +518,23 @@ function updateTrayMenu(recording: boolean = false) { showMainWindow(); }, }, - { - label: mainT("common", "actions.checkForUpdates") || "Check for Updates", - click: () => { - void checkForUpdates(); - }, - }, + // Omitted entirely where a package manager owns the update (Microsoft Store, + // Flathub, Snap, Nix): there the app is already kept current, and offering a + // GitHub download walks the user into a second, parallel installation. + ...(platformOwnsUpdates(getInstallChannel()) + ? [] + : [ + { + label: mainT("common", "actions.checkForUpdates") || "Check for Updates", + click: () => { + // Not `void`: an unhandled rejection here is re-thrown by + // main-process-errors and would kill the main process. + checkForUpdates().catch((error) => { + console.error("[updates] check failed", error); + }); + }, + }, + ]), { type: "separator" as const }, { label: mainT("common", "actions.quit") || "Quit", @@ -572,6 +682,12 @@ app.on("activate", () => { } }); +app.on("before-quit", () => { + // A check started seconds ago must not settle after the app is gone and try to open a + // dialog on a quitting app. + updateCheckAbort?.abort(); +}); + app.on("will-quit", () => { unregisterAllGlobalShortcuts(); }); @@ -717,6 +833,7 @@ appReady?.then(async () => { () => countdownOverlayWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; + isRecording = recording; if (!tray) createTray(); updateTrayMenu(recording); if (!recording) { diff --git a/package-lock.json b/package-lock.json index 03324d6bc..16fc629b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "electron-updater": "^6.8.9", "i18next": "^23.16.0", "langchain": "^1.2.39", "lucide-react": "^0.545.0", @@ -5256,7 +5257,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5578,7 +5578,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -6100,7 +6099,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6574,6 +6572,69 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -7409,7 +7470,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -7803,7 +7863,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, "funding": [ { "type": "github", @@ -8016,7 +8075,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lilconfig": { @@ -8189,6 +8247,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -8585,7 +8656,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mustache": { @@ -10196,7 +10266,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -10793,6 +10862,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 76f8c9621..7a22a8d3c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "name": "Etienne Lescot", "url": "https://github.com/EtienneLescot" }, + "repository": { + "type": "git", + "url": "git+https://github.com/getopenscreen/openscreen.git" + }, "contributors": [ { "name": "Siddharth Vaddem", @@ -105,6 +109,7 @@ "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "electron-updater": "^6.8.9", "i18next": "^23.16.0", "langchain": "^1.2.39", "lucide-react": "^0.545.0", diff --git a/scripts/mac-update-feed.mjs b/scripts/mac-update-feed.mjs new file mode 100644 index 000000000..042160b14 --- /dev/null +++ b/scripts/mac-update-feed.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Builds `latest-mac.yml`, the feed Squirrel.Mac reads through electron-updater. +// +// Two subcommands because the two arches are built on DIFFERENT runners (macos-15-intel and +// macos-latest — see the matrix in build.yml, which pins them on purpose so ffmpeg's configure, +// cargo and the Swift helper all key off the host arch). electron-builder would write a +// `latest-mac.yml` per job and the second upload would overwrite the first, leaving one +// architecture served the wrong build — electron-builder#5592, closed as not-planned. So each +// job emits a JSON sidecar and `merge` folds both into ONE feed with two `files:` entries. +// +// describe — per-arch, on the macOS runner +// merge — once, on the publish runner +// +// JSON in between rather than parsing YAML back: nothing here needs a YAML parser, and the +// repo has no dependency that provides one outside electron-builder's own tree. + +import { createHash } from "node:crypto"; +import { readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** electron-updater compares this against the digest it computes after downloading. It is + * base64, NOT hex — a hex digest parses fine and then fails every update with a checksum + * mismatch, which is exactly the kind of silent breakage this file exists to avoid. */ +export function sha512Base64(file) { + return createHash("sha512").update(readFileSync(file)).digest("base64"); +} + +/** `filterFilesForArch` in MacUpdater.ts selects by the literal substring "arm64" in the file + * name. Our DMGs are deliberately named `Apple-Silicon`/`Intel` because that is what About + * This Mac shows the user — but naming the ZIP that way would make every Apple Silicon client + * silently take the Intel build and run it under Rosetta, with no error anywhere. */ +export function archOf(url) { + if (/apple-silicon|intel/i.test(url)) { + throw new Error( + `update ZIP "${url}" must identify its architecture as "arm64"/"x64", not a marketing ` + + 'name: electron-updater matches the literal substring "arm64" and would serve an ' + + "Apple-Silicon-named build to Intel clients", + ); + } + return /arm64/.test(url) ? "arm64" : "x64"; +} + +/** Pure: entries in, YAML out. The ordering and fallback rules live here so they are testable + * without a macOS runner — nothing else in this repo can exercise them. */ +export function buildFeedYml(entries, now = new Date()) { + if (entries.length !== 2) { + throw new Error(`expected exactly 2 architecture sidecars, got ${entries.length}`); + } + const versions = new Set(entries.map((entry) => entry.version)); + if (versions.size !== 1) { + throw new Error(`architecture sidecars disagree on version: ${[...versions].join(", ")}`); + } + const arm64 = entries.filter((entry) => archOf(entry.url) === "arm64"); + if (arm64.length !== 1) { + throw new Error(`expected exactly one arm64 and one x64 sidecar, got ${arm64.length} arm64`); + } + + // `path`/`sha512` at the top level are what pre-arch-aware clients read. Point them at the + // x64 entry: an Apple Silicon Mac runs an Intel build under Rosetta, whereas an Intel Mac + // handed an arm64 build cannot run it at all. Degrade to slow, never to broken. + const fallback = entries.find((entry) => archOf(entry.url) === "x64"); + // Sorted so the feed is byte-identical whichever arch job finishes first. electron-updater + // selects by filtering, never by position, so this costs nothing and makes the published + // artifact diffable between releases. + const ordered = [...entries].sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0)); + return `${[ + `version: ${entries[0].version}`, + "files:", + ...ordered.flatMap((entry) => [ + ` - url: ${entry.url}`, + ` sha512: ${entry.sha512}`, + ` size: ${entry.size}`, + ]), + `path: ${fallback.url}`, + `sha512: ${fallback.sha512}`, + `releaseDate: '${now.toISOString()}'`, + ].join("\n")}\n`; +} + +function describe(zipPath, version, outPath) { + const url = path.basename(zipPath); + const info = { version, url, sha512: sha512Base64(zipPath), size: statSync(zipPath).size }; + archOf(url); // throws on a marketing-named ZIP before it can reach a release + writeFileSync(outPath, `${JSON.stringify(info, null, 2)}\n`); + console.log(`${outPath}: ${url} (${info.size} bytes, ${archOf(url)})`); +} + +function merge(inputs, outPath) { + const yml = buildFeedYml(inputs.map((file) => JSON.parse(readFileSync(file, "utf8")))); + writeFileSync(outPath, yml); + console.log(yml); +} + +// Only when run as a CLI — importing this from a test must not dispatch. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, ...args] = process.argv.slice(2); + if (command === "describe") describe(args[0], args[1], args[2]); + else if (command === "merge") merge(args.slice(0, -1), args.at(-1)); + else { + console.error("usage: mac-update-feed.mjs describe "); + console.error(" mac-update-feed.mjs merge "); + process.exit(2); + } +} diff --git a/scripts/mac-update-feed.test.mjs b/scripts/mac-update-feed.test.mjs new file mode 100644 index 000000000..36c761586 --- /dev/null +++ b/scripts/mac-update-feed.test.mjs @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { archOf, buildFeedYml } from "./mac-update-feed.mjs"; + +const ARM = { + version: "1.9.3", + url: "Openscreen-Mac-arm64-1.9.3.zip", + sha512: "ARMDIGEST==", + size: 111, +}; +const X64 = { + version: "1.9.3", + url: "Openscreen-Mac-x64-1.9.3.zip", + sha512: "X64DIGEST==", + size: 222, +}; +const NOW = new Date("2026-08-13T09:00:00.000Z"); + +describe("archOf", () => { + it("reads the substring electron-updater actually matches on", () => { + expect(archOf("Openscreen-Mac-arm64-1.9.3.zip")).toBe("arm64"); + expect(archOf("Openscreen-Mac-x64-1.9.3.zip")).toBe("x64"); + }); + + // The whole reason this helper exists. Our DMGs are named for the user; the ZIPs must be + // named for the updater, and the two conventions collide. + it("refuses a marketing-named ZIP instead of silently calling it x64", () => { + expect(() => archOf("Openscreen-macOS-Apple-Silicon-1.9.3.zip")).toThrow(/arm64/); + expect(() => archOf("Openscreen-macOS-Intel-1.9.3.zip")).toThrow(/marketing/); + }); +}); + +describe("buildFeedYml", () => { + it("emits one feed listing both architectures", () => { + const yml = buildFeedYml([ARM, X64], NOW); + expect(yml).toContain("version: 1.9.3"); + expect(yml).toContain(" - url: Openscreen-Mac-arm64-1.9.3.zip"); + expect(yml).toContain(" - url: Openscreen-Mac-x64-1.9.3.zip"); + expect(yml).toContain("releaseDate: '2026-08-13T09:00:00.000Z'"); + // Two entries, not one — the failure mode of electron-builder#5592 is a feed that + // silently lists a single arch after the second job overwrites the first. + expect(yml.match(/^ {2}- url:/gm)).toHaveLength(2); + }); + + it("points the arch-blind fallback at x64 so it degrades to Rosetta, never to broken", () => { + const yml = buildFeedYml([ARM, X64], NOW); + expect(yml).toMatch(/^path: Openscreen-Mac-x64-1\.9\.3\.zip$/m); + expect(yml).toMatch(/^sha512: X64DIGEST==$/m); + }); + + it("is order-independent", () => { + expect(buildFeedYml([X64, ARM], NOW)).toBe(buildFeedYml([ARM, X64], NOW)); + }); + + it("refuses a half-built feed rather than publishing one arch", () => { + expect(() => buildFeedYml([ARM], NOW)).toThrow(/exactly 2/); + expect(() => buildFeedYml([ARM, X64, X64], NOW)).toThrow(/exactly 2/); + expect(() => buildFeedYml([ARM, ARM], NOW)).toThrow(/one arm64/); + }); + + it("refuses to mix versions, which is what a stale artifact looks like", () => { + expect(() => buildFeedYml([ARM, { ...X64, version: "1.9.2" }], NOW)).toThrow(/disagree/); + }); +}); diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index baf728d84..4790f15c6 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -9,6 +9,8 @@ "open": "فتح", "checkForUpdates": "التحقق من وجود تحديثات", "viewRelease": "عرض الإصدار", + "downloadUpdate": "تنزيل التحديث", + "restartNow": "إعادة التشغيل الآن", "upload": "رفع", "export": "تصدير", "showInFolder": "عرض في المجلد", @@ -42,6 +44,9 @@ "updates": { "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", "current": "OpenScreen محدّث ({{currentVersion}}).", + "readyToInstall": "تم تنزيل OpenScreen {{latestVersion}}. أعد التشغيل لإكمال التثبيت.", + "blockedRecording": "أنهِ التسجيل قبل تثبيت التحديث.", + "blockedLocation": "انقل OpenScreen إلى مجلد التطبيقات قبل التحديث.", "failed": "تعذّر التحقق من وجود تحديثات." }, "playback": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 23fec2451..7bb798c3d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -9,6 +9,8 @@ "open": "Open", "checkForUpdates": "Check for Updates", "viewRelease": "View Release", + "downloadUpdate": "Download Update", + "restartNow": "Restart Now", "upload": "Upload", "export": "Export", "showInFolder": "Show in Folder", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} is available. You are using {{currentVersion}}.", "current": "OpenScreen is up to date ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} has been downloaded. Restart to finish installing.", + "blockedRecording": "Finish your recording before installing the update.", + "blockedLocation": "Move OpenScreen to your Applications folder before updating.", "failed": "Could not check for updates." }, "playback": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 6486fd545..17dec8e2f 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -9,6 +9,8 @@ "open": "Abrir", "checkForUpdates": "Buscar actualizaciones", "viewRelease": "Ver versión", + "downloadUpdate": "Descargar actualización", + "restartNow": "Reiniciar ahora", "upload": "Subir", "export": "Exportar", "showInFolder": "Mostrar en carpeta", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} está disponible. Estás usando {{currentVersion}}.", "current": "OpenScreen está actualizado ({{currentVersion}}).", + "readyToInstall": "Se ha descargado OpenScreen {{latestVersion}}. Reinicia para completar la instalación.", + "blockedRecording": "Termina la grabación antes de instalar la actualización.", + "blockedLocation": "Mueve OpenScreen a la carpeta Aplicaciones antes de actualizar.", "failed": "No se pudieron buscar actualizaciones." }, "playback": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 130048349..32dce1fe1 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -9,6 +9,8 @@ "open": "Ouvrir", "checkForUpdates": "Rechercher des mises à jour", "viewRelease": "Voir la version", + "downloadUpdate": "Télécharger la mise à jour", + "restartNow": "Redémarrer maintenant", "upload": "Téléverser", "export": "Exporter", "showInFolder": "Afficher dans le dossier", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} est disponible. Vous utilisez la version {{currentVersion}}.", "current": "OpenScreen est à jour ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} a été téléchargé. Redémarrez pour terminer l'installation.", + "blockedRecording": "Terminez votre enregistrement avant d'installer la mise à jour.", + "blockedLocation": "Déplacez OpenScreen dans le dossier Applications avant de le mettre à jour.", "failed": "Impossible de rechercher les mises à jour." }, "playback": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 7265a1dbe..d1dce3518 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -9,6 +9,8 @@ "open": "Apri", "checkForUpdates": "Controlla aggiornamenti", "viewRelease": "Visualizza versione", + "downloadUpdate": "Scarica aggiornamento", + "restartNow": "Riavvia ora", "upload": "Carica", "export": "Esporta", "showInFolder": "Mostra nella cartella", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} è disponibile. Stai usando la versione {{currentVersion}}.", "current": "OpenScreen è aggiornato ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} è stato scaricato. Riavvia per completare l'installazione.", + "blockedRecording": "Termina la registrazione prima di installare l'aggiornamento.", + "blockedLocation": "Sposta OpenScreen nella cartella Applicazioni prima di aggiornare.", "failed": "Impossibile controllare gli aggiornamenti." }, "playback": { diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index 66d23371b..ddbf3a6a4 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -9,6 +9,8 @@ "open": "開く", "checkForUpdates": "アップデートを確認", "viewRelease": "リリースを表示", + "downloadUpdate": "アップデートをダウンロード", + "restartNow": "今すぐ再起動", "upload": "読み込む", "export": "エクスポート", "showInFolder": "フォルダに表示", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} を利用できます。現在のバージョンは {{currentVersion}} です。", "current": "OpenScreen は最新です({{currentVersion}})。", + "readyToInstall": "OpenScreen {{latestVersion}} をダウンロードしました。再起動してインストールを完了してください。", + "blockedRecording": "アップデートをインストールする前に録画を終了してください。", + "blockedLocation": "アップデートする前に OpenScreen をアプリケーションフォルダに移動してください。", "failed": "アップデートを確認できませんでした。" }, "playback": { diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index a3e409bf3..6d8e18b58 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -9,6 +9,8 @@ "open": "열기", "checkForUpdates": "업데이트 확인", "viewRelease": "릴리스 보기", + "downloadUpdate": "업데이트 다운로드", + "restartNow": "지금 다시 시작", "upload": "업로드", "export": "내보내기", "showInFolder": "폴더에 표시", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} 버전을 사용할 수 있습니다. 현재 버전은 {{currentVersion}}입니다.", "current": "OpenScreen이 최신 버전입니다({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}}을(를) 다운로드했습니다. 다시 시작하여 설치를 완료하세요.", + "blockedRecording": "업데이트를 설치하기 전에 녹화를 마치세요.", + "blockedLocation": "업데이트하기 전에 OpenScreen을 응용 프로그램 폴더로 옮기세요.", "failed": "업데이트를 확인할 수 없습니다." }, "playback": { diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 950683e65..db985cb7b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -9,6 +9,8 @@ "open": "Abrir", "checkForUpdates": "Verificar atualizações", "viewRelease": "Ver versão", + "downloadUpdate": "Baixar atualização", + "restartNow": "Reiniciar agora", "upload": "Upload", "export": "Exportar", "showInFolder": "Mostrar na Pasta", @@ -42,6 +44,9 @@ "updates": { "available": "O OpenScreen {{latestVersion}} está disponível. Você está usando a versão {{currentVersion}}.", "current": "O OpenScreen está atualizado ({{currentVersion}}).", + "readyToInstall": "O OpenScreen {{latestVersion}} foi baixado. Reinicie para concluir a instalação.", + "blockedRecording": "Conclua a gravação antes de instalar a atualização.", + "blockedLocation": "Mova o OpenScreen para a pasta Aplicativos antes de atualizar.", "failed": "Não foi possível verificar atualizações." }, "playback": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6388998af..699a6c68f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -9,6 +9,8 @@ "open": "Открыть", "checkForUpdates": "Проверить обновления", "viewRelease": "Открыть выпуск", + "downloadUpdate": "Загрузить обновление", + "restartNow": "Перезапустить", "upload": "Загрузить", "export": "Экспорт", "showInFolder": "Показать в папке", @@ -42,6 +44,9 @@ "updates": { "available": "Доступен OpenScreen {{latestVersion}}. Установлена версия {{currentVersion}}.", "current": "Установлена последняя версия OpenScreen ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} загружен. Перезапустите, чтобы завершить установку.", + "blockedRecording": "Завершите запись перед установкой обновления.", + "blockedLocation": "Переместите OpenScreen в папку «Программы» перед обновлением.", "failed": "Не удалось проверить наличие обновлений." }, "playback": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index a489bbdda..23c790d18 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -9,6 +9,8 @@ "open": "Aç", "checkForUpdates": "Güncellemeleri denetle", "viewRelease": "Sürümü görüntüle", + "downloadUpdate": "Güncellemeyi indir", + "restartNow": "Şimdi yeniden başlat", "upload": "Yükle", "export": "Dışa Aktar", "showInFolder": "Klasörde Göster", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} kullanılabilir. Mevcut sürümünüz {{currentVersion}}.", "current": "OpenScreen güncel ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} indirildi. Kurulumu tamamlamak için yeniden başlatın.", + "blockedRecording": "Güncellemeyi yüklemeden önce kaydınızı tamamlayın.", + "blockedLocation": "Güncellemeden önce OpenScreen'i Uygulamalar klasörüne taşıyın.", "failed": "Güncellemeler denetlenemedi." }, "playback": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 3b0ad152b..7bfbe4b1a 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -9,6 +9,8 @@ "open": "Mở", "checkForUpdates": "Kiểm tra bản cập nhật", "viewRelease": "Xem bản phát hành", + "downloadUpdate": "Tải bản cập nhật", + "restartNow": "Khởi động lại ngay", "upload": "Tải lên", "export": "Xuất", "showInFolder": "Hiển thị trong thư mục", @@ -42,6 +44,9 @@ "updates": { "available": "Đã có OpenScreen {{latestVersion}}. Bạn đang dùng phiên bản {{currentVersion}}.", "current": "OpenScreen đang ở phiên bản mới nhất ({{currentVersion}}).", + "readyToInstall": "Đã tải xuống OpenScreen {{latestVersion}}. Khởi động lại để hoàn tất cài đặt.", + "blockedRecording": "Hãy kết thúc bản ghi trước khi cài đặt bản cập nhật.", + "blockedLocation": "Hãy chuyển OpenScreen vào thư mục Applications trước khi cập nhật.", "failed": "Không thể kiểm tra bản cập nhật." }, "playback": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 16d7d7df4..6408caf0e 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -9,6 +9,8 @@ "open": "打开", "checkForUpdates": "检查更新", "viewRelease": "查看版本", + "downloadUpdate": "下载更新", + "restartNow": "立即重启", "upload": "上传", "export": "导出", "showInFolder": "在文件夹中显示", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} 已发布。当前版本为 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", + "readyToInstall": "OpenScreen {{latestVersion}} 已下载完成。重启以完成安装。", + "blockedRecording": "请先结束录制,再安装更新。", + "blockedLocation": "更新前请将 OpenScreen 移动到「应用程序」文件夹。", "failed": "无法检查更新。" }, "playback": { diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 9644d8170..5c8c2f440 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -9,6 +9,8 @@ "open": "開啟", "checkForUpdates": "檢查更新", "viewRelease": "檢視版本", + "downloadUpdate": "下載更新", + "restartNow": "立即重新啟動", "upload": "上傳", "export": "匯出", "showInFolder": "在資料夾中顯示", @@ -42,6 +44,9 @@ "updates": { "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", + "readyToInstall": "OpenScreen {{latestVersion}} 已下載完成。請重新啟動以完成安裝。", + "blockedRecording": "請先結束錄影,再安裝更新。", + "blockedLocation": "更新前請將 OpenScreen 移至「應用程式」資料夾。", "failed": "無法檢查更新。" }, "playback": { From 84082208275a85d209040c2057a5236ebfc40c3e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 13 Aug 2026 19:02:33 +0200 Subject: [PATCH 2/4] fix(update): distinguish a failed download from a failed check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #358. A failed downloadSelfUpdate() showed `updates.failed` — "Could not check for updates" — but the check is how we got there. Adds `updates.downloadFailed` across all 13 locales. Also pins the three updater settings that are the difference between an update and a lost recording, and that all read as deletable boilerplate: autoDownload and autoInstallOnAppQuit are off, and quitAndInstall is called with isSilent=false so a per-machine Windows install can show its UAC prompt. The mock starts from electron-updater's defaults so the assertions prove our configuration ran rather than reading untouched values. --- electron/auto-updater.test.ts | 102 ++++++++++++++++++++++++++++- electron/main.ts | 4 +- src/i18n/locales/ar/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 3 +- src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja-JP/common.json | 3 +- src/i18n/locales/ko-KR/common.json | 3 +- src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-TW/common.json | 3 +- 15 files changed, 129 insertions(+), 16 deletions(-) diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 276155ef6..17958b54a 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -1,5 +1,28 @@ -import { describe, expect, it } from "vitest"; -import { blockedFromInstalling, type InstallReadiness } from "./auto-updater"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + blockedFromInstalling, + checkForSelfUpdate, + downloadSelfUpdate, + type InstallReadiness, + installSelfUpdate, +} from "./auto-updater"; + +const mocks = vi.hoisted(() => ({ + // Deliberately initialised to electron-updater's DEFAULTS, so a test asserting they are + // false proves our configuration ran rather than reading a value that was never touched. + autoUpdater: { + autoDownload: true, + autoInstallOnAppQuit: true, + logger: {} as unknown, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + }, + app: { isPackaged: true, getVersion: vi.fn(() => "1.9.2") }, +})); + +vi.mock("electron", () => ({ app: mocks.app })); +vi.mock("electron-updater", () => ({ autoUpdater: mocks.autoUpdater })); function state(overrides: Partial = {}): InstallReadiness { return { recording: false, inApplicationsFolder: true, platform: "linux", ...overrides }; @@ -43,3 +66,78 @@ describe("blockedFromInstalling", () => { ).toBe("recording"); }); }); + +describe("self-update flow", () => { + beforeEach(() => { + mocks.app.isPackaged = true; + mocks.autoUpdater.checkForUpdates.mockReset(); + mocks.autoUpdater.downloadUpdate.mockReset(); + mocks.autoUpdater.quitAndInstall.mockReset(); + }); + + it("never touches the updater on a channel a package manager owns", async () => { + for (const channel of ["store", "flatpak", "snap", "nix"] as const) { + await expect(checkForSelfUpdate(channel)).resolves.toEqual({ kind: "unsupported" }); + } + expect(mocks.autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + it("refuses to self-update an unpacked build", async () => { + mocks.app.isPackaged = false; + await expect(checkForSelfUpdate("nsis")).resolves.toEqual({ kind: "unsupported" }); + expect(mocks.autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + // These three settings are the difference between an update and a lost recording, and each + // looks like harmless boilerplate to delete. `window-all-closed` quits this app and the HUD + // is a window, so the stock autoInstallOnAppQuit would fire a ~243 MB installer when the + // user merely closed the HUD. + it("disables auto-download and install-on-quit before doing anything", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); + await checkForSelfUpdate("nsis"); + expect(mocks.autoUpdater.autoDownload).toBe(false); + expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(false); + }); + + it("reports current when the feed offers the running version", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); + await expect(checkForSelfUpdate("appimage")).resolves.toEqual({ kind: "current" }); + }); + + it("reports current when no feed resolves, rather than claiming an update", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue(null); + await expect(checkForSelfUpdate("deb")).resolves.toEqual({ kind: "current" }); + }); + + it("surfaces an available version", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.10.0" } }); + await expect(checkForSelfUpdate("dmg")).resolves.toEqual({ + kind: "downloaded", + version: "1.10.0", + }); + }); + + // A release published before the update feeds existed has no latest*.yml. That must degrade + // to the release-page fallback, not throw into main-process-errors, which re-throws. + it("reports a missing or broken feed as failed instead of throwing", async () => { + mocks.autoUpdater.checkForUpdates.mockRejectedValue(new Error("404 latest.yml")); + const result = await checkForSelfUpdate("nsis"); + expect(result.kind).toBe("failed"); + expect(result).toMatchObject({ error: { message: "404 latest.yml" } }); + }); + + it("reports a failed download instead of throwing", async () => { + mocks.autoUpdater.downloadUpdate.mockRejectedValue(new Error("connection reset")); + const result = await downloadSelfUpdate(); + expect(result.kind).toBe("failed"); + expect(result).toMatchObject({ error: { message: "connection reset" } }); + }); + + // isSilent=false so a per-machine Windows install can show its UAC prompt — a silent upgrade + // of a Program Files install hits elevation and, if dismissed, quits having done nothing. + // isForceRunAfter=true so the app comes back. + it("hands over to the installer non-silently and relaunches", async () => { + await installSelfUpdate(); + expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true); + }); +}); diff --git a/electron/main.ts b/electron/main.ts index f51bcafa5..60313e3ed 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -378,7 +378,9 @@ async function downloadAndInstall(latestVersion: string) { await dialog.showMessageBox({ type: "error", title: app.name, - message: mainT("common", "updates.failed"), + // Not `updates.failed`: the CHECK succeeded — that is how we got here — and telling + // the user we could not check for updates sends them looking in the wrong place. + message: mainT("common", "updates.downloadFailed"), detail: downloaded.error.message, }); return; diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 4790f15c6..29acacda1 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -47,7 +47,8 @@ "readyToInstall": "تم تنزيل OpenScreen {{latestVersion}}. أعد التشغيل لإكمال التثبيت.", "blockedRecording": "أنهِ التسجيل قبل تثبيت التحديث.", "blockedLocation": "انقل OpenScreen إلى مجلد التطبيقات قبل التحديث.", - "failed": "تعذّر التحقق من وجود تحديثات." + "failed": "تعذّر التحقق من وجود تحديثات.", + "downloadFailed": "تعذّر تنزيل التحديث." }, "playback": { "play": "تشغيل", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 7bb798c3d..f08159335 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} has been downloaded. Restart to finish installing.", "blockedRecording": "Finish your recording before installing the update.", "blockedLocation": "Move OpenScreen to your Applications folder before updating.", - "failed": "Could not check for updates." + "failed": "Could not check for updates.", + "downloadFailed": "Could not download the update." }, "playback": { "play": "Play", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 17dec8e2f..d65cac3aa 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -47,7 +47,8 @@ "readyToInstall": "Se ha descargado OpenScreen {{latestVersion}}. Reinicia para completar la instalación.", "blockedRecording": "Termina la grabación antes de instalar la actualización.", "blockedLocation": "Mueve OpenScreen a la carpeta Aplicaciones antes de actualizar.", - "failed": "No se pudieron buscar actualizaciones." + "failed": "No se pudieron buscar actualizaciones.", + "downloadFailed": "No se pudo descargar la actualización." }, "playback": { "play": "Reproducir", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 32dce1fe1..1041a771a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} a été téléchargé. Redémarrez pour terminer l'installation.", "blockedRecording": "Terminez votre enregistrement avant d'installer la mise à jour.", "blockedLocation": "Déplacez OpenScreen dans le dossier Applications avant de le mettre à jour.", - "failed": "Impossible de rechercher les mises à jour." + "failed": "Impossible de rechercher les mises à jour.", + "downloadFailed": "Impossible de télécharger la mise à jour." }, "playback": { "play": "Lecture", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index d1dce3518..ca715f234 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} è stato scaricato. Riavvia per completare l'installazione.", "blockedRecording": "Termina la registrazione prima di installare l'aggiornamento.", "blockedLocation": "Sposta OpenScreen nella cartella Applicazioni prima di aggiornare.", - "failed": "Impossibile controllare gli aggiornamenti." + "failed": "Impossibile controllare gli aggiornamenti.", + "downloadFailed": "Impossibile scaricare l'aggiornamento." }, "playback": { "play": "Riproduci", diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index ddbf3a6a4..c2dba1331 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} をダウンロードしました。再起動してインストールを完了してください。", "blockedRecording": "アップデートをインストールする前に録画を終了してください。", "blockedLocation": "アップデートする前に OpenScreen をアプリケーションフォルダに移動してください。", - "failed": "アップデートを確認できませんでした。" + "failed": "アップデートを確認できませんでした。", + "downloadFailed": "アップデートをダウンロードできませんでした。" }, "playback": { "play": "再生", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 6d8e18b58..2a9593155 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}}을(를) 다운로드했습니다. 다시 시작하여 설치를 완료하세요.", "blockedRecording": "업데이트를 설치하기 전에 녹화를 마치세요.", "blockedLocation": "업데이트하기 전에 OpenScreen을 응용 프로그램 폴더로 옮기세요.", - "failed": "업데이트를 확인할 수 없습니다." + "failed": "업데이트를 확인할 수 없습니다.", + "downloadFailed": "업데이트를 다운로드하지 못했습니다." }, "playback": { "play": "재생", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index db985cb7b..7cdad691c 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -47,7 +47,8 @@ "readyToInstall": "O OpenScreen {{latestVersion}} foi baixado. Reinicie para concluir a instalação.", "blockedRecording": "Conclua a gravação antes de instalar a atualização.", "blockedLocation": "Mova o OpenScreen para a pasta Aplicativos antes de atualizar.", - "failed": "Não foi possível verificar atualizações." + "failed": "Não foi possível verificar atualizações.", + "downloadFailed": "Não foi possível baixar a atualização." }, "playback": { "play": "Play", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 699a6c68f..4cfca236e 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} загружен. Перезапустите, чтобы завершить установку.", "blockedRecording": "Завершите запись перед установкой обновления.", "blockedLocation": "Переместите OpenScreen в папку «Программы» перед обновлением.", - "failed": "Не удалось проверить наличие обновлений." + "failed": "Не удалось проверить наличие обновлений.", + "downloadFailed": "Не удалось загрузить обновление." }, "playback": { "play": "Воспроизвести", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 23c790d18..c2ca46a0f 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} indirildi. Kurulumu tamamlamak için yeniden başlatın.", "blockedRecording": "Güncellemeyi yüklemeden önce kaydınızı tamamlayın.", "blockedLocation": "Güncellemeden önce OpenScreen'i Uygulamalar klasörüne taşıyın.", - "failed": "Güncellemeler denetlenemedi." + "failed": "Güncellemeler denetlenemedi.", + "downloadFailed": "Güncelleme indirilemedi." }, "playback": { "play": "Oynat", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 7bfbe4b1a..d5e2ed4c0 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -47,7 +47,8 @@ "readyToInstall": "Đã tải xuống OpenScreen {{latestVersion}}. Khởi động lại để hoàn tất cài đặt.", "blockedRecording": "Hãy kết thúc bản ghi trước khi cài đặt bản cập nhật.", "blockedLocation": "Hãy chuyển OpenScreen vào thư mục Applications trước khi cập nhật.", - "failed": "Không thể kiểm tra bản cập nhật." + "failed": "Không thể kiểm tra bản cập nhật.", + "downloadFailed": "Không thể tải xuống bản cập nhật." }, "playback": { "play": "Phát", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 6408caf0e..7607db7ab 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} 已下载完成。重启以完成安装。", "blockedRecording": "请先结束录制,再安装更新。", "blockedLocation": "更新前请将 OpenScreen 移动到「应用程序」文件夹。", - "failed": "无法检查更新。" + "failed": "无法检查更新。", + "downloadFailed": "无法下载更新。" }, "playback": { "play": "播放", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 5c8c2f440..186e68224 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -47,7 +47,8 @@ "readyToInstall": "OpenScreen {{latestVersion}} 已下載完成。請重新啟動以完成安裝。", "blockedRecording": "請先結束錄影,再安裝更新。", "blockedLocation": "更新前請將 OpenScreen 移至「應用程式」資料夾。", - "failed": "無法檢查更新。" + "failed": "無法檢查更新。", + "downloadFailed": "無法下載更新。" }, "playback": { "play": "播放", From e3f061b6d171d35f1b714193b3bf3775ebff8c85 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 11:55:24 +0200 Subject: [PATCH 3/4] fix(update): apply the updater settings on every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #358. The `configured` flag meant only the first caller set autoDownload/autoInstallOnAppQuit, so the test asserting they are false was passing on whichever test happened to run first rather than on its own call — reorder the file and it would have gone vacuous. Memoising three property writes buys nothing measurable, and the flag also meant anything that later reset them would never be corrected. Dropped it. The test now restores electron-updater's defaults in beforeEach and asserts from inside the checkForUpdates mock, so it pins that the settings are applied BEFORE the check runs — a check that starts downloading before autoDownload is off has already pulled ~243 MB nobody asked for. --- electron/auto-updater.test.ts | 21 ++++++++++++++++++--- electron/auto-updater.ts | 22 +++++++++++----------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 17958b54a..3f60a2ec2 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -70,6 +70,10 @@ describe("blockedFromInstalling", () => { describe("self-update flow", () => { beforeEach(() => { mocks.app.isPackaged = true; + // Back to electron-updater's DEFAULTS before every test, so "these are false" can only + // pass because this test's call configured them — not because an earlier test did. + mocks.autoUpdater.autoDownload = true; + mocks.autoUpdater.autoInstallOnAppQuit = true; mocks.autoUpdater.checkForUpdates.mockReset(); mocks.autoUpdater.downloadUpdate.mockReset(); mocks.autoUpdater.quitAndInstall.mockReset(); @@ -93,10 +97,21 @@ describe("self-update flow", () => { // is a window, so the stock autoInstallOnAppQuit would fire a ~243 MB installer when the // user merely closed the HUD. it("disables auto-download and install-on-quit before doing anything", async () => { - mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); + // Asserted INSIDE the mock: checking after the call would still pass if the settings + // were applied late, and "late" is the whole failure — a check that starts downloading + // before autoDownload is turned off has already pulled ~243 MB the user never asked for. + const settingsWhenChecked: Array = []; + mocks.autoUpdater.checkForUpdates.mockImplementation(() => { + settingsWhenChecked.push( + mocks.autoUpdater.autoDownload, + mocks.autoUpdater.autoInstallOnAppQuit, + ); + return Promise.resolve({ updateInfo: { version: "1.9.2" } }); + }); + await checkForSelfUpdate("nsis"); - expect(mocks.autoUpdater.autoDownload).toBe(false); - expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(false); + + expect(settingsWhenChecked).toEqual([false, false]); }); it("reports current when the feed offers the running version", async () => { diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts index 9dbe17c89..fdee80d2b 100644 --- a/electron/auto-updater.ts +++ b/electron/auto-updater.ts @@ -36,21 +36,21 @@ export function blockedFromInstalling(state: InstallReadiness): string | null { return null; } -let configured = false; - /** Loaded lazily: importing electron-updater costs startup time on every launch, and the * channels that cannot use it must not pay for it at all. */ async function getUpdater() { const { autoUpdater } = await import("electron-updater"); - if (!configured) { - // Never surprise the user with a 240 MB download or an install they did not ask for. - // `window-all-closed` quits this app, and the HUD is a window — with the default - // `autoInstallOnAppQuit` simply closing the HUD would kick off an installer. - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - autoUpdater.logger = null; - configured = true; - } + // Never surprise the user with a 240 MB download or an install they did not ask for. + // `window-all-closed` quits this app, and the HUD is a window — with the default + // `autoInstallOnAppQuit` simply closing the HUD would kick off an installer. + // + // Reapplied on every call rather than set once behind a flag: these are three property + // writes, so memoising them buys nothing measurable, and a "configured" flag would mean + // anything that later reset them is never corrected — while making the guarantee hold only + // for whichever caller happened to come first. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.logger = null; return autoUpdater; } From 42c9587e94d2098ba61dd8280a5a816027508e53 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 11:58:10 +0200 Subject: [PATCH 4/4] chore(nix): refresh npmDepsHash for electron-updater The rebase onto main took main's hash for the conflicting line; the lockfile is now main's plus electron-updater, so neither side's value was right. nix-check.yml reported the expected one. --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 4da63450d..91cf68e9e 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -37,7 +37,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-fBNFOicysW5FAbPERqzXUzIcQ0C+ICsQFtGt1agF1VI="; + npmDepsHash = "sha256-1fer91zZlZxC5SoIj3F/bNcLoD+Q+QxLpCZF067Upko="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";