diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..c796f0954f57b 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,240 @@ 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, commands: harness.commands }; + }), + ).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/); + + // 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", + }); + 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 14688e5258067..9bbcafa30f41d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -31,13 +31,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, @@ -75,6 +78,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, }); @@ -87,6 +96,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)); @@ -336,6 +346,7 @@ function getAutoUpdateDisabledReason(args: { platform: NodeJS.Platform; appImage?: string | undefined; isDebPackage: boolean; + macAppLocation: MacAppVolume.MacAppLocation; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -354,7 +365,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 { @@ -366,12 +377,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; @@ -384,6 +397,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. @@ -452,6 +468,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) => @@ -469,6 +500,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), isDebPackage, + macAppLocation, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), @@ -756,6 +788,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* () { @@ -811,6 +857,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. @@ -824,13 +875,30 @@ export const make = Effect.gen(function* () { instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { 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); @@ -1060,7 +1128,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); @@ -1078,6 +1146,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..c84589be275e2 --- /dev/null +++ b/apps/desktop/src/updates/MacAppVolume.test.ts @@ -0,0 +1,224 @@ +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"; + +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; + 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 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* tool( + "open", + `echo "$*" >> "${opened}"\n[ "$(wc -l < "${opened}")" -gt ${options.failedOpens ?? 0} ]\n`, + ); + 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 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 { + 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")), + stagedVersion: read(path.join(staged, "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", + () => + 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.notEqual(restored.exitCode, 0); + assert.equal(restored.version, "1.2.3"); + 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`); + + 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, + 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), + budgets: ["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)), + ); + + 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 new file mode 100644 index 0000000000000..3c711cb4bbdf9 --- /dev/null +++ b/apps/desktop/src/updates/MacAppVolume.ts @@ -0,0 +1,253 @@ +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; + +// 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} 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 +mv "$target" "$backup" || { open -n "$target"; exit 1; } +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 +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* () { + 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 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, + ) { + 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.", + ); + } + // 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), + ]); + // `/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* launchHelper(SWAP_SCRIPT, [ + staged.bundlePath, + staged.stagedBundlePath, + staged.stageDirectory, + ]).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 1b892851dd39a..4bf2a8ede83f7 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -98,6 +98,13 @@ The AUR packages currently support x86_64 only; on ARM64, use the AppImage. Update AUR installs with your AUR helper, such as `yay -Syu`, instead of the in-app updater. +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 e5e2f8e97f108..ca01e53d5be31 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -60,6 +60,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", {