Files
fusion/packages/desktop/src/native.ts
gsxdsm 2302fb8a3d feat: add beta/stable release tracks with switchable update channel (#2345)
## Summary

Fusion can now ship on two release tracks. Betas are cut from `main` as
`vX.Y.Z-beta.N` (npm dist-tag `beta`, GitHub prerelease), stable
releases are promoted to a long-lived `release` branch and published to
`latest`, and users pick their track with the new `updateChannel` global
setting — via **Settings → General → Release channel** or `fn update
--channel <stable|beta>`. Previously everything was single-track: every
publish landed on `latest` and every update surface could only see it.

| | beta | stable |
|---|---|---|
| Cut from | `main` | `release` branch |
| Version | `X.Y.Z-beta.N` (changesets pre-mode) | `X.Y.Z` |
| npm dist-tag | `beta` | `latest` |
| GitHub Release | prerelease | latest |
| Homebrew tap / X draft | skipped | bumped / printed |

## How releasing works now

`pnpm release` prompts for the channel and **defaults to beta**, so
day-to-day releases are betas; stable is always an explicit choice.
Choosing stable from `main` triggers assisted promotion: the script
proposes the newest beta tag reachable from HEAD, verifies `release`
fast-forwards to it, then runs the whole stable release inside a
temporary git worktree on `release` — the primary checkout never leaves
`main`. Changesets pre-mode preserves changeset files across betas, so
the promoted stable release aggregates every changeset since the last
stable into one clean changelog entry.

## Design decisions

- **Every publish path names an explicit `--tag`.** A beta accidentally
landing on `latest` is the one unrecoverable failure of a dual-track
scheme, so nothing relies on npm's implicit default (`release.mjs`,
`version.yml`).
- **Beta channel resolves to semver-max of `latest` and `beta`**, so
beta users are offered each promoted stable once it overtakes their
prerelease. Switching beta → stable never downgrades; `fn update
--channel stable --force` is the explicit escape hatch.
- **One comparator instead of three.** CLI, dashboard, and desktop each
had their own `isRemoteNewer` that ignored prerelease identifiers —
`0.73.0-beta.2`, `-beta.3`, and `0.73.0` all compared equal, which
breaks the moment any beta exists. They now share full SemVer-precedence
helpers (`compareVersions`, `resolveUpdateTargetVersion`) from
`@fusion/core`.
- **Installs pin exact versions** (`@runfusion/fusion@0.73.0-beta.2`),
never a dist-tag, so an install can't silently land on the wrong track.
- **Desktop channels via electron-updater manifests.** Beta tags build
desktop artifacts with `publish.channel=beta` (emitting `beta*.yml`);
the app sets `channel`/`allowPrerelease` from the shared setting,
re-read on every manual check.
- **Update caches are channel-stamped** — a cache written for one
channel is never served to the other, so switching tracks takes effect
on the next check instead of after TTL.

## Test plan

