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
16 changes: 15 additions & 1 deletion .github/workflows/openflow-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
# row per architecture) and Linux (AppImage + deb, SCRUM-70) are here. Everyday checks live in
# openflow-ci.yml.
#
# In-app updates: electron-builder writes latest.yml / latest-mac.yml / latest-linux.yml next to
# the installers (the GitHub publish target in package.json; nothing is uploaded by it). They go
# on the draft release with the installers, the two Mac rows' files merged into one by the
# release job; installed copies read them only once the owner publishes the release.
#
# Signing: Windows through Azure Artifact Signing, macOS with a Developer ID certificate plus
# notarization. Both switch on by themselves once the secrets and variables named in the
# "code signing" step exist (scripts/ci-signing.mjs has the list); until then the builds are
Expand Down Expand Up @@ -107,13 +112,16 @@ jobs:
run: npm run build:app -- ${{ matrix.target }}
- name: the artefacts are signed (when signing is on)
run: node scripts/ci-signing.mjs --verify
- name: name this Mac row's update file by its architecture (the release job merges them)
if: runner.os == 'macOS'
run: mv release/latest-mac.yml "release/latest-mac-$(uname -m).yml"
- name: checksums
env:
SUMS_FOR: ${{ matrix.os }} # not process.platform: both Mac rows report darwin
run: |
node -e '
const fs = require("fs"), crypto = require("crypto");
const out = fs.readdirSync("release").filter(f => /\.(exe|dmg|AppImage|deb)$/.test(f))
const out = fs.readdirSync("release").filter(f => /\.(exe|dmg|zip|AppImage|deb)$/.test(f))
.map(f => crypto.createHash("sha256").update(fs.readFileSync("release/" + f)).digest("hex") + " " + f);
fs.writeFileSync("release/SHA256SUMS-" + process.env.SUMS_FOR + ".txt", out.join("\n") + "\n");
console.log(out.join("\n"));'
Expand All @@ -123,8 +131,11 @@ jobs:
path: |
release/*.exe
release/*.dmg
release/*.zip
release/*.AppImage
release/*.deb
release/latest*.yml
release/*.blockmap
release/SHA256SUMS-*.txt
if-no-files-found: error

Expand All @@ -135,10 +146,13 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: one latest-mac.yml for both Mac builds
run: node scripts/merge-mac-update-info.mjs artifacts
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/openflow-updater-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# The in-app updater, end to end: an installed OpenFlow updates itself to 9.9.9 from a release
# served on localhost (scripts/updater-e2e.mjs), on Windows, macOS and Linux. The builds are
# signed exactly as the release signs them, because the updater checks that: Squirrel.Mac takes
# only an update signed like the running app, and on Windows the installer's publisher is
# compared with the one the app was built for. Nothing here is uploaded anywhere but the logs.

name: openflow-updater-e2e

on:
workflow_dispatch:
pull_request:
paths:
- "electron/**"
- "package.json"
- "scripts/updater-e2e.mjs"
- "scripts/ci-signing.mjs"
- ".github/workflows/openflow-updater-e2e.yml"

defaults:
run:
shell: bash

jobs:
e2e:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
env:
# electron-builder skips signing in a pull request unless told otherwise; this test needs
# the signature (Squirrel.Mac refuses an unsigned update). A pull request from a fork gets
# no secrets from GitHub anyway, so nothing would be signed there.
CSC_FOR_PULL_REQUEST: "true"
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-22.04]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: backend with the build extra
working-directory: backend
run: pip install -e ".[build]"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: |
package-lock.json
frontend/package-lock.json
- name: npm ci (shell and renderer)
run: |
npm ci
npm --prefix frontend ci
- name: AppImage runtime and a display (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libfuse2 xvfb
- name: code signing, as the release does it
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ vars.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_PROFILE: ${{ vars.AZURE_SIGNING_PROFILE }}
AZURE_SIGNING_PUBLISHER: ${{ vars.AZURE_SIGNING_PUBLISHER }}
run: node scripts/ci-signing.mjs
- name: an old build updates itself to 9.9.9
run: node scripts/updater-e2e.mjs
- name: updater logs
if: always()
uses: actions/upload-artifact@v4
with:
name: updater-e2e-logs-${{ matrix.os }}
path: |
~/AppData/Roaming/OpenFlow/logs/
~/Library/Application Support/OpenFlow/logs/
~/.local/share/OpenFlow/logs/
if-no-files-found: ignore
16 changes: 16 additions & 0 deletions backend/openflow_backend/api/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,22 @@ async def check_for_updates() -> dict:
return {"updateAvailable": False, "reason": "OpenFlow has no external update source"}


@router.get("/api/update-safe")
async def update_safe() -> dict:
"""Whether OpenFlow may close now to install an update of itself.

The shell asks before it stops the backend for an install (electron/updater.js), and again
after the download: quitting ends the backend, and a keyboard or module flash cut off that
way can leave a half that will not boot. Keyboard and module flashes both run through
flash_runs, so its flag covers both.
"""
from ..device import flash_runs as runs
if runs.active():
return {"safe": False,
"reason": "A firmware flash is running. Install the update once it has finished."}
return {"safe": True, "reason": None}


# --- flash (write) : preview only for now (dry-run); real write is Phase C ---

@router.get("/api/module-variants")
Expand Down
1 change: 1 addition & 0 deletions backend/tests/route-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"GET /api/status/last",
"GET /api/status/live",
"GET /api/ui/state",
"GET /api/update-safe",
"GET /api/userdata",
"GET /docs",
"GET /docs/oauth2-redirect",
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/test_update_safe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""The shell asks /api/update-safe before closing OpenFlow to install an update of itself.

Quitting ends the backend, and a keyboard or module flash cut off that way can leave a half that
will not boot. Both kinds of flash run through flash_runs, so its flag is the whole answer.
"""
import asyncio

from openflow_backend.api import rest
from openflow_backend.device import flash_runs


def ask():
return asyncio.run(rest.update_safe())


def test_safe_when_nothing_is_flashing(monkeypatch):
monkeypatch.setattr(flash_runs, "_active", False)
assert ask() == {"safe": True, "reason": None}


def test_not_safe_while_a_flash_runs(monkeypatch):
monkeypatch.setattr(flash_runs, "_active", True)
answer = ask()
assert answer["safe"] is False
assert "flash" in answer["reason"]
44 changes: 43 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
// * a clean stop: /rpc/shutdown with the token (the backend closes the keyboard's port),
// then the process tree is killed if it has not gone within two seconds;
// * the sidecar's output lands in <data dir>/logs/sidecar.log, named in the error dialog when
// the backend fails to start or dies.
// the backend fails to start or dies;
// * an update installs only on a click and never during a flash (electron/updater.js).

const { app, BrowserWindow, Menu, dialog, screen, shell } = require("electron");
const { spawn, spawnSync } = require("child_process");
Expand All @@ -27,6 +28,7 @@ const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { setupUpdates } = require("./updater");

const DEV = process.env.OPENFLOW_DEV === "1";
const PREFERRED_PORT = 3001;
Expand Down Expand Up @@ -228,7 +230,40 @@ function createWindow(port) {
}
}

// The backend's answer to "may OpenFlow close for an update now?". No answer counts as no: a
// flash cannot be ruled out.
async function updateSafe() {
try {
const { status, json } = await httpJson("GET", `http://127.0.0.1:${backendPort}/api/update-safe`, null, 3000);
if (status === 200 && json && typeof json.safe === "boolean") return json;
} catch {}
return { safe: false, reason: "OpenFlow's backend did not answer; try again in a moment." };
}

// `--update-now`: check, download and install with no window and no backend, then exit. For
// the updater test (scripts/updater-e2e.mjs); the window's Install button runs the same code.
async function updateNow() {
backendStopped = true;
const updates = setupUpdates({
dataDir, getWindow: () => null,
updateSafe: async () => ({ safe: true, reason: null }),
stopBackend: async () => {},
});
const found = await updates.check().catch((e) => ({ error: e.message }));
if (found.error || !found.available) {
updates.log(`--update-now: ${found.error || "nothing to install"}`);
app.exit(found.error ? 1 : 0);
return;
}
const r = await updates.install({ silent: true, restart: false });
if (!r.installing) {
updates.log(`--update-now: ${r.error || r.blocked}`);
app.exit(1);
}
}

async function start() {
if (process.argv.includes("--update-now")) return updateNow();
buildMenu();
backendPort = await findFreePort(PREFERRED_PORT);
startBackend(backendPort);
Expand All @@ -241,6 +276,13 @@ async function start() {
app.exit(1);
return;
}
setupUpdates({
dataDir,
getWindow: () => win,
updateSafe,
// Closing for an install is a quit like any other: no "backend stopped" dialog.
stopBackend: () => { quitting = true; return stopBackend(); },
});
createWindow(backendPort);
}

Expand Down
21 changes: 20 additions & 1 deletion electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,28 @@
// The port is the second-to-last argv entry (Electron appends "/prefetch:1"),
// which is exactly how NayaFlow's preload.mjs read it.

const { contextBridge } = require("electron");
//
// And OpenFlow's own updates (electron/updater.js) as window.openflowUpdates: the page asks, the
// shell checks, downloads and installs. Absent in a plain browser (the Vite dev server, tests).

const { contextBridge, ipcRenderer } = require("electron");

const args = process.argv.filter((a) => a !== "/prefetch:1");
const bgServerPort = parseInt(args[args.length - 1], 10) || 3001;

contextBridge.exposeInMainWorld("EXPOSED", { bgServerPort });

const subscribe = (channel) => (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
};

contextBridge.exposeInMainWorld("openflowUpdates", {
state: () => ipcRenderer.invoke("updates:state"),
setCheck: (on) => ipcRenderer.invoke("updates:set-check", !!on),
check: () => ipcRenderer.invoke("updates:check"),
install: () => ipcRenderer.invoke("updates:install"),
onProgress: subscribe("updates:progress"),
onAvailable: subscribe("updates:available"),
});
Loading
Loading