diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..e675e4ae6bbe8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -42,6 +42,33 @@ describe("DesktopUpdates", () => { }), ); + it.effect("turns updates off only when the macOS app runs from a read-only volume", () => + Effect.gen(function* () { + const darwinUpdates = (bundleWriteError: "EROFS" | "EACCES") => { + const harness = makeHarness({ platform: "darwin", bundleWriteError }); + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* TestClock.adjust(Duration.minutes(1)); + yield* flushCallbacks; + return { state: yield* updates.getState, checks: harness.checkCount() }; + }), + ).pipe(Effect.provide(harness.layer)); + }; + + const readOnly = yield* darwinUpdates("EROFS"); + assert.equal(readOnly.state.status, "disabled"); + assert.isString(readOnly.state.message); + assert.equal(readOnly.checks, 0); + + // ShipIt can ask for authorization over a bundle the user cannot write, so that still updates. + const notWritable = yield* darwinUpdates("EACCES"); + assert.notEqual(notWritable.state.status, "disabled"); + assert.equal(notWritable.checks, 1); + }), + ); + 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..09a5c68007f23 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -335,6 +335,7 @@ function getAutoUpdateDisabledReason(args: { platform: NodeJS.Platform; appImage?: string | undefined; isDebPackage: boolean; + isReadOnlyMacBundle: boolean; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -353,6 +354,9 @@ function getAutoUpdateDisabledReason(args: { if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { return "Automatic updates on Linux require the AppImage or the .deb package."; } + if (args.isReadOnlyMacBundle) { + return "Automatic updates are off because T3 Code is running from a read-only location, such as a disk image. Move T3 Code to the Applications folder and open it from there."; + } return null; } @@ -451,6 +455,27 @@ export const make = Effect.gen(function* () { ) : false; + // Squirrel.Mac refuses to install over a bundle on a read-only volume (a disk image or App + // Translocation), and only says so after the download, inside quitAndInstall. + const isReadOnlyMacBundle = + environment.platform === "darwin" && environment.isPackaged + ? yield* fileSystem + .access(environment.path.resolve(environment.resourcesPath, "..", ".."), { + writable: true, + }) + .pipe( + Effect.as(false), + Effect.catch(({ reason: { cause } }) => + Effect.succeed( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EROFS", + ), + ), + ) + : false; + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( Effect.map( (appUpdateYmlConfig) => @@ -468,6 +493,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), isDebPackage, + isReadOnlyMacBundle, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 226f00f8cba89..38b4e1ff1b6aa 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -36,6 +36,8 @@ export interface UpdatesHarnessOptions { readonly env?: Record; readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; + /** Fails write checks on the app bundle: EROFS on a disk image or under App Translocation. */ + readonly bundleWriteError?: "EROFS" | "EACCES"; readonly installRecord?: string | undefined; readonly policy?: DesktopUpdatePolicy.UpdatePolicy; readonly appVersion?: string; @@ -242,6 +244,26 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); const layerFileSystem = FileSystem.layerNoop({ + // The harness bundle sits at "/", so every write check is a check on the bundle's volume. + access: (path, accessOptions) => { + const code = options.bundleWriteError; + return code && accessOptions?.writable + ? Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "access", + _tag: code === "EACCES" ? "PermissionDenied" : "Unknown", + pathOrDescriptor: path, + syscall: "access", + cause: Object.assign(new Error(`${code}: access '${path}'`), { + code, + syscall: "access", + path, + }), + }), + ) + : Effect.void; + }, readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord)