Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 103 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
29 changes: 29 additions & 0 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
158 changes: 158 additions & 0 deletions electron/auto-updater.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<boolean> = [];
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]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
Loading
Loading