diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71e95f54..6925c8a6 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 86dbad7c..8110c9b4 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 00000000..3f60a2ec --- /dev/null +++ b/electron/auto-updater.test.ts @@ -0,0 +1,158 @@ +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 }; +} + +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"); + }); +}); + +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(); + }); + + 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 () => { + // 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(settingsWhenChecked).toEqual([false, 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/auto-updater.ts b/electron/auto-updater.ts new file mode 100644 index 00000000..fdee80d2 --- /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; +} + +/** 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"); + // 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; +} + +/** 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 00000000..b34861f7 --- /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 00000000..2e007c58 --- /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 f719ed4d..60313e3e 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,78 @@ 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, + // 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; + } + + 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 +441,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 +464,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 +490,7 @@ async function checkForUpdates() { }); } finally { updateCheckInFlight = false; + updateCheckAbort = null; } } @@ -419,12 +520,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 +684,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 +835,7 @@ appReady?.then(async () => { () => countdownOverlayWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; + isRecording = recording; if (!tray) createTray(); updateTrayMenu(recording); if (!recording) { diff --git a/nix/package.nix b/nix/package.nix index 4da63450..91cf68e9 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"; diff --git a/package-lock.json b/package-lock.json index 03324d6b..16fc629b 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 76f8c962..7a22a8d3 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 00000000..042160b1 --- /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 00000000..36c76158 --- /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 baf728d8..29acacda 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,7 +44,11 @@ "updates": { "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", "current": "OpenScreen محدّث ({{currentVersion}}).", - "failed": "تعذّر التحقق من وجود تحديثات." + "readyToInstall": "تم تنزيل OpenScreen {{latestVersion}}. أعد التشغيل لإكمال التثبيت.", + "blockedRecording": "أنهِ التسجيل قبل تثبيت التحديث.", + "blockedLocation": "انقل OpenScreen إلى مجلد التطبيقات قبل التحديث.", + "failed": "تعذّر التحقق من وجود تحديثات.", + "downloadFailed": "تعذّر تنزيل التحديث." }, "playback": { "play": "تشغيل", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 23fec245..f0815933 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} is available. You are using {{currentVersion}}.", "current": "OpenScreen is up to date ({{currentVersion}}).", - "failed": "Could not check for updates." + "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.", + "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 6486fd54..d65cac3a 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} está disponible. Estás usando {{currentVersion}}.", "current": "OpenScreen está actualizado ({{currentVersion}}).", - "failed": "No se pudieron buscar actualizaciones." + "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.", + "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 13004834..1041a771 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} est disponible. Vous utilisez la version {{currentVersion}}.", "current": "OpenScreen est à jour ({{currentVersion}}).", - "failed": "Impossible de rechercher les mises à jour." + "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.", + "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 7265a1db..ca715f23 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} è disponibile. Stai usando la versione {{currentVersion}}.", "current": "OpenScreen è aggiornato ({{currentVersion}}).", - "failed": "Impossibile controllare gli aggiornamenti." + "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.", + "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 66d23371..c2dba133 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} を利用できます。現在のバージョンは {{currentVersion}} です。", "current": "OpenScreen は最新です({{currentVersion}})。", - "failed": "アップデートを確認できませんでした。" + "readyToInstall": "OpenScreen {{latestVersion}} をダウンロードしました。再起動してインストールを完了してください。", + "blockedRecording": "アップデートをインストールする前に録画を終了してください。", + "blockedLocation": "アップデートする前に OpenScreen をアプリケーションフォルダに移動してください。", + "failed": "アップデートを確認できませんでした。", + "downloadFailed": "アップデートをダウンロードできませんでした。" }, "playback": { "play": "再生", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index a3e409bf..2a959315 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} 버전을 사용할 수 있습니다. 현재 버전은 {{currentVersion}}입니다.", "current": "OpenScreen이 최신 버전입니다({{currentVersion}}).", - "failed": "업데이트를 확인할 수 없습니다." + "readyToInstall": "OpenScreen {{latestVersion}}을(를) 다운로드했습니다. 다시 시작하여 설치를 완료하세요.", + "blockedRecording": "업데이트를 설치하기 전에 녹화를 마치세요.", + "blockedLocation": "업데이트하기 전에 OpenScreen을 응용 프로그램 폴더로 옮기세요.", + "failed": "업데이트를 확인할 수 없습니다.", + "downloadFailed": "업데이트를 다운로드하지 못했습니다." }, "playback": { "play": "재생", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 950683e6..7cdad691 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,7 +44,11 @@ "updates": { "available": "O OpenScreen {{latestVersion}} está disponível. Você está usando a versão {{currentVersion}}.", "current": "O OpenScreen está atualizado ({{currentVersion}}).", - "failed": "Não foi possível verificar atualizações." + "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.", + "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 6388998a..4cfca236 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,7 +44,11 @@ "updates": { "available": "Доступен OpenScreen {{latestVersion}}. Установлена версия {{currentVersion}}.", "current": "Установлена последняя версия OpenScreen ({{currentVersion}}).", - "failed": "Не удалось проверить наличие обновлений." + "readyToInstall": "OpenScreen {{latestVersion}} загружен. Перезапустите, чтобы завершить установку.", + "blockedRecording": "Завершите запись перед установкой обновления.", + "blockedLocation": "Переместите OpenScreen в папку «Программы» перед обновлением.", + "failed": "Не удалось проверить наличие обновлений.", + "downloadFailed": "Не удалось загрузить обновление." }, "playback": { "play": "Воспроизвести", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index a489bbdd..c2ca46a0 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} kullanılabilir. Mevcut sürümünüz {{currentVersion}}.", "current": "OpenScreen güncel ({{currentVersion}}).", - "failed": "Güncellemeler denetlenemedi." + "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.", + "downloadFailed": "Güncelleme indirilemedi." }, "playback": { "play": "Oynat", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 3b0ad152..d5e2ed4c 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,7 +44,11 @@ "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}}).", - "failed": "Không thể kiểm tra bản cập nhật." + "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.", + "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 16d7d7df..7607db7a 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} 已发布。当前版本为 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", - "failed": "无法检查更新。" + "readyToInstall": "OpenScreen {{latestVersion}} 已下载完成。重启以完成安装。", + "blockedRecording": "请先结束录制,再安装更新。", + "blockedLocation": "更新前请将 OpenScreen 移动到「应用程序」文件夹。", + "failed": "无法检查更新。", + "downloadFailed": "无法下载更新。" }, "playback": { "play": "播放", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 9644d817..186e6822 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,7 +44,11 @@ "updates": { "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", - "failed": "無法檢查更新。" + "readyToInstall": "OpenScreen {{latestVersion}} 已下載完成。請重新啟動以完成安裝。", + "blockedRecording": "請先結束錄影,再安裝更新。", + "blockedLocation": "更新前請將 OpenScreen 移至「應用程式」資料夾。", + "failed": "無法檢查更新。", + "downloadFailed": "無法下載更新。" }, "playback": { "play": "播放",