From e3262a83e6a359b29893ba3bbe011c54afdd12da Mon Sep 17 00:00:00 2001 From: jkramer5103 Date: Wed, 30 Sep 2026 15:03:52 +0200 Subject: [PATCH 1/4] fix(desktop): preserve Linux device scale across updates --- .../app/DesktopEarlyElectronStartup.test.ts | 80 +++++++++++++++++++ .../src/app/DesktopEarlyElectronStartup.ts | 40 ++++++++++ .../src/app/DesktopPreReadyPlatform.test.ts | 17 +++- .../src/app/DesktopPreReadyPlatform.ts | 11 +++ 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 01d1aa1b115e..08392b127002 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -3,6 +3,7 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import { + restoreEarlyLinuxDeviceScaleFactor, resolveEarlyLinuxElectronOptions, resolveEarlyLinuxPasswordStorePreference, resolveLinuxDisableFeaturesSwitch, @@ -269,3 +270,82 @@ describe("DesktopEarlyElectronStartup", () => { }); }); }); + +describe("Linux device scale across update relaunches", () => { + const input = { + env: { T3CODE_HOME: "/isolated" }, + homeDirectory: "/home/user", + joinPath: NodePath.posix.join, + }; + + it("restores the exact explicit scale on a subsequent launch without arguments", () => { + let saved = ""; + const applied: Array<[string, string]> = []; + const launch = (explicit: string | null) => + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => explicit !== null, + getSwitchValue: () => explicit ?? "", + appendSwitch: (name, value) => applied.push([name, value]), + }, + readFileString: () => saved, + writeFileString: (path, value) => { + assert.equal(path, "/isolated/userdata/linux-device-scale-factor"); + saved = value; + }, + }); + launch("1.75"); + assert.deepEqual(applied, []); + launch(null); + assert.deepEqual(applied, [["force-device-scale-factor", "1.75"]]); + launch("1"); + launch(null); + assert.deepEqual(applied.at(-1), ["force-device-scale-factor", "1"]); + }); + + for (const value of ["", "garbage", "0", "-1", "Infinity", "NaN"]) { + it(`ignores invalid persisted scale ${JSON.stringify(value)}`, () => { + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: () => assert.fail("invalid scale applied"), + }, + readFileString: () => value, + writeFileString: () => assert.fail("unexpected write"), + }); + }); + } + + it("preserves explicit switches even when persistence fails", () => { + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => true, + getSwitchValue: () => "2", + appendSwitch: () => assert.fail("explicit scale overridden"), + }, + readFileString: () => assert.fail("explicit scale must take precedence"), + writeFileString: () => { + throw new Error("read-only"); + }, + }); + }); + + it("keeps default scaling when state is missing", () => { + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: () => assert.fail("default scale overridden"), + }, + readFileString: () => { + throw new Error("missing"); + }, + writeFileString: () => assert.fail("unexpected write"), + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index ad2947013771..0015e642672c 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -159,3 +159,43 @@ export function resolveEarlyLinuxElectronOptions( }), }; } + +/** Remember the explicit Chromium scale across AppImage updates, which drop launch arguments. */ +export function restoreEarlyLinuxDeviceScaleFactor( + input: EarlyDesktopSettingsInput & { + readonly commandLine: { + readonly hasSwitch: (name: string) => boolean; + readonly getSwitchValue: (name: string) => string; + readonly appendSwitch: (name: string, value: string) => void; + }; + readonly writeFileString: (path: string, value: string) => void; + }, +): void { + // Keep this separate from desktop-settings.json: the settings service rewrites + // that document, and Chromium needs the value before that service is available. + const path = input.joinPath( + resolveEarlyDesktopSettingsPath(input), + "..", + "linux-device-scale-factor", + ); + const switchName = "force-device-scale-factor"; + const isValid = (value: string) => + value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) > 0; + if (input.commandLine.hasSwitch(switchName)) { + const value = input.commandLine.getSwitchValue(switchName); + if (isValid(value)) { + try { + input.writeFileString(path, value); + } catch { + // A read-only state directory must not prevent startup with the explicit switch. + } + } + return; + } + try { + const value = input.readFileString(path).trim(); + if (isValid(value)) input.commandLine.appendSwitch(switchName, value); + } catch { + // First launch, missing state, or unreadable state: keep Chromium's default. + } +} diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index dd15f879fd8e..07c103e5fdb9 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -15,6 +15,7 @@ const { writeFileSyncMock, copyFileSyncMock, execFileSyncMock, + readFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -25,6 +26,7 @@ const { writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -45,7 +47,7 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ - readFileSync: () => "{}", + readFileSync: readFileSyncMock, mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, copyFileSync: copyFileSyncMock, @@ -68,6 +70,19 @@ describe("DesktopPreReadyPlatform", () => { writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); + readFileSyncMock.mockReset().mockReturnValue("{}"); + }); + + it.effect("restores saved scaling before asynchronous startup can observe it", () => { + readFileSyncMock.mockImplementation((path: string) => + path.endsWith("linux-device-scale-factor") ? "1.75" : "{}", + ); + return Effect.gen(function* () { + const startup = Promise.resolve().then(() => appendSwitchMock.mock.calls.slice()); + yield* DesktopPreReadyPlatform.make; + const switches = yield* Effect.promise(() => startup); + assert.deepInclude(switches, ["force-device-scale-factor", "1.75"]); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")); }); it.effect("preserves an explicit Linux password-store switch", () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index a90246c73b3b..58670439a525 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -62,6 +62,17 @@ export const make = Effect.gen(function* () { platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory) : null; if (linux !== null) { + DesktopEarlyElectronStartup.restoreEarlyLinuxDeviceScaleFactor({ + env: process.env, + homeDirectory, + joinPath: NodePath.posix.join, + commandLine: Electron.app.commandLine, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + writeFileString: (path, value) => { + NodeFS.mkdirSync(NodePath.posix.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, value, "utf8"); + }, + }); // The portal also requires a valid desktop entry. An AppImage update may // have removed the executable referenced by the previous launch's entry. try { From 6275a9b79c4db70732471727acc34d83a7dd0e1c Mon Sep 17 00:00:00 2001 From: jkramer5103 Date: Wed, 30 Sep 2026 15:10:32 +0200 Subject: [PATCH 2/4] test(desktop): assert invalid scale calls outside startup catch --- apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 08392b127002..3aa59941c410 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -306,16 +306,18 @@ describe("Linux device scale across update relaunches", () => { for (const value of ["", "garbage", "0", "-1", "Infinity", "NaN"]) { it(`ignores invalid persisted scale ${JSON.stringify(value)}`, () => { + const applied: Array<[string, string]> = []; restoreEarlyLinuxDeviceScaleFactor({ ...input, commandLine: { hasSwitch: () => false, getSwitchValue: () => "", - appendSwitch: () => assert.fail("invalid scale applied"), + appendSwitch: (name, scale) => applied.push([name, scale]), }, readFileString: () => value, writeFileString: () => assert.fail("unexpected write"), }); + assert.deepEqual(applied, []); }); } From af3a5baa759463a08cd1ac53c4c4305fd80b03f0 Mon Sep 17 00:00:00 2001 From: Jaron Kramer Date: Sat, 17 Oct 2026 09:23:07 +0200 Subject: [PATCH 3/4] test(desktop): use table cases for invalid scales --- apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 3aa59941c410..e90288166a27 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -304,8 +304,9 @@ describe("Linux device scale across update relaunches", () => { assert.deepEqual(applied.at(-1), ["force-device-scale-factor", "1"]); }); - for (const value of ["", "garbage", "0", "-1", "Infinity", "NaN"]) { - it(`ignores invalid persisted scale ${JSON.stringify(value)}`, () => { + it.each(["", "garbage", "0", "-1", "Infinity", "NaN"])( + "ignores invalid persisted scale %j", + (value) => { const applied: Array<[string, string]> = []; restoreEarlyLinuxDeviceScaleFactor({ ...input, @@ -318,8 +319,8 @@ describe("Linux device scale across update relaunches", () => { writeFileString: () => assert.fail("unexpected write"), }); assert.deepEqual(applied, []); - }); - } + }, + ); it("preserves explicit switches even when persistence fails", () => { restoreEarlyLinuxDeviceScaleFactor({ From a84c1eca0581adeca44ac3639634fb677cb0c266 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:13:28 +0000 Subject: [PATCH 4/4] test(desktop): verify scale persistence across early startup --- .../app/DesktopEarlyElectronStartup.test.ts | 95 ++++++++----------- .../src/app/DesktopPreReadyPlatform.test.ts | 61 ++++++++++-- 2 files changed, 94 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index e90288166a27..6f72aa1f509d 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -271,84 +271,69 @@ describe("DesktopEarlyElectronStartup", () => { }); }); -describe("Linux device scale across update relaunches", () => { +describe("Linux device scale persistence failures", () => { const input = { env: { T3CODE_HOME: "/isolated" }, homeDirectory: "/home/user", joinPath: NodePath.posix.join, }; - it("restores the exact explicit scale on a subsequent launch without arguments", () => { - let saved = ""; + it.each(["0", "Infinity", "garbage"])("ignores invalid persisted scale %j", (value) => { const applied: Array<[string, string]> = []; - const launch = (explicit: string | null) => - restoreEarlyLinuxDeviceScaleFactor({ - ...input, - commandLine: { - hasSwitch: () => explicit !== null, - getSwitchValue: () => explicit ?? "", - appendSwitch: (name, value) => applied.push([name, value]), - }, - readFileString: () => saved, - writeFileString: (path, value) => { - assert.equal(path, "/isolated/userdata/linux-device-scale-factor"); - saved = value; - }, - }); - launch("1.75"); + const writes: Array = []; + restoreEarlyLinuxDeviceScaleFactor({ + ...input, + commandLine: { + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: (name, scale) => applied.push([name, scale]), + }, + readFileString: () => value, + writeFileString: (_path, contents) => writes.push(contents), + }); assert.deepEqual(applied, []); - launch(null); - assert.deepEqual(applied, [["force-device-scale-factor", "1.75"]]); - launch("1"); - launch(null); - assert.deepEqual(applied.at(-1), ["force-device-scale-factor", "1"]); + assert.deepEqual(writes, []); }); - it.each(["", "garbage", "0", "-1", "Infinity", "NaN"])( - "ignores invalid persisted scale %j", - (value) => { - const applied: Array<[string, string]> = []; - restoreEarlyLinuxDeviceScaleFactor({ - ...input, - commandLine: { - hasSwitch: () => false, - getSwitchValue: () => "", - appendSwitch: (name, scale) => applied.push([name, scale]), - }, - readFileString: () => value, - writeFileString: () => assert.fail("unexpected write"), - }); - assert.deepEqual(applied, []); - }, - ); - - it("preserves explicit switches even when persistence fails", () => { + it("keeps startup available with default scaling when saved state cannot be read", () => { + const applied: Array<[string, string]> = []; restoreEarlyLinuxDeviceScaleFactor({ ...input, commandLine: { - hasSwitch: () => true, - getSwitchValue: () => "2", - appendSwitch: () => assert.fail("explicit scale overridden"), + hasSwitch: () => false, + getSwitchValue: () => "", + appendSwitch: (name, value) => applied.push([name, value]), }, - readFileString: () => assert.fail("explicit scale must take precedence"), - writeFileString: () => { - throw new Error("read-only"); + readFileString: () => { + throw new Error("ENOENT"); }, + writeFileString: () => {}, }); + assert.deepEqual(applied, []); }); - it("keeps default scaling when state is missing", () => { + it("keeps an explicit scale when saving it fails, without falling back to saved state", () => { + const applied: Array<[string, string]> = []; + const reads: Array = []; + const writes: Array = []; restoreEarlyLinuxDeviceScaleFactor({ ...input, commandLine: { - hasSwitch: () => false, - getSwitchValue: () => "", - appendSwitch: () => assert.fail("default scale overridden"), + hasSwitch: () => true, + getSwitchValue: () => "1", + appendSwitch: (name, value) => applied.push([name, value]), }, - readFileString: () => { - throw new Error("missing"); + readFileString: (path) => { + reads.push(path); + return "1.75"; + }, + writeFileString: (_path, value) => { + writes.push(value); + throw new Error("EACCES"); }, - writeFileString: () => assert.fail("unexpected write"), }); + assert.deepEqual(writes, ["1"]); + assert.deepEqual(reads, []); + assert.deepEqual(applied, []); }); }); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 07c103e5fdb9..61d30e7669cc 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -73,16 +73,63 @@ describe("DesktopPreReadyPlatform", () => { readFileSyncMock.mockReset().mockReturnValue("{}"); }); - it.effect("restores saved scaling before asynchronous startup can observe it", () => { - readFileSyncMock.mockImplementation((path: string) => - path.endsWith("linux-device-scale-factor") ? "1.75" : "{}", + it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { + vi.stubEnv("T3CODE_HOME", "/isolated"); + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + const settingsPath = "/isolated/userdata/desktop-settings.json"; + const files = new Map([[settingsPath, "{}"]]); + const commandLine = new Map(); + readFileSyncMock.mockImplementation((path: string) => { + const contents = files.get(path); + if (contents === undefined) throw new Error("ENOENT"); + return contents; + }); + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + files.set(path, contents); + }); + hasSwitchMock.mockImplementation((name: string) => commandLine.has(name)); + getSwitchValueMock.mockImplementation((name: string) => commandLine.get(name) ?? ""); + appendSwitchMock.mockImplementation((name: string, value: string) => { + commandLine.set(name, value); + }); + const launch = Effect.gen(function* () { + const startup = Promise.resolve().then(() => commandLine.get("force-device-scale-factor")); + yield* DesktopPreReadyPlatform.make; + return yield* Effect.promise(() => startup); + }); + + return Effect.gen(function* () { + for (const explicitScale of ["1.75", "1"]) { + commandLine.clear(); + commandLine.set("force-device-scale-factor", explicitScale); + appendSwitchMock.mockClear(); + assert.equal(yield* launch, explicitScale); + assert.isFalse( + appendSwitchMock.mock.calls.some(([name]) => name === "force-device-scale-factor"), + ); + + // The settings service discards fields it does not own when rewriting its document. + files.set(settingsPath, JSON.stringify({ linuxPasswordStore: "basic" })); + commandLine.clear(); + assert.equal(yield* launch, explicitScale); + } + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), ); + }); + + it.effect.each(["darwin", "win32"] as const)("leaves device scaling alone on %s", (platform) => { + readFileSyncMock.mockReturnValue("1.75"); + hasSwitchMock.mockImplementation((name: string) => name === "force-device-scale-factor"); + getSwitchValueMock.mockReturnValue("2"); + return Effect.gen(function* () { - const startup = Promise.resolve().then(() => appendSwitchMock.mock.calls.slice()); yield* DesktopPreReadyPlatform.make; - const switches = yield* Effect.promise(() => startup); - assert.deepInclude(switches, ["force-device-scale-factor", "1.75"]); - }).pipe(Effect.provideService(HostProcess.Platform, "linux")); + assert.deepEqual(readFileSyncMock.mock.calls, []); + assert.deepEqual(writeFileSyncMock.mock.calls, []); + assert.deepEqual(appendSwitchMock.mock.calls, []); + }).pipe(Effect.provideService(HostProcess.Platform, platform)); }); it.effect("preserves an explicit Linux password-store switch", () => {