- New unit coverage: SemVer precedence + channel resolution in
`@fusion/core` (30), channel behavior of the dashboard update check (28,
incl. 9 new) and `fn update` (16, incl. 8 new: persist `--channel`,
no-downgrade, `--force`, cache channel mismatch).
- `pnpm verify:fast` green (scoped typecheck, builds, CLI build, boot
smoke); desktop + settings-section suites green.
- `release.mjs` dry-run matrix exercised by hand: channel prompt
(default/override/invalid), branch preflights per channel,
assisted-promotion target selection, fast-forward guard against a
diverged `release` branch, and bootstrap when no `release` branch
exists.
- Not exercised live: an end-to-end publish (needs TTY authorization +
real npm publish). First real run is the first `pnpm release --channel
beta`.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![Claude
Code](https://img.shields.io/badge/Fable_5-D97757?logo=claude&logoColor=white)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added beta and stable release channels across CLI, dashboard, and
desktop updates.
* Users can select a channel via Settings or `fn update --channel
<stable|beta>` (stored as a global default).
* Desktop beta releases now generate beta update manifests and publish
as prereleases.
* **Documentation**
* Expanded release-track, settings, and CLI references to explain
channel semantics and workflows.
* **Bug Fixes**
* Updates now pin the resolved version per channel, improve version
comparison, and prevent unintended cross-channel downgrades unless
`--force` is used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:34:25 -07:00

549 lines
16 KiB
TypeScript

import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
app,
BrowserWindow,
dialog,
Notification,
type OpenDialogOptions,
type SaveDialogOptions,
} from "electron";
export interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
export interface DisplayWorkAreaLike {
workArea: {
x: number;
y: number;
width: number;
height: number;
};
}
const MIN_VISIBLE_OVERLAP_PX = 64;
export function clampWindowStateToVisibleDisplay(
state: WindowState,
displays: DisplayWorkAreaLike[],
): WindowState {
if (state.x === undefined || state.y === undefined) {
return state;
}
const windowLeft = state.x;
const windowTop = state.y;
const windowRight = state.x + state.width;
const windowBottom = state.y + state.height;
const hasSufficientOverlap = displays.some(({ workArea }) => {
const displayLeft = workArea.x;
const displayTop = workArea.y;
const displayRight = workArea.x + workArea.width;
const displayBottom = workArea.y + workArea.height;
const overlapWidth = Math.max(0, Math.min(windowRight, displayRight) - Math.max(windowLeft, displayLeft));
const overlapHeight = Math.max(0, Math.min(windowBottom, displayBottom) - Math.max(windowTop, displayTop));
return overlapWidth >= MIN_VISIBLE_OVERLAP_PX && overlapHeight >= MIN_VISIBLE_OVERLAP_PX;
});
if (hasSufficientOverlap) {
return state;
}
return {
width: state.width,
height: state.height,
isMaximized: state.isMaximized,
};
}
export type DesktopLaunchMode = "choose" | "local" | "remote";
export interface DesktopRemoteProfileLike {
id: string;
name: string;
serverUrl: string;
authToken?: string | null;
}
export interface DesktopShellSettingsLike {
desktopMode: "local" | "remote" | null;
activeProfileId: string | null;
profiles: DesktopRemoteProfileLike[];
}
export interface NormalizedDesktopRemoteLaunch {
mode: "remote";
profileId: string;
serverBaseUrl: string;
serverLabel?: string;
authToken?: string;
}
export const SHELL_HANDOFF_QUERY = {
shellKind: "shellKind",
shellMode: "shellMode",
profileId: "profileId",
serverBaseUrl: "serverBaseUrl",
serverLabel: "serverLabel",
token: "token",
canOpenConnectionManager: "shellCanOpenConnectionManager",
} as const;
export const DEFAULT_WINDOW_STATE: WindowState = {
width: 1280,
height: 900,
isMaximized: false,
};
interface DesktopNotificationOptions {
silent?: boolean;
onClick?: () => void;
}
function generateSettingsExportFilename(date: Date = new Date()): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `fusion-settings-${year}-${month}-${day}-${hours}${minutes}${seconds}.json`;
}
function getWindowStatePath(): string {
return join(app.getPath("userData"), "window-state.json");
}
function getDesktopLaunchModePath(): string {
return join(app.getPath("userData"), "desktop-launch-mode.json");
}
function isValidWindowState(value: unknown): value is WindowState {
if (value === null || typeof value !== "object") {
return false;
}
const candidate = value as Partial<WindowState>;
const hasValidPosition =
(candidate.x === undefined || typeof candidate.x === "number") &&
(candidate.y === undefined || typeof candidate.y === "number");
return (
hasValidPosition &&
typeof candidate.width === "number" &&
Number.isFinite(candidate.width) &&
typeof candidate.height === "number" &&
Number.isFinite(candidate.height) &&
typeof candidate.isMaximized === "boolean"
);
}
export async function showExportSettingsDialog(parentWindow?: BrowserWindow): Promise<string | null> {
const filename = generateSettingsExportFilename();
const defaultPath = join(app.getPath("documents"), filename);
const dialogOptions: SaveDialogOptions = {
title: "Export Fusion Settings",
defaultPath,
filters: [{ name: "JSON Files", extensions: ["json"] }],
};
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return null;
}
return result.filePath;
}
export async function showImportSettingsDialog(parentWindow?: BrowserWindow): Promise<string | null> {
const dialogOptions: OpenDialogOptions = {
title: "Import Fusion Settings",
properties: ["openFile"],
filters: [{ name: "JSON Files", extensions: ["json"] }],
};
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0] ?? null;
}
export function showDesktopNotification(
title: string,
body: string,
options: DesktopNotificationOptions = {},
): void {
if (!Notification.isSupported()) {
console.warn("[desktop/native] Notifications are not supported in this environment");
return;
}
try {
const notification = new Notification({
title,
body,
silent: options.silent,
});
if (options.onClick) {
notification.on("click", options.onClick);
}
notification.show();
} catch (error) {
console.error("[desktop/native] Failed to display desktop notification", error);
}
}
type AutoUpdaterLike = {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
channel?: string | null;
allowPrerelease?: boolean;
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
checkForUpdates: () => Promise<unknown>;
};
let cachedAutoUpdater: AutoUpdaterLike | null = null;
let autoUpdaterLoadPromise: Promise<AutoUpdaterLike | null> | null = null;
let listenersBound = false;
let hasRunInitialUpdateCheck = false;
let autoUpdaterWindow: BrowserWindow | undefined;
let updateIntervalTimer: ReturnType<typeof setInterval> | null = null;
let updateIntervalDisposer: (() => void) | null = null;
let updateIntervalWindow: BrowserWindow | null = null;
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "Unknown error";
}
async function resolveAutoUpdater(): Promise<AutoUpdaterLike | null> {
if (cachedAutoUpdater) {
return cachedAutoUpdater;
}
if (!autoUpdaterLoadPromise) {
autoUpdaterLoadPromise = (async () => {
try {
const mod = (await import("electron-updater")) as {
default?: { autoUpdater?: unknown };
autoUpdater?: unknown;
};
const namedAutoUpdater = "autoUpdater" in mod ? (mod as { autoUpdater?: unknown }).autoUpdater : undefined;
const autoUpdater = (mod.default?.autoUpdater ?? namedAutoUpdater) as AutoUpdaterLike | undefined;
if (!autoUpdater) {
console.warn("[desktop/native] Auto-updater module loaded without autoUpdater export");
return null;
}
cachedAutoUpdater = autoUpdater;
return autoUpdater;
} catch (error) {
console.warn("[desktop/native] Auto-updater unavailable", error);
return null;
}
})();
}
return autoUpdaterLoadPromise;
}
function bindAutoUpdaterListeners(autoUpdater: AutoUpdaterLike): void {
if (listenersBound) {
return;
}
listenersBound = true;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on("update-available", (info) => {
showDesktopNotification("Fusion Update Available", "Update available — downloading in background", {
silent: true,
});
autoUpdaterWindow?.webContents.send("update-available", info);
});
autoUpdater.on("update-downloaded", (info) => {
showDesktopNotification("Fusion Update Ready", "Update ready — will install on quit", {
silent: true,
});
autoUpdaterWindow?.webContents.send("update-downloaded", info);
});
autoUpdater.on("update-not-available", (info) => {
showDesktopNotification("Fusion is up to date", "You're on the latest version", {
silent: true,
});
autoUpdaterWindow?.webContents.send("update-not-available", info);
});
autoUpdater.on("error", (error) => {
const message = getErrorMessage(error);
console.error("[desktop/native] Auto-updater error", error);
autoUpdaterWindow?.webContents.send("update-error", { message });
});
}
/*
FNXC:UpdateChannels 2026-07-19-13:15:
The desktop updater honors the shared `updateChannel` global setting. On the
beta channel we set electron-updater's `channel` to "beta" (so it reads the
`beta*.yml` manifests published by beta desktop builds) and `allowPrerelease`
(so GitHub prereleases are considered). Stable leaves electron-updater at its
defaults: the GitHub "latest" non-prerelease release only. Failures fall back
to stable — the updater must never break because settings are unreadable.
*/
async function applyConfiguredUpdateChannel(autoUpdater: AutoUpdaterLike): Promise<void> {
let channel: "stable" | "beta" = "stable";
try {
const { GlobalSettingsStore } = await import("@fusion/core");
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
channel = settings.updateChannel === "beta" ? "beta" : "stable";
} catch (error) {
console.warn("[desktop/native] Could not read update channel setting; defaulting to stable", error);
}
if (channel === "beta") {
autoUpdater.channel = "beta";
autoUpdater.allowPrerelease = true;
} else {
autoUpdater.channel = null;
autoUpdater.allowPrerelease = false;
}
}
export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
if (mainWindow) {
autoUpdaterWindow = mainWindow;
}
void (async () => {
try {
const autoUpdater = await resolveAutoUpdater();
if (!autoUpdater) {
return;
}
bindAutoUpdaterListeners(autoUpdater);
await applyConfiguredUpdateChannel(autoUpdater);
if (hasRunInitialUpdateCheck) {
return;
}
hasRunInitialUpdateCheck = true;
await autoUpdater.checkForUpdates().catch((error: unknown) => {
console.error("[desktop/native] Auto-updater check failed", error);
});
} catch (error) {
console.warn("[desktop/native] Auto-updater setup failed", error);
}
})();
}
export async function triggerUpdateCheck(
mainWindow?: BrowserWindow,
): Promise<{ status: "checking" } | { status: "unavailable"; reason: string } | { status: "error"; error: string }> {
setupAutoUpdater(mainWindow);
const autoUpdater = await resolveAutoUpdater();
if (!autoUpdater) {
return { status: "unavailable", reason: "updater_unavailable" };
}
try {
// Re-read the channel on every manual check so a settings change takes
// effect without an app restart.
await applyConfiguredUpdateChannel(autoUpdater);
await autoUpdater.checkForUpdates();
return { status: "checking" };
} catch (error) {
return { status: "error", error: getErrorMessage(error) };
}
}
export function startUpdateCheckInterval(mainWindow: BrowserWindow, intervalMs = 4 * 60 * 60 * 1000): () => void {
autoUpdaterWindow = mainWindow;
if (updateIntervalDisposer && updateIntervalWindow === mainWindow) {
return updateIntervalDisposer;
}
if (updateIntervalTimer) {
clearInterval(updateIntervalTimer);
updateIntervalTimer = null;
}
updateIntervalWindow = mainWindow;
updateIntervalTimer = setInterval(() => {
void triggerUpdateCheck(mainWindow);
}, intervalMs);
updateIntervalDisposer = () => {
if (updateIntervalTimer) {
clearInterval(updateIntervalTimer);
updateIntervalTimer = null;
}
updateIntervalDisposer = null;
updateIntervalWindow = null;
};
return updateIntervalDisposer;
}
function normalizeServerBaseUrl(serverUrl: string): string | null {
try {
const parsed = new URL(serverUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.toString().replace(/\/$/, "");
} catch {
return null;
}
}
export function normalizeDesktopRemoteLaunch(settings: DesktopShellSettingsLike): NormalizedDesktopRemoteLaunch | null {
if (settings.desktopMode !== "remote" || !settings.activeProfileId) {
return null;
}
const activeProfile = settings.profiles.find((profile) => profile.id === settings.activeProfileId);
if (!activeProfile) {
return null;
}
const serverBaseUrl = normalizeServerBaseUrl(activeProfile.serverUrl);
if (!serverBaseUrl) {
return null;
}
return {
mode: "remote",
profileId: activeProfile.id,
serverBaseUrl,
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
...(activeProfile.authToken ? { authToken: activeProfile.authToken } : {}),
};
}
export function buildRemoteShellHandoffUrl(launch: NormalizedDesktopRemoteLaunch): string {
const url = new URL(launch.serverBaseUrl);
url.searchParams.set(SHELL_HANDOFF_QUERY.shellKind, "desktop");
url.searchParams.set(SHELL_HANDOFF_QUERY.shellMode, "remote");
url.searchParams.set(SHELL_HANDOFF_QUERY.profileId, launch.profileId);
url.searchParams.set(SHELL_HANDOFF_QUERY.serverBaseUrl, launch.serverBaseUrl);
if (launch.serverLabel) {
url.searchParams.set(SHELL_HANDOFF_QUERY.serverLabel, launch.serverLabel);
}
if (launch.authToken) {
url.searchParams.set(SHELL_HANDOFF_QUERY.token, launch.authToken);
}
url.searchParams.set(SHELL_HANDOFF_QUERY.canOpenConnectionManager, "1");
return url.toString();
}
function isValidDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
return value === "choose" || value === "local" || value === "remote";
}
export async function loadDesktopLaunchMode(): Promise<DesktopLaunchMode> {
const launchModePath = getDesktopLaunchModePath();
try {
const raw = await readFile(launchModePath, "utf-8");
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object" && "mode" in parsed) {
const mode = (parsed as { mode?: unknown }).mode;
if (isValidDesktopLaunchMode(mode)) {
return mode;
}
}
return "choose";
} catch {
return "choose";
}
}
export async function saveDesktopLaunchMode(mode: DesktopLaunchMode): Promise<void> {
const launchModePath = getDesktopLaunchModePath();
const tempPath = `${launchModePath}.tmp`;
await writeFile(tempPath, JSON.stringify({ mode }, null, 2), "utf-8");
await rename(tempPath, launchModePath);
}
export async function loadWindowState(): Promise<WindowState | null> {
const statePath = getWindowStatePath();
try {
const raw = await readFile(statePath, "utf-8");
const parsed: unknown = JSON.parse(raw);
if (!isValidWindowState(parsed)) {
return null;
}
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
return null;
}
}
export function saveWindowState(mainWindow: BrowserWindow): void {
if (mainWindow.isDestroyed()) {
return;
}
const bounds = mainWindow.getBounds();
const state: WindowState = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: mainWindow.isMaximized(),
};
const statePath = getWindowStatePath();
const tempPath = `${statePath}.tmp`;
void writeFile(tempPath, JSON.stringify(state, null, 2), "utf-8")
.then(() => rename(tempPath, statePath))
.catch((error) => {
console.error("[desktop/native] Failed to save window state", error);
});
}