From 299ae534c476e7ca2a5b3b62750c63cfe5dc8d6e Mon Sep 17 00:00:00 2001 From: atoz03 <31232741+atoz03@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:13:21 -0800 Subject: [PATCH 1/4] fix(desktop): stage macOS updates on the app's own volume for external drives Squirrel.Mac's ShipIt stages updates in the startup disk's temporary directory, so replacing an app on another volume is a cross-volume copy and delete. A ShipIt stopped partway leaves the app missing or half-written. Copies run from App Translocation or a read-only disk image download updates and fail only after quitAndInstall stops the backends. At startup, check which volume the app bundle is on. Startup-disk installs keep using ShipIt. App Translocation and read-only volumes turn automatic updates off and say why. For a writable app on another volume, unpack the update beside the app with ditto and verify it against the running app's designated requirement before stopping the backends. Then a detached helper swaps the bundle with two renames on that volume once the app exits, and reopens it. Fixes #31244 --- .../src/updates/DesktopUpdates.test.ts | 222 ++++++++++++++++- apps/desktop/src/updates/DesktopUpdates.ts | 87 ++++++- apps/desktop/src/updates/MacAppVolume.test.ts | 116 +++++++++ apps/desktop/src/updates/MacAppVolume.ts | 232 ++++++++++++++++++ .../desktop/src/updates/updatesTestHarness.ts | 121 ++++++++- docs/user/install.md | 7 + packages/shared/src/HostProcess.ts | 7 + 7 files changed, 777 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/updates/MacAppVolume.test.ts create mode 100644 apps/desktop/src/updates/MacAppVolume.ts diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..95ff27f7c133b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -20,7 +20,12 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +import * as MacAppVolume from "./MacAppVolume.ts"; +import { TEMP_DIRECTORY, flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; + +const DOWNLOADED_ZIP = "/Users/alice/Library/Caches/t3code-updater/pending/T3-Code-1.2.4-mac.zip"; +const REQUIREMENT = + 'designated => identifier "com.t3tools.t3code" and anchor apple generic and certificate leaf[subject.OU] = TEAMID1234'; describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => @@ -42,6 +47,221 @@ describe("DesktopUpdates", () => { }), ); + it.effect("updates macOS apps on any writable volume and says why it cannot elsewhere", () => + Effect.gen(function* () { + const startupDisk = 1n; + const checkFrom = ( + bundlePath: string, + location: { readonly device?: bigint; readonly readOnly?: boolean } = {}, + ) => { + const harness = makeHarness({ + resourcesPath: `${bundlePath}/Contents/Resources`, + devices: { [TEMP_DIRECTORY]: startupDisk, [bundlePath]: location.device ?? startupDisk }, + readOnlyPaths: location.readOnly + ? [bundlePath.slice(0, bundlePath.lastIndexOf("/"))] + : [], + }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const { checked } = yield* updates.check("manual"); + return { checked, state: yield* updates.getState }; + }), + ).pipe(Effect.provide(harness.layer)); + }; + + assert.isTrue((yield* checkFrom("/Applications/T3 Code.app")).checked); + assert.isTrue((yield* checkFrom("/Volumes/Macintosh HD/Applications/T3 Code.app")).checked); + const externalDrive = yield* checkFrom("/Volumes/ExternalDrive/T3 Code.app", { device: 2n }); + assert.isTrue(externalDrive.checked); + + const diskImage = yield* checkFrom("/Volumes/T3 Code/T3 Code.app", { + device: 3n, + readOnly: true, + }); + assert.isFalse(diskImage.checked); + assert.equal(diskImage.state.status, "disabled"); + assert.match(diskImage.state.message ?? "", /cannot replace itself on "T3 Code"/); + + const translocated = yield* checkFrom( + "/private/var/folders/xy/abc/T/AppTranslocation/0F3B9E2A/d/T3 Code.app", + ); + assert.isFalse(translocated.checked); + assert.equal(translocated.state.status, "disabled"); + assert.match(translocated.state.message ?? "", /temporary read-only copy/); + + const unreadable = makeHarness({ + resourcesPath: "/Volumes/PortableSSD/T3 Code.app/Contents/Resources", + }); + const unreadableCheck = yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const { checked } = yield* updates.check("manual"); + return { checked, state: yield* updates.getState }; + }), + ).pipe(Effect.provide(unreadable.layer)); + assert.isFalse(unreadableCheck.checked); + assert.isFalse(unreadableCheck.state.enabled); + assert.equal(unreadableCheck.state.status, "disabled"); + assert.equal( + unreadableCheck.state.message, + "Automatic updates are off because T3 Code could not check the disk it runs from.", + ); + }), + ); + + it.effect( + "installs beside a macOS app on another volume and leaves ShipIt the startup disk", + () => + Effect.gen(function* () { + const installFrom = (bundlePath: string, device: bigint) => { + const harness = makeHarness({ + resourcesPath: `${bundlePath}/Contents/Resources`, + devices: { [TEMP_DIRECTORY]: 1n, [bundlePath]: device }, + stopBackend: Effect.sync(() => { + harness.installSteps.push("stopBackend"); + }), + runCommand: (_command, args) => + args[0] === "-d" + ? { output: `Executable=${bundlePath}/Contents/MacOS/T3 Code\n${REQUIREMENT}\n` } + : undefined, + }); + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: DOWNLOADED_ZIP, + }); + yield* flushCallbacks; + const result = yield* updates.install; + return { harness, result, quitting: yield* Ref.get(desktopState.quitting) }; + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }; + + for (const bundlePath of [ + "/Applications/T3 Code.app", + "/Volumes/Macintosh HD/Applications/T3 Code.app", + ]) { + const { harness, result } = yield* installFrom(bundlePath, 1n); + assert.isTrue(result.accepted); + assert.deepEqual(harness.installSteps, ["stopBackend", "quitAndInstall"]); + } + + const bundlePath = "/Volumes/ExternalDrive/T3 Code.app"; + const stage = "/Volumes/ExternalDrive/.T3 Code.update"; + const stagedBundle = `${stage}/extracted/T3 Code.app`; + const { harness, result, quitting } = yield* installFrom(bundlePath, 2n); + assert.isTrue(result.accepted); + assert.equal(result.state.message, null); + assert.isTrue(quitting); + // The update unpacks and verifies while the backends still serve; only then does it quit. + assert.deepEqual(harness.installSteps, [ + "/usr/bin/ditto", + "/usr/bin/codesign", + "/usr/bin/codesign", + "stopBackend", + "/bin/sh", + "quit", + ]); + assert.deepEqual(harness.commands, [ + { + command: "/usr/bin/ditto", + args: ["-x", "-k", DOWNLOADED_ZIP, `${stage}/extracted`], + }, + { command: "/usr/bin/codesign", args: ["-d", "-r-", bundlePath] }, + { + command: "/usr/bin/codesign", + args: [ + "--verify", + "--deep", + "--strict", + "--test-requirement", + `=${REQUIREMENT.slice("designated => ".length)}`, + stagedBundle, + ], + }, + { + command: "/bin/sh", + args: [ + "-c", + MacAppVolume.SWAP_SCRIPT, + "t3code-update", + String(process.pid), + bundlePath, + stagedBundle, + stage, + ], + }, + ]); + }), + ); + + it.effect("keeps a macOS app on another volume running when its update fails to stage", () => + Effect.gen(function* () { + const bundlePath = "/Volumes/ExternalDrive/T3 Code.app"; + const stage = "/Volumes/ExternalDrive/.T3 Code.update"; + const cases = [ + { + downloadedFile: DOWNLOADED_ZIP, + exitCodes: { "/usr/bin/ditto": 1 }, + message: "Could not unpack the update next to T3 Code.", + }, + { + downloadedFile: DOWNLOADED_ZIP, + exitCodes: { "/usr/bin/codesign": 1 }, + message: + "The downloaded update is not signed like this copy of T3 Code, so it was not installed.", + }, + { + downloadedFile: undefined, + exitCodes: {}, + message: "Could not prepare the update next to T3 Code.", + }, + ] as const; + for (const failure of cases) { + const exitCodes: Readonly> = failure.exitCodes; + const harness = makeHarness({ + resourcesPath: `${bundlePath}/Contents/Resources`, + devices: { [TEMP_DIRECTORY]: 1n, [bundlePath]: 2n }, + runCommand: (command, args) => + args[0] === "-d" ? { output: REQUIREMENT } : { exitCode: exitCodes[command] ?? 0 }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { + version: "1.2.4", + downloadedFile: failure.downloadedFile, + }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.equal(result.state.errorContext, "install"); + assert.equal(result.state.message, failure.message); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.notInclude(harness.installSteps, "/bin/sh"); + assert.notInclude(harness.installSteps, "quit"); + assert.notInclude(harness.installSteps, "quitAndInstall"); + // Staging fails before the backends stop, so they never went down. + assert.isTrue((yield* (yield* pool.primary).snapshot).desiredRunning); + if (failure.downloadedFile !== undefined) assert.include(harness.removedPaths, stage); + assert.equal(harness.installRecords.size, 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + } + }), + ); + it.effect("keeps the disabled reason available after configuration and channel changes", () => { const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); const reason = "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..f63fab34a4b89 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -30,13 +30,16 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; +import * as MacAppVolume from "./MacAppVolume.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { createInitialDesktopUpdateState, @@ -74,6 +77,12 @@ const UpdateInfo = Schema.Struct({ releaseDate: Schema.optional(Schema.String), }); +const UpdateDownloadedInfo = Schema.Struct({ + ...UpdateInfo.fields, + // The zip an install on the app's own volume unpacks itself. + downloadedFile: Schema.optional(Schema.String), +}); + const DownloadProgressInfo = Schema.Struct({ percent: Schema.Number, }); @@ -86,6 +95,7 @@ const encodeInstallRecord = Schema.encodeEffect(DesktopUpdateInstallRecord); const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig); const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo); +const decodeUpdateDownloadedInfo = Schema.decodeUnknownEffect(UpdateDownloadedInfo); const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); @@ -335,6 +345,7 @@ function getAutoUpdateDisabledReason(args: { platform: NodeJS.Platform; appImage?: string | undefined; isDebPackage: boolean; + macAppLocation: MacAppVolume.MacAppLocation; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -353,7 +364,7 @@ function getAutoUpdateDisabledReason(args: { if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { return "Automatic updates on Linux require the AppImage or the .deb package."; } - return null; + return args.macAppLocation._tag === "Blocked" ? args.macAppLocation.reason : null; } function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { @@ -365,12 +376,14 @@ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const policy = yield* DesktopUpdatePolicy.DesktopUpdatePolicy; + const macAppVolume = yield* MacAppVolume.make; // Any user can set the mock flag, so it never replaces an administrator's update source. const mockUpdates = config.mockUpdates && policy.message === null; @@ -383,6 +396,9 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); + const downloadedFileRef = yield* Ref.make( + Option.none<{ readonly version: string; readonly file: string }>(), + ); // The release a track switch's database check cleared. A build crosses to another track only // through such a switch, and never below that release: version order alone cannot tell whether // a Stable knows a Nightly's migrations. @@ -451,6 +467,21 @@ export const make = Effect.gen(function* () { ) : false; + const macAppLocation = + environment.platform === "darwin" && environment.isPackaged + ? yield* macAppVolume + .resolveLocation(environment.resourcesPath, yield* HostProcess.TempDirectory) + .pipe( + // ShipIt onto a volume it cannot see may leave the app half-written, so updates stay off. + Effect.catch((error) => + logUpdaterWarning("could not check where the app is installed", { + errorTag: error._tag, + reason: error.reason._tag, + }).pipe(Effect.as(MacAppVolume.UNCHECKED)), + ), + ) + : MacAppVolume.SHIP_IT; + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( Effect.map( (appUpdateYmlConfig) => @@ -468,6 +499,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), isDebPackage, + macAppLocation, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), @@ -755,6 +787,20 @@ export const make = Effect.gen(function* () { ); }); + const stageOnAppVolume = Effect.fn("desktop.updates.stageOnAppVolume")(function* ( + bundlePath: string, + downloadedVersion: string | null, + ) { + const downloaded = Option.filter( + yield* Ref.get(downloadedFileRef), + (download) => download.version === downloadedVersion, + ); + if (Option.isNone(downloaded)) { + return yield* new MacAppVolume.MacAppVolumeInstallError({ step: "stage" }); + } + return yield* macAppVolume.stage(bundlePath, downloaded.value.file); + }); + const installDownloadedUpdate = (expectedVersion?: string) => Effect.scoped( Effect.gen(function* () { @@ -810,6 +856,11 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); + // Unpacking onto the app's own drive is slow, so backends keep serving until it is verified. + const staged = + macAppLocation._tag === "OwnVolume" + ? Option.some(yield* stageOnAppVolume(macAppLocation.bundlePath, downloadedVersion)) + : Option.none(); // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. @@ -822,13 +873,30 @@ export const make = Effect.gen(function* () { (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); - yield* electronUpdater.quitAndInstall({ - isSilent: true, - isForceRunAfter: true, - }); + if (Option.isSome(staged)) { + yield* macAppVolume.launchSwap(staged.value); + yield* electronApp.quit; + } else { + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, + }); + } return { accepted: true, completed: false, failed: false }; }).pipe( Effect.catchTags({ + MacAppVolumeInstallError: Effect.fn("desktop.updates.handleAppVolumeInstallFailure")( + function* (error) { + yield* recoverFailedInstall(error.message); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + step: error.step, + exitCode: error.exitCode, + cause: describeUpdateErrorCause(error.cause), + }); + return { accepted: true, completed: false, failed: true }; + }, + ), ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( function* (error) { yield* recoverFailedInstall(error.message); @@ -1058,7 +1126,7 @@ export const make = Effect.gen(function* () { const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* ( raw: unknown, ) { - yield* decodeUpdateInfo(raw).pipe( + yield* decodeUpdateDownloadedInfo(raw).pipe( Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); @@ -1076,6 +1144,13 @@ export const make = Effect.gen(function* () { } return; } + yield* Ref.set( + downloadedFileRef, + Option.map(Option.fromNullishOr(info.downloadedFile), (file) => ({ + version: info.version, + file, + })), + ); yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), diff --git a/apps/desktop/src/updates/MacAppVolume.test.ts b/apps/desktop/src/updates/MacAppVolume.test.ts new file mode 100644 index 0000000000000..7e3ef6725ed3e --- /dev/null +++ b/apps/desktop/src/updates/MacAppVolume.test.ts @@ -0,0 +1,116 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as MacAppVolume from "./MacAppVolume.ts"; + +// No process has this id, so the helper starts as if the app had already quit. +const EXITED_PID = "2147483647"; + +const runSwap = Effect.fn("test.runSwap")(function* (options: { + readonly stageNewApp: boolean; + readonly pid?: string; + readonly waits?: string; + readonly xattr?: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fileSystem.makeTempDirectoryScoped(); + const bin = path.join(root, "bin"); + const drive = path.join(root, "ExternalDrive"); + const target = path.join(drive, "T3 Code.app"); + const stage = path.join(drive, ".T3 Code.update"); + const staged = path.join(stage, "extracted", "T3 Code.app"); + const opened = path.join(root, "opened"); + + yield* fileSystem.makeDirectory(bin); + yield* fileSystem.writeFileString( + path.join(bin, "open"), + `#!/bin/sh\necho "$*" >> "${opened}"\n`, + ); + yield* fileSystem.writeFileString(path.join(bin, "xattr"), `#!/bin/sh\n${options.xattr ?? ""}`); + yield* fileSystem.chmod(path.join(bin, "open"), 0o755); + yield* fileSystem.chmod(path.join(bin, "xattr"), 0o755); + yield* fileSystem.makeDirectory(target, { recursive: true }); + yield* fileSystem.writeFileString(path.join(target, "version"), "1.2.3"); + yield* fileSystem.makeDirectory(path.dirname(staged), { recursive: true }); + if (options.stageNewApp) { + yield* fileSystem.makeDirectory(staged); + yield* fileSystem.writeFileString(path.join(staged, "version"), "1.2.4"); + } + + const exitCode = yield* spawner.exitCode( + ChildProcess.make( + "/bin/sh", + [ + "-c", + MacAppVolume.SWAP_SCRIPT, + "t3code-update", + options.pid ?? EXITED_PID, + target, + staged, + stage, + ...(options.waits === undefined ? [] : [options.waits]), + ], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + extendEnv: true, + env: { PATH: `${bin}:/usr/bin:/bin` }, + }, + ), + ); + return { + exitCode, + version: yield* fileSystem.readFileString(path.join(target, "version")), + stageLeft: yield* fileSystem.exists(stage), + opened: (yield* fileSystem.exists(opened)) ? yield* fileSystem.readFileString(opened) : "", + target, + }; +}); + +describe("MacAppVolume.SWAP_SCRIPT", () => { + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "swaps the staged app in, or puts the previous one back, then reopens it", + () => + Effect.gen(function* () { + const swapped = yield* runSwap({ stageNewApp: true }); + assert.equal(swapped.exitCode, 0); + assert.equal(swapped.version, "1.2.4"); + assert.isFalse(swapped.stageLeft); + assert.equal(swapped.opened, `-n ${swapped.target}\n`); + + const restored = yield* runSwap({ stageNewApp: false }); + assert.equal(restored.version, "1.2.3"); + assert.isFalse(restored.stageLeft); + assert.equal(restored.opened, `-n ${restored.target}\n`); + + // The app's exit hangs up and terminates the helper between the swap and the reopen. + const signalled = yield* runSwap({ + stageNewApp: true, + xattr: 'kill -HUP "$PPID"\nkill -TERM "$PPID"\n', + }); + assert.equal(signalled.exitCode, 0); + assert.equal(signalled.version, "1.2.4"); + assert.isFalse(signalled.stageLeft); + assert.equal(signalled.opened, `-n ${signalled.target}\n`); + + const stillRunning = yield* runSwap({ + stageNewApp: true, + pid: String(process.pid), + waits: "1", + }); + assert.notEqual(stillRunning.exitCode, 0); + assert.equal(stillRunning.version, "1.2.3"); + assert.isFalse(stillRunning.stageLeft); + assert.equal(stillRunning.opened, ""); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/updates/MacAppVolume.ts b/apps/desktop/src/updates/MacAppVolume.ts new file mode 100644 index 0000000000000..5b9a44ec2f71e --- /dev/null +++ b/apps/desktop/src/updates/MacAppVolume.ts @@ -0,0 +1,232 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +// Squirrel.Mac's ShipIt stages updates on the startup disk. Swapping an app on another volume is +// then a copy, and a ShipIt stopped partway leaves the app missing or half-written. +export type MacAppLocation = + | { readonly _tag: "ShipIt" } + | { readonly _tag: "Blocked"; readonly reason: string } + | { readonly _tag: "OwnVolume"; readonly bundlePath: string }; + +export const SHIP_IT: MacAppLocation = { _tag: "ShipIt" }; + +export class MacAppVolumeInstallError extends Schema.TaggedError()( + "MacAppVolumeInstallError", + { + step: Schema.Literals(["stage", "extract", "verify", "launch"]), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.step) { + case "stage": + return "Could not prepare the update next to T3 Code."; + case "extract": + return "Could not unpack the update next to T3 Code."; + case "verify": + return "The downloaded update is not signed like this copy of T3 Code, so it was not installed."; + case "launch": + return "Could not start the update installer."; + } + } +} + +export interface StagedMacUpdate { + readonly bundlePath: string; + readonly stageDirectory: string; + readonly stagedBundlePath: string; +} + +const blocked = (reason: string): MacAppLocation => ({ _tag: "Blocked", reason }); + +export const UNCHECKED: MacAppLocation = blocked( + "Automatic updates are off because T3 Code could not check the disk it runs from.", +); + +const quietly = { stdin: "ignore", stdout: "ignore", stderr: "ignore" } as const; + +// Arguments: app pid, app bundle, staged bundle, stage directory, and how many 0.2s waits the app +// gets to exit (default 300). No signal can cut the swap between its two moves, both renames on +// the app's volume. A failed second move puts the previous app back, and the stage is removed +// only once an app is in place again. +export const SWAP_SCRIPT = `trap '' HUP INT TERM QUIT PIPE +pid=$1 target=$2 staged=$3 stage=$4 waits=\${5:-300} +backup="$stage/previous.app" +while kill -0 "$pid" 2>/dev/null; do + if [ "$waits" -le 0 ]; then rm -rf "$stage"; exit 1; fi + waits=$((waits - 1)) + sleep 0.2 +done +if mv "$target" "$backup"; then + if mv "$staged" "$target"; then + xattr -r -d com.apple.quarantine "$target" 2>/dev/null + else + mv "$backup" "$target" + fi +fi +if [ -e "$target" ]; then + rm -rf "$stage" + open -n "$target" +fi +`; + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const removeStage = (stageDirectory: string) => + fileSystem.remove(stageDirectory, { recursive: true, force: true }).pipe(Effect.ignore); + + const resolveLocation = Effect.fn("MacAppVolume.resolveLocation")(function* ( + resourcesPath: string, + tempDirectory: string, + ) { + const bundlePath = /^(.+\.app)\/Contents\/Resources\/?$/.exec(resourcesPath)?.[1]; + if (bundlePath === undefined) return SHIP_IT; + if (bundlePath.includes("/AppTranslocation/")) { + return blocked( + "Automatic updates are off because macOS opened T3 Code from a temporary read-only copy. Move T3 Code to Applications with Finder, then open it from there.", + ); + } + const [bundle, temp] = yield* Effect.all([ + fileSystem.stat(bundlePath), + fileSystem.stat(tempDirectory), + ]); + // `/Volumes/Macintosh HD` links back to the startup disk. + if (bundle.dev === temp.dev) return SHIP_IT; + const writable = yield* Effect.all([ + fileSystem.access(path.dirname(bundlePath), { writable: true }), + fileSystem.access(bundlePath, { writable: true }), + ]).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (writable) return { _tag: "OwnVolume", bundlePath } satisfies MacAppLocation; + const volume = /^\/Volumes\/([^/]+)\//.exec(bundlePath)?.[1]; + return blocked( + `Automatic updates are off because T3 Code cannot replace itself on ${volume === undefined ? "the drive it runs from" : `"${volume}"`}, such as its disk image or a read-only drive. Drag T3 Code to Applications, then open it from there.`, + ); + }); + + /** Unpacks and verifies the update beside the app, so the swap is a rename on one volume. */ + const stage = Effect.fn("MacAppVolume.stage")(function* ( + bundlePath: string, + downloadedFile: string, + ) { + const stageDirectory = path.join( + path.dirname(bundlePath), + `.${path.basename(bundlePath, ".app")}.update`, + ); + const extractDirectory = path.join(stageDirectory, "extracted"); + const failAt = + (step: MacAppVolumeInstallError["step"]) => + (cause: unknown): MacAppVolumeInstallError => + new MacAppVolumeInstallError({ step, cause }); + + return yield* Effect.gen(function* () { + // A stage left by an interrupted install holds nothing the running app needs. + yield* fileSystem + .remove(stageDirectory, { recursive: true, force: true }) + .pipe( + Effect.andThen(fileSystem.makeDirectory(extractDirectory, { recursive: true })), + Effect.mapError(failAt("stage")), + ); + + // ditto keeps the bundle's symlinks, resource forks, and extended attributes, as ShipIt does. + const dittoExit = yield* spawner + .exitCode( + ChildProcess.make( + "/usr/bin/ditto", + ["-x", "-k", downloadedFile, extractDirectory], + quietly, + ), + ) + .pipe(Effect.mapError(failAt("extract"))); + if (dittoExit !== 0) { + return yield* new MacAppVolumeInstallError({ step: "extract", exitCode: dittoExit }); + } + const bundles = (yield* fileSystem + .readDirectory(extractDirectory) + .pipe(Effect.mapError(failAt("extract")))).filter((name) => name.endsWith(".app")); + const [bundleName] = bundles; + if (bundleName === undefined || bundles.length > 1) { + return yield* new MacAppVolumeInstallError({ step: "extract" }); + } + const stagedBundlePath = path.join(extractDirectory, bundleName); + + // ShipIt's own check: the update must satisfy the running app's designated requirement. + const display = yield* spawner + .string(ChildProcess.make("/usr/bin/codesign", ["-d", "-r-", bundlePath]), { + includeStderr: true, + }) + .pipe(Effect.mapError(failAt("verify"))); + const requirement = /designated => (.+)/.exec(display)?.[1]; + if (requirement === undefined) { + return yield* new MacAppVolumeInstallError({ step: "verify" }); + } + const verifyExit = yield* spawner + .exitCode( + ChildProcess.make( + "/usr/bin/codesign", + [ + "--verify", + "--deep", + "--strict", + "--test-requirement", + `=${requirement}`, + stagedBundlePath, + ], + quietly, + ), + ) + .pipe(Effect.mapError(failAt("verify"))); + if (verifyExit !== 0) { + return yield* new MacAppVolumeInstallError({ step: "verify", exitCode: verifyExit }); + } + return { bundlePath, stageDirectory, stagedBundlePath } satisfies StagedMacUpdate; + }).pipe(Effect.onError(() => removeStage(stageDirectory))); + }); + + /** Starts the helper that swaps in the staged app once this process exits, then reopens it. */ + const launchSwap = Effect.fn("MacAppVolume.launchSwap")(function* (staged: StagedMacUpdate) { + yield* Effect.scoped( + spawner + .spawn( + ChildProcess.make( + "/bin/sh", + [ + "-c", + SWAP_SCRIPT, + "t3code-update", + String(process.pid), + staged.bundlePath, + staged.stagedBundlePath, + staged.stageDirectory, + ], + { + ...quietly, + detached: true, + extendEnv: true, + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, + }, + ), + ) + // An unreferenced helper outlives this scope and the app. + .pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + ), + ).pipe( + Effect.mapError((cause) => new MacAppVolumeInstallError({ step: "launch", cause })), + Effect.onError(() => removeStage(staged.stageDirectory)), + ); + }); + + return { resolveLocation, stage, launchSwap }; +}); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 226f00f8cba89..a612577987637 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,15 +1,21 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ByteSize from "effect/ByteSize"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -19,6 +25,8 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; export const flushCallbacks = Effect.yieldNow; +export const TEMP_DIRECTORY = "/var/folders/xy/abc/T"; + export interface UpdatesHarnessOptions { readonly checkForUpdates?: Effect.Effect< void, @@ -35,6 +43,16 @@ export interface UpdatesHarnessOptions { readonly backendInstances?: ReadonlyArray; readonly env?: Record; readonly platform?: NodeJS.Platform; + readonly resourcesPath?: string; + /** Device numbers `stat` reports; other paths are missing. */ + readonly devices?: Readonly>; + /** Paths `access` refuses to write, as on a mounted disk image. */ + readonly readOnlyPaths?: ReadonlyArray; + /** Exit code and output of each helper command; unlisted ones succeed silently. */ + readonly runCommand?: ( + command: string, + args: ReadonlyArray, + ) => { readonly exitCode?: number; readonly output?: string } | undefined; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; readonly policy?: DesktopUpdatePolicy.UpdatePolicy; @@ -56,6 +74,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; const installSteps: string[] = []; + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + const removedPaths: string[] = []; const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { const eventListeners = listeners.get(eventName) ?? new Set(); @@ -117,6 +137,46 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ).pipe(Effect.asVoid), } satisfies ElectronUpdater.ElectronUpdater["Service"]); + const layerApp = Layer.mock(ElectronApp.ElectronApp, { + quit: Effect.sync(() => { + installSteps.push("quit"); + }), + }); + + const encoder = new TextEncoder(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + const { command: name, args } = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push({ command: name, args }); + installSteps.push(name); + const result = options.runCommand?.(name, args); + const output = Stream.make(encoder.encode(result?.output ?? "")); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + // The swap helper waits for the app to exit. + exitCode: + name === "/bin/sh" + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(result?.exitCode ?? 0)), + isRunning: Effect.succeed(name === "/bin/sh"), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: output, + stderr: Stream.empty, + all: output, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + const layerWindow = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), main: Effect.succeedNone, @@ -172,6 +232,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { : [stubBackendInstance]), ); + const resourcesPath = options.resourcesPath ?? "/missing/resources"; const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, @@ -180,7 +241,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, - resourcesPath: "/missing/resources", + resourcesPath, runningUnderArm64Translation: false, }).pipe( Layer.provide( @@ -241,23 +302,56 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); + const notFound = (method: string, path: string) => + PlatformError.systemError({ + module: "FileSystem", + method, + _tag: "NotFound", + pathOrDescriptor: path, + }); const layerFileSystem = FileSystem.layerNoop({ readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord) : undefined; if (installRecord !== undefined) return Effect.succeed(installRecord); - return path === "/missing/resources/package-type" && options.packageType !== undefined + return path === `${resourcesPath}/package-type` && options.packageType !== undefined ? Effect.succeed(options.packageType) - : Effect.fail( + : Effect.fail(notFound("readFileString", path)); + }, + stat: (path) => { + const dev = options.devices?.[path]; + return dev === undefined + ? Effect.fail(notFound("stat", path)) + : Effect.succeed({ + type: "Directory", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev, + ino: Option.none(), + mode: 0o755, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: ByteSize.bytes(0), + blksize: Option.none(), + blocks: Option.none(), + }); + }, + access: (path, accessOptions) => + accessOptions?.writable === true && options.readOnlyPaths?.includes(path) + ? Effect.fail( PlatformError.systemError({ module: "FileSystem", - method: "readFileString", - _tag: "NotFound", + method: "access", + _tag: "PermissionDenied", pathOrDescriptor: path, }), - ); - }, + ) + : Effect.void, + readDirectory: () => Effect.succeed(["T3 Code.app"]), makeDirectory: () => Effect.void, writeFileString: (path, data) => Effect.sync(() => { @@ -266,6 +360,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), remove: (path) => Effect.sync(() => { + removedPaths.push(path); updateRestartMarkers.delete(path); installRecords.delete(path); if (isInstallRecord(path)) previousInstallRecord = undefined; @@ -275,6 +370,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), Layer.provide(DesktopUpdatePolicy.layerTest(options.policy)), + Layer.provide(layerSpawner), + Layer.provideMerge(layerApp), Layer.provideMerge(layerUpdater), Layer.provideMerge(layerWindow), Layer.provideMerge(layerBackend), @@ -289,7 +386,13 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), ), Layer.provideMerge(layerEnvironment), - Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + Layer.provideMerge( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.succeed(HostProcess.TempDirectory, TEMP_DIRECTORY), + ), + ), ); return { @@ -297,6 +400,8 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount: () => checkCount, quitAndInstalls: () => quitAndInstallCount, installSteps, + commands, + removedPaths, updateRestartMarkers, installRecords, previousInstallRecord: () => previousInstallRecord, diff --git a/docs/user/install.md b/docs/user/install.md index fde265746004e..297d41221d233 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -79,6 +79,13 @@ The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +On macOS, drag T3 Code to Applications, or to another drive you can write to, +before opening it. Opened from the disk image, from a read-only drive, or from +the temporary copy macOS makes when you open it straight from Downloads, T3 Code +cannot replace itself. It turns automatic updates off there, and +**Settings → About** says why. Move it with Finder and open it from its new +place to turn updates back on. + ### Nix (nixpkgs) T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index 50f0a3ef85950..5cac419422386 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -44,6 +44,13 @@ export const Environment = Context.Reference( }, ); +export const TempDirectory = Context.Reference( + "@t3tools/shared/HostProcess/TempDirectory", + { + defaultValue: () => NodeOS.tmpdir(), + }, +); + export const WorkingDirectory = Context.Reference( "@t3tools/shared/HostProcess/WorkingDirectory", { From 83e573ed78523424b9ea6431e5b332d8b2a64ee7 Mon Sep 17 00:00:00 2001 From: atoz03 <31232741+atoz03@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:20:06 -0800 Subject: [PATCH 2/4] fix(desktop): put the app back when the drive drops out mid-swap If the external drive sleeps or unmounts after the helper moves the app to previous.app, both the install move and the rollback fail. The helper then exited 0 with no app at the bundle path, and the old app sat hidden in the stage. The helper now retries the install move, then the rollback, for up to an hour, on a budget separate from its wait for the app to quit. It keeps previous.app until an app opens at the bundle path, and puts it back if the new one will not open. If the helper still gives up, macOS can open the app from previous.app, because it follows a moved bundle. That copy's backends run from its own bundle, so it does not move itself. It starts a second helper that moves it back to the bundle path once it quits, and keeps automatic updates off until then. --- .../src/updates/DesktopUpdates.test.ts | 21 ++- apps/desktop/src/updates/MacAppVolume.test.ts | 175 ++++++++++++++---- apps/desktop/src/updates/MacAppVolume.ts | 108 ++++++----- 3 files changed, 220 insertions(+), 84 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 95ff27f7c133b..c796f0954f57b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -66,7 +66,7 @@ describe("DesktopUpdates", () => { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; const { checked } = yield* updates.check("manual"); - return { checked, state: yield* updates.getState }; + return { checked, state: yield* updates.getState, commands: harness.commands }; }), ).pipe(Effect.provide(harness.layer)); }; @@ -91,6 +91,25 @@ describe("DesktopUpdates", () => { assert.equal(translocated.state.status, "disabled"); assert.match(translocated.state.message ?? "", /temporary read-only copy/); + // macOS opened the app an interrupted swap moved aside. A helper moves it back once it quits. + const stage = "/Volumes/ExternalDrive/.T3 Code.update"; + const movedAside = yield* checkFrom(`${stage}/previous.app`, { device: 2n }); + assert.isFalse(movedAside.checked); + assert.match(movedAside.state.message ?? "", /an earlier update did not finish/); + assert.deepEqual(movedAside.commands, [ + { + command: "/bin/sh", + args: [ + "-c", + MacAppVolume.PUT_BACK_SCRIPT, + "t3code-update", + String(process.pid), + "/Volumes/ExternalDrive/T3 Code.app", + stage, + ], + }, + ]); + const unreadable = makeHarness({ resourcesPath: "/Volumes/PortableSSD/T3 Code.app/Contents/Resources", }); diff --git a/apps/desktop/src/updates/MacAppVolume.test.ts b/apps/desktop/src/updates/MacAppVolume.test.ts index 7e3ef6725ed3e..c0082e29d85a4 100644 --- a/apps/desktop/src/updates/MacAppVolume.test.ts +++ b/apps/desktop/src/updates/MacAppVolume.test.ts @@ -12,12 +12,22 @@ import * as MacAppVolume from "./MacAppVolume.ts"; // No process has this id, so the helper starts as if the app had already quit. const EXITED_PID = "2147483647"; -const runSwap = Effect.fn("test.runSwap")(function* (options: { - readonly stageNewApp: boolean; - readonly pid?: string; - readonly waits?: string; +interface DriveOptions { readonly xattr?: string; -}) { + /** Renames numbered first through last fail, as when the drive drops out mid-swap. */ + readonly failedMoves?: readonly [first: number, last: number]; + /** How many leading `open` calls fail. */ + readonly failedOpens?: number; +} + +/** A drive holding the app, with each bundle present when given a version. */ +const setUpDrive = Effect.fn("test.setUpDrive")(function* ( + options: DriveOptions & { + readonly target?: string; + readonly staged?: string; + readonly previous?: string; + }, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -27,55 +37,90 @@ const runSwap = Effect.fn("test.runSwap")(function* (options: { const target = path.join(drive, "T3 Code.app"); const stage = path.join(drive, ".T3 Code.update"); const staged = path.join(stage, "extracted", "T3 Code.app"); + const backup = path.join(stage, "previous.app"); const opened = path.join(root, "opened"); + const moves = path.join(root, "moves"); + const tool = (name: string, body: string) => + fileSystem + .writeFileString(path.join(bin, name), `#!/bin/sh\n${body}`) + .pipe(Effect.andThen(fileSystem.chmod(path.join(bin, name), 0o755))); yield* fileSystem.makeDirectory(bin); - yield* fileSystem.writeFileString( - path.join(bin, "open"), - `#!/bin/sh\necho "$*" >> "${opened}"\n`, + yield* tool( + "open", + `echo "$*" >> "${opened}"\n[ "$(wc -l < "${opened}")" -gt ${options.failedOpens ?? 0} ]\n`, ); - yield* fileSystem.writeFileString(path.join(bin, "xattr"), `#!/bin/sh\n${options.xattr ?? ""}`); - yield* fileSystem.chmod(path.join(bin, "open"), 0o755); - yield* fileSystem.chmod(path.join(bin, "xattr"), 0o755); - yield* fileSystem.makeDirectory(target, { recursive: true }); - yield* fileSystem.writeFileString(path.join(target, "version"), "1.2.3"); - yield* fileSystem.makeDirectory(path.dirname(staged), { recursive: true }); - if (options.stageNewApp) { - yield* fileSystem.makeDirectory(staged); - yield* fileSystem.writeFileString(path.join(staged, "version"), "1.2.4"); + yield* tool("xattr", options.xattr ?? ""); + if (options.failedMoves !== undefined) { + const [first, last] = options.failedMoves; + yield* tool( + "mv", + `n=$(($(cat "${moves}" 2>/dev/null || echo 0) + 1))\necho "$n" > "${moves}"\n[ "$n" -ge ${first} ] && [ "$n" -le ${last} ] && exit 1\nexec /bin/mv "$@"\n`, + ); + } + for (const [bundle, version] of [ + [target, options.target], + [staged, options.staged], + [backup, options.previous], + ] as const) { + yield* fileSystem.makeDirectory(path.dirname(bundle), { recursive: true }); + if (version === undefined) continue; + yield* fileSystem.makeDirectory(bundle); + yield* fileSystem.writeFileString(path.join(bundle, "version"), version); } - const exitCode = yield* spawner.exitCode( - ChildProcess.make( - "/bin/sh", - [ - "-c", - MacAppVolume.SWAP_SCRIPT, - "t3code-update", - options.pid ?? EXITED_PID, - target, - staged, - stage, - ...(options.waits === undefined ? [] : [options.waits]), - ], - { + const runHelper = (script: string, args: ReadonlyArray) => + spawner.exitCode( + ChildProcess.make("/bin/sh", ["-c", script, "t3code-update", ...args], { stdin: "ignore", stdout: "ignore", stderr: "ignore", extendEnv: true, env: { PATH: `${bin}:/usr/bin:/bin` }, - }, - ), - ); + }), + ); + const read = (file: string) => + fileSystem + .exists(file) + .pipe( + Effect.flatMap((exists) => (exists ? fileSystem.readFileString(file) : Effect.succeed(""))), + ); + return { - exitCode, - version: yield* fileSystem.readFileString(path.join(target, "version")), - stageLeft: yield* fileSystem.exists(stage), - opened: (yield* fileSystem.exists(opened)) ? yield* fileSystem.readFileString(opened) : "", target, + swap: (pid: string, budgets: ReadonlyArray) => + runHelper(MacAppVolume.SWAP_SCRIPT, [pid, target, staged, stage, ...budgets]), + putBack: runHelper(MacAppVolume.PUT_BACK_SCRIPT, [EXITED_PID, target, stage]), + driveReturns: fileSystem.remove(path.join(bin, "mv"), { force: true }), + state: Effect.all({ + version: read(path.join(target, "version")), + backupVersion: read(path.join(backup, "version")), + stageLeft: fileSystem.exists(stage), + opened: read(opened), + }), }; }); +const runSwap = Effect.fn("test.runSwap")(function* ({ + stageNewApp, + pid = EXITED_PID, + budgets = [], + ...options +}: DriveOptions & { + readonly stageNewApp: boolean; + readonly pid?: string; + /** How many 0.2s waits the app gets to exit, then how many retries the swap gets. */ + readonly budgets?: ReadonlyArray; +}) { + const drive = yield* setUpDrive({ + ...options, + target: "1.2.3", + ...(stageNewApp ? { staged: "1.2.4" } : {}), + }); + const exitCode = yield* drive.swap(pid, budgets); + return { exitCode, target: drive.target, ...(yield* drive.state) }; +}); + describe("MacAppVolume.SWAP_SCRIPT", () => { it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( "swaps the staged app in, or puts the previous one back, then reopens it", @@ -92,6 +137,19 @@ describe("MacAppVolume.SWAP_SCRIPT", () => { assert.isFalse(restored.stageLeft); assert.equal(restored.opened, `-n ${restored.target}\n`); + // The app cannot be moved aside, so the helper fails and keeps the staged update. + const unmoved = yield* runSwap({ stageNewApp: true, failedMoves: [1, 1] }); + assert.notEqual(unmoved.exitCode, 0); + assert.equal(unmoved.version, "1.2.3"); + assert.isTrue(unmoved.stageLeft); + assert.equal(unmoved.opened, ""); + + const refused = yield* runSwap({ stageNewApp: true, failedOpens: 1 }); + assert.equal(refused.exitCode, 0); + assert.equal(refused.version, "1.2.3"); + assert.isFalse(refused.stageLeft); + assert.equal(refused.opened, `-n ${refused.target}\n-n ${refused.target}\n`); + // The app's exit hangs up and terminates the helper between the swap and the reopen. const signalled = yield* runSwap({ stageNewApp: true, @@ -105,7 +163,7 @@ describe("MacAppVolume.SWAP_SCRIPT", () => { const stillRunning = yield* runSwap({ stageNewApp: true, pid: String(process.pid), - waits: "1", + budgets: ["1"], }); assert.notEqual(stillRunning.exitCode, 0); assert.equal(stillRunning.version, "1.2.3"); @@ -113,4 +171,43 @@ describe("MacAppVolume.SWAP_SCRIPT", () => { assert.equal(stillRunning.opened, ""); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "retries a swap the drive drops out of, and puts the app back once it quits from the stage", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const returned = yield* runSwap({ + stageNewApp: true, + budgets: ["300", "3"], + failedMoves: [2, 3], + }); + assert.equal(returned.exitCode, 0); + assert.equal(returned.version, "1.2.4"); + assert.isFalse(returned.stageLeft); + assert.equal(returned.opened, `-n ${returned.target}\n`); + + const drive = yield* setUpDrive({ target: "1.2.3", staged: "1.2.4", failedMoves: [2, 99] }); + assert.notEqual(yield* drive.swap(EXITED_PID, ["300", "3"]), 0); + const gone = yield* drive.state; + assert.equal(gone.version, ""); + assert.equal(gone.backupVersion, "1.2.3"); + assert.equal(gone.opened, ""); + + yield* drive.driveReturns; + assert.equal(yield* drive.putBack, 0); + const putBack = yield* drive.state; + assert.equal(putBack.version, "1.2.3"); + assert.isFalse(putBack.stageLeft); + assert.equal(putBack.opened, ""); + + // A copy at the bundle path stays, and the one moved aside goes with the stage. + const beside = yield* setUpDrive({ target: "1.2.4", previous: "1.2.3" }); + assert.equal(yield* beside.putBack, 0); + const besideState = yield* beside.state; + assert.equal(besideState.version, "1.2.4"); + assert.isFalse(besideState.stageLeft); + assert.deepEqual(yield* fileSystem.readDirectory(beside.target), ["version"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/desktop/src/updates/MacAppVolume.ts b/apps/desktop/src/updates/MacAppVolume.ts index 5b9a44ec2f71e..7398fbfb31008 100644 --- a/apps/desktop/src/updates/MacAppVolume.ts +++ b/apps/desktop/src/updates/MacAppVolume.ts @@ -50,29 +50,37 @@ export const UNCHECKED: MacAppLocation = blocked( const quietly = { stdin: "ignore", stdout: "ignore", stderr: "ignore" } as const; -// Arguments: app pid, app bundle, staged bundle, stage directory, and how many 0.2s waits the app -// gets to exit (default 300). No signal can cut the swap between its two moves, both renames on -// the app's volume. A failed second move puts the previous app back, and the stage is removed -// only once an app is in place again. +// Args: app pid, app, staged app, stage, then how many 0.2s waits the app gets to exit (300) and +// how many 0.2s retries the swap gets once the app is moved aside (18000). A drive that drops out +// between the renames fails both, so they retry until it returns. previous.app stays until an app +// opens at the bundle path, and goes back there if the new one will not open. export const SWAP_SCRIPT = `trap '' HUP INT TERM QUIT PIPE -pid=$1 target=$2 staged=$3 stage=$4 waits=\${5:-300} +pid=$1 target=$2 staged=$3 stage=$4 waits=\${5:-300} retries=\${6:-18000} backup="$stage/previous.app" while kill -0 "$pid" 2>/dev/null; do if [ "$waits" -le 0 ]; then rm -rf "$stage"; exit 1; fi waits=$((waits - 1)) sleep 0.2 done -if mv "$target" "$backup"; then - if mv "$staged" "$target"; then - xattr -r -d com.apple.quarantine "$target" 2>/dev/null - else - mv "$backup" "$target" - fi -fi -if [ -e "$target" ]; then - rm -rf "$stage" - open -n "$target" -fi +mv "$target" "$backup" || exit 1 +until [ -e "$target" ] || mv "$staged" "$target" || mv "$backup" "$target"; do + if [ "$retries" -le 0 ]; then exit 1; fi + retries=$((retries - 1)) + sleep 0.2 +done +xattr -r -d com.apple.quarantine "$target" 2>/dev/null +open -n "$target" || + { [ -e "$backup" ] && rm -rf "$target" && mv "$backup" "$target" && open -n "$target"; } || + exit 1 +rm -rf "$stage" +`; + +// Args: app pid, app, stage. Once the copy running from the stage's previous.app quits, moves it +// back to the bundle path unless an app is already there, then removes the stage. +export const PUT_BACK_SCRIPT = `trap '' HUP INT TERM QUIT PIPE +while kill -0 "$1" 2>/dev/null; do sleep 1; done +[ -e "$2" ] || mv "$3/previous.app" "$2" +[ -e "$2" ] && rm -rf "$3" `; export const make = Effect.gen(function* () { @@ -83,6 +91,28 @@ export const make = Effect.gen(function* () { const removeStage = (stageDirectory: string) => fileSystem.remove(stageDirectory, { recursive: true, force: true }).pipe(Effect.ignore); + const launchHelper = (script: string, args: ReadonlyArray) => + Effect.scoped( + spawner + .spawn( + ChildProcess.make( + "/bin/sh", + ["-c", script, "t3code-update", String(process.pid), ...args], + { + ...quietly, + detached: true, + extendEnv: true, + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, + }, + ), + ) + // An unreferenced helper outlives this scope and the app. + .pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + ), + ); + const resolveLocation = Effect.fn("MacAppVolume.resolveLocation")(function* ( resourcesPath: string, tempDirectory: string, @@ -94,6 +124,19 @@ export const make = Effect.gen(function* () { "Automatic updates are off because macOS opened T3 Code from a temporary read-only copy. Move T3 Code to Applications with Finder, then open it from there.", ); } + // macOS follows an app the swap moved aside, so an interrupted swap's previous.app can be the + // copy that opens. Its backends run from this bundle, so it moves back only after it quits. + const stageDirectory = path.dirname(bundlePath); + const appName = /^\.(.+)\.update$/.exec(path.basename(stageDirectory))?.[1]; + if (path.basename(bundlePath) === "previous.app" && appName !== undefined) { + yield* launchHelper(PUT_BACK_SCRIPT, [ + path.join(path.dirname(stageDirectory), `${appName}.app`), + stageDirectory, + ]).pipe(Effect.ignore({ log: true })); + return blocked( + "Automatic updates are off because an earlier update did not finish. Quit and reopen T3 Code to turn them back on.", + ); + } const [bundle, temp] = yield* Effect.all([ fileSystem.stat(bundlePath), fileSystem.stat(tempDirectory), @@ -195,34 +238,11 @@ export const make = Effect.gen(function* () { /** Starts the helper that swaps in the staged app once this process exits, then reopens it. */ const launchSwap = Effect.fn("MacAppVolume.launchSwap")(function* (staged: StagedMacUpdate) { - yield* Effect.scoped( - spawner - .spawn( - ChildProcess.make( - "/bin/sh", - [ - "-c", - SWAP_SCRIPT, - "t3code-update", - String(process.pid), - staged.bundlePath, - staged.stagedBundlePath, - staged.stageDirectory, - ], - { - ...quietly, - detached: true, - extendEnv: true, - env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, - }, - ), - ) - // An unreferenced helper outlives this scope and the app. - .pipe( - Effect.flatMap((handle) => handle.unref), - Effect.asVoid, - ), - ).pipe( + yield* launchHelper(SWAP_SCRIPT, [ + staged.bundlePath, + staged.stagedBundlePath, + staged.stageDirectory, + ]).pipe( Effect.mapError((cause) => new MacAppVolumeInstallError({ step: "launch", cause })), Effect.onError(() => removeStage(staged.stageDirectory)), ); From ddb9f40a393846c0fdfdb66a52af736fb0a21303 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:57:07 +0000 Subject: [PATCH 3/4] fix(desktop): reopen the app when the first swap rename fails --- apps/desktop/src/updates/MacAppVolume.test.ts | 4 ++-- apps/desktop/src/updates/MacAppVolume.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/updates/MacAppVolume.test.ts b/apps/desktop/src/updates/MacAppVolume.test.ts index c0082e29d85a4..dd35f1091ffc6 100644 --- a/apps/desktop/src/updates/MacAppVolume.test.ts +++ b/apps/desktop/src/updates/MacAppVolume.test.ts @@ -137,12 +137,12 @@ describe("MacAppVolume.SWAP_SCRIPT", () => { assert.isFalse(restored.stageLeft); assert.equal(restored.opened, `-n ${restored.target}\n`); - // The app cannot be moved aside, so the helper fails and keeps the staged update. + // The app cannot be moved aside, so the helper reopens it and keeps the stage. const unmoved = yield* runSwap({ stageNewApp: true, failedMoves: [1, 1] }); assert.notEqual(unmoved.exitCode, 0); assert.equal(unmoved.version, "1.2.3"); assert.isTrue(unmoved.stageLeft); - assert.equal(unmoved.opened, ""); + assert.equal(unmoved.opened, `-n ${unmoved.target}\n`); const refused = yield* runSwap({ stageNewApp: true, failedOpens: 1 }); assert.equal(refused.exitCode, 0); diff --git a/apps/desktop/src/updates/MacAppVolume.ts b/apps/desktop/src/updates/MacAppVolume.ts index 7398fbfb31008..9e001b4a325c9 100644 --- a/apps/desktop/src/updates/MacAppVolume.ts +++ b/apps/desktop/src/updates/MacAppVolume.ts @@ -62,7 +62,7 @@ while kill -0 "$pid" 2>/dev/null; do waits=$((waits - 1)) sleep 0.2 done -mv "$target" "$backup" || exit 1 +mv "$target" "$backup" || { open -n "$target"; exit 1; } until [ -e "$target" ] || mv "$staged" "$target" || mv "$backup" "$target"; do if [ "$retries" -le 0 ]; then exit 1; fi retries=$((retries - 1)) From 87fa2837181f7571202e666a897cc90934bcc3dc Mon Sep 17 00:00:00 2001 From: atoz03 <31232741+atoz03@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:19:25 -0800 Subject: [PATCH 4/4] fix(desktop): retain staged update and exit non-zero on swap rollback --- apps/desktop/src/updates/MacAppVolume.test.ts | 13 ++++++++++++- apps/desktop/src/updates/MacAppVolume.ts | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/updates/MacAppVolume.test.ts b/apps/desktop/src/updates/MacAppVolume.test.ts index dd35f1091ffc6..c84589be275e2 100644 --- a/apps/desktop/src/updates/MacAppVolume.test.ts +++ b/apps/desktop/src/updates/MacAppVolume.test.ts @@ -94,6 +94,7 @@ const setUpDrive = Effect.fn("test.setUpDrive")(function* ( driveReturns: fileSystem.remove(path.join(bin, "mv"), { force: true }), state: Effect.all({ version: read(path.join(target, "version")), + stagedVersion: read(path.join(staged, "version")), backupVersion: read(path.join(backup, "version")), stageLeft: fileSystem.exists(stage), opened: read(opened), @@ -133,14 +134,24 @@ describe("MacAppVolume.SWAP_SCRIPT", () => { assert.equal(swapped.opened, `-n ${swapped.target}\n`); const restored = yield* runSwap({ stageNewApp: false }); + assert.notEqual(restored.exitCode, 0); assert.equal(restored.version, "1.2.3"); - assert.isFalse(restored.stageLeft); + assert.isTrue(restored.stageLeft); assert.equal(restored.opened, `-n ${restored.target}\n`); + // A failed install move puts the previous app back, reopens it, and keeps the staged update. + const rolledBack = yield* runSwap({ stageNewApp: true, failedMoves: [2, 2] }); + assert.notEqual(rolledBack.exitCode, 0); + assert.equal(rolledBack.version, "1.2.3"); + assert.equal(rolledBack.stagedVersion, "1.2.4"); + assert.isTrue(rolledBack.stageLeft); + assert.equal(rolledBack.opened, `-n ${rolledBack.target}\n`); + // The app cannot be moved aside, so the helper reopens it and keeps the stage. const unmoved = yield* runSwap({ stageNewApp: true, failedMoves: [1, 1] }); assert.notEqual(unmoved.exitCode, 0); assert.equal(unmoved.version, "1.2.3"); + assert.equal(unmoved.stagedVersion, "1.2.4"); assert.isTrue(unmoved.stageLeft); assert.equal(unmoved.opened, `-n ${unmoved.target}\n`); diff --git a/apps/desktop/src/updates/MacAppVolume.ts b/apps/desktop/src/updates/MacAppVolume.ts index 9e001b4a325c9..3c711cb4bbdf9 100644 --- a/apps/desktop/src/updates/MacAppVolume.ts +++ b/apps/desktop/src/updates/MacAppVolume.ts @@ -63,7 +63,8 @@ while kill -0 "$pid" 2>/dev/null; do sleep 0.2 done mv "$target" "$backup" || { open -n "$target"; exit 1; } -until [ -e "$target" ] || mv "$staged" "$target" || mv "$backup" "$target"; do +until mv "$staged" "$target"; do + if mv "$backup" "$target"; then open -n "$target"; exit 1; fi if [ "$retries" -le 0 ]; then exit 1; fi retries=$((retries - 1)) sleep 0.2