| author | |
| committer | |
| log | 9ccfd9dfef4e78285f9ac2a83b0333e67df36378 |
| tree | a51901f064804df118aa75d3e987a4878d127d92 |
| parent | 4f584da0f84666bda0cd399721115b0001cc0308 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 179 insertions(+), 1 deletions(-)
apps/desktop/src/app/DesktopAppIdentity.test.ts+1| ... | @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => | ... | @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 63 | calls.setDockIcon.push(iconPath); | 63 | calls.setDockIcon.push(iconPath); |
| 64 | }), | 64 | }), |
| 65 | appendCommandLineSwitch: () => Effect.void, | 65 | appendCommandLineSwitch: () => Effect.void, |
| 66 | onBeforeQuitForUpdate: () => Effect.void, | ||
| 66 | on: () => Effect.void, | 67 | on: () => Effect.void, |
| 67 | } satisfies ElectronApp.ElectronApp["Service"]); | 68 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 68 | 69 |
apps/desktop/src/app/DesktopLifecycle.test.ts created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Ref from "effect/Ref"; | ||
| 5 | |||
| 6 | import type * as Electron from "electron"; | ||
| 7 | |||
| 8 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 9 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | ||
| 10 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 11 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; | ||
| 12 | import * as DesktopShutdown from "./DesktopShutdown.ts"; | ||
| 13 | import * as DesktopState from "./DesktopState.ts"; | ||
| 14 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | ||
| 15 | |||
| 16 | describe("DesktopLifecycle", () => { | ||
| 17 | for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>) { | ||
| 18 | it.effect(`lets the updater's quit event proceed on ${platform}`, () => { | ||
| 19 | const appListeners = new Map<string, (...args: readonly unknown[]) => void>(); | ||
| 20 | |||
| 21 | const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | ||
| 22 | metadata: Effect.die("unexpected metadata read"), | ||
| 23 | name: Effect.succeed("T3 Code"), | ||
| 24 | whenReady: Effect.void, | ||
| 25 | quit: Effect.void, | ||
| 26 | exit: () => Effect.void, | ||
| 27 | relaunch: () => Effect.void, | ||
| 28 | setPath: () => Effect.void, | ||
| 29 | setName: () => Effect.void, | ||
| 30 | setAboutPanelOptions: () => Effect.void, | ||
| 31 | setAppUserModelId: () => Effect.void, | ||
| 32 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 33 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 34 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 35 | setDesktopName: () => Effect.void, | ||
| 36 | setDockIcon: () => Effect.void, | ||
| 37 | appendCommandLineSwitch: () => Effect.void, | ||
| 38 | onBeforeQuitForUpdate: (listener) => | ||
| 39 | Effect.acquireRelease( | ||
| 40 | Effect.sync(() => { | ||
| 41 | appListeners.set("before-quit-for-update", listener); | ||
| 42 | }), | ||
| 43 | () => | ||
| 44 | Effect.sync(() => { | ||
| 45 | appListeners.delete("before-quit-for-update"); | ||
| 46 | }), | ||
| 47 | ).pipe(Effect.asVoid), | ||
| 48 | on: (eventName, listener) => | ||
| 49 | Effect.acquireRelease( | ||
| 50 | Effect.sync(() => { | ||
| 51 | appListeners.set( | ||
| 52 | eventName, | ||
| 53 | listener as unknown as (...args: readonly unknown[]) => void, | ||
| 54 | ); | ||
| 55 | }), | ||
| 56 | () => | ||
| 57 | Effect.sync(() => { | ||
| 58 | appListeners.delete(eventName); | ||
| 59 | }), | ||
| 60 | ).pipe(Effect.asVoid), | ||
| 61 | } satisfies ElectronApp.ElectronApp["Service"]); | ||
| 62 | |||
| 63 | const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { | ||
| 64 | shouldUseDarkColors: Effect.succeed(false), | ||
| 65 | setSource: () => Effect.void, | ||
| 66 | onUpdated: () => Effect.void, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { | ||
| 70 | createMain: Effect.die("unexpected window creation"), | ||
| 71 | ensureMain: Effect.die("unexpected window creation"), | ||
| 72 | revealOrCreateMain: Effect.die("unexpected window creation"), | ||
| 73 | activate: Effect.void, | ||
| 74 | createMainIfBackendReady: Effect.void, | ||
| 75 | showConnectingSplash: Effect.void, | ||
| 76 | handleBackendReady: () => Effect.void, | ||
| 77 | handleBackendNotReady: Effect.void, | ||
| 78 | flushMainWindowBounds: Effect.void, | ||
| 79 | dispatchMenuAction: () => Effect.void, | ||
| 80 | syncAppearance: Effect.void, | ||
| 81 | }); | ||
| 82 | |||
| 83 | const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { | ||
| 84 | platform, | ||
| 85 | isDevelopment: false, | ||
| 86 | } as DesktopEnvironment.DesktopEnvironment["Service"]); | ||
| 87 | |||
| 88 | const layer = DesktopLifecycle.layer.pipe( | ||
| 89 | Layer.provideMerge(electronAppLayer), | ||
| 90 | Layer.provideMerge(electronThemeLayer), | ||
| 91 | Layer.provideMerge(desktopWindowLayer), | ||
| 92 | Layer.provideMerge(environmentLayer), | ||
| 93 | Layer.provideMerge(DesktopShutdown.layer), | ||
| 94 | Layer.provideMerge(DesktopState.layer), | ||
| 95 | ); | ||
| 96 | |||
| 97 | return Effect.scoped( | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | ||
| 100 | yield* lifecycle.register; | ||
| 101 | |||
| 102 | appListeners.get("before-quit-for-update")?.(); | ||
| 103 | |||
| 104 | let prevented = false; | ||
| 105 | const event = { | ||
| 106 | preventDefault: () => { | ||
| 107 | prevented = true; | ||
| 108 | }, | ||
| 109 | } as Electron.Event; | ||
| 110 | appListeners.get("before-quit")?.(event); | ||
| 111 | |||
| 112 | assert.isFalse( | ||
| 113 | prevented, | ||
| 114 | "cancelling this event prevents the updater from completing its relaunch", | ||
| 115 | ); | ||
| 116 | |||
| 117 | const state = yield* DesktopState.DesktopState; | ||
| 118 | assert.isTrue(yield* Ref.get(state.quitting)); | ||
| 119 | }), | ||
| 120 | ).pipe(Effect.provide(layer)); | ||
| 121 | }); | ||
| 122 | } | ||
| 123 | }); | ||
apps/desktop/src/app/DesktopLifecycle.ts+13-1| ... | @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({ | ... | @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({ |
| 176 | const context = yield* Effect.context<DesktopLifecycleRuntimeServices>(); | 176 | const context = yield* Effect.context<DesktopLifecycleRuntimeServices>(); |
| 177 | const runEffect = Effect.runPromiseWith(context); | 177 | const runEffect = Effect.runPromiseWith(context); |
| 178 | let quitAllowed = false; | 178 | let quitAllowed = false; |
| 179 | let updaterQuitAllowed = false; | ||
| 179 | yield* electronTheme.onUpdated(() => { | 180 | yield* electronTheme.onUpdated(() => { |
| 180 | void runEffect( | 181 | void runEffect( |
| 181 | desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), | 182 | desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), |
| 182 | ); | 183 | ); |
| 183 | }); | 184 | }); |
| 185 | yield* electronApp.onBeforeQuitForUpdate(() => { | ||
| 186 | // Electron's updater owns the remaining quit/install/relaunch sequence. | ||
| 187 | // Cancelling the following app "before-quit" event breaks that sequence, | ||
| 188 | // most visibly on macOS where the native updater performs the relaunch. | ||
| 189 | updaterQuitAllowed = true; | ||
| 190 | void runEffect( | ||
| 191 | logLifecycleInfo("allowing updater-controlled quit").pipe( | ||
| 192 | Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), | ||
| 193 | ), | ||
| 194 | ); | ||
| 195 | }); | ||
| 184 | yield* electronApp.on("before-quit", (event: Electron.Event) => { | 196 | yield* electronApp.on("before-quit", (event: Electron.Event) => { |
| 185 | handleBeforeQuit( | 197 | handleBeforeQuit( |
| 186 | event, | 198 | event, |
| 187 | runEffect, | 199 | runEffect, |
| 188 | () => quitAllowed, | 200 | () => quitAllowed || updaterQuitAllowed, |
| 189 | () => { | 201 | () => { |
| 190 | quitAllowed = true; | 202 | quitAllowed = true; |
| 191 | }, | 203 | }, |
apps/desktop/src/electron/ElectronApp.test.ts+28| ... | @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test"; | ... | @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | const { | 5 | const { |
| 6 | appendSwitchMock, | 6 | appendSwitchMock, |
| 7 | autoUpdaterOnMock, | ||
| 8 | autoUpdaterRemoveListenerMock, | ||
| 7 | exitMock, | 9 | exitMock, |
| 8 | getAppPathMock, | 10 | getAppPathMock, |
| 9 | getVersionMock, | 11 | getVersionMock, |
| ... | @@ -23,6 +25,8 @@ const { | ... | @@ -23,6 +25,8 @@ const { |
| 23 | whenReadyMock, | 25 | whenReadyMock, |
| 24 | } = vi.hoisted(() => ({ | 26 | } = vi.hoisted(() => ({ |
| 25 | appendSwitchMock: vi.fn(), | 27 | appendSwitchMock: vi.fn(), |
| 28 | autoUpdaterOnMock: vi.fn(), | ||
| 29 | autoUpdaterRemoveListenerMock: vi.fn(), | ||
| 26 | exitMock: vi.fn(), | 30 | exitMock: vi.fn(), |
| 27 | getAppPathMock: vi.fn(() => "/app"), | 31 | getAppPathMock: vi.fn(() => "/app"), |
| 28 | getVersionMock: vi.fn(() => "1.2.3"), | 32 | getVersionMock: vi.fn(() => "1.2.3"), |
| ... | @@ -43,6 +47,10 @@ const { | ... | @@ -43,6 +47,10 @@ const { |
| 43 | })); | 47 | })); |
| 44 | 48 | ||
| 45 | vi.mock("electron", () => ({ | 49 | vi.mock("electron", () => ({ |
| 50 | autoUpdater: { | ||
| 51 | on: autoUpdaterOnMock, | ||
| 52 | removeListener: autoUpdaterRemoveListenerMock, | ||
| 53 | }, | ||
| 46 | app: { | 54 | app: { |
| 47 | commandLine: { | 55 | commandLine: { |
| 48 | appendSwitch: appendSwitchMock, | 56 | appendSwitch: appendSwitchMock, |
| ... | @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; | ... | @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; |
| 77 | describe("ElectronApp", () => { | 85 | describe("ElectronApp", () => { |
| 78 | beforeEach(() => { | 86 | beforeEach(() => { |
| 79 | appendSwitchMock.mockClear(); | 87 | appendSwitchMock.mockClear(); |
| 88 | autoUpdaterOnMock.mockClear(); | ||
| 89 | autoUpdaterRemoveListenerMock.mockClear(); | ||
| 80 | exitMock.mockClear(); | 90 | exitMock.mockClear(); |
| 81 | onMock.mockClear(); | 91 | onMock.mockClear(); |
| 82 | quitMock.mockClear(); | 92 | quitMock.mockClear(); |
| ... | @@ -153,4 +163,22 @@ describe("ElectronApp", () => { | ... | @@ -153,4 +163,22 @@ describe("ElectronApp", () => { |
| 153 | assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); | 163 | assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); |
| 154 | }).pipe(Effect.provide(ElectronApp.layer)), | 164 | }).pipe(Effect.provide(ElectronApp.layer)), |
| 155 | ); | 165 | ); |
| 166 | |||
| 167 | it.effect("scopes native updater quit listeners", () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const listener = vi.fn(); | ||
| 170 | |||
| 171 | yield* Effect.scoped( | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const electronApp = yield* ElectronApp.ElectronApp; | ||
| 174 | yield* electronApp.onBeforeQuitForUpdate(listener); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); | ||
| 179 | assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ | ||
| 180 | ["before-quit-for-update", listener], | ||
| 181 | ]); | ||
| 182 | }).pipe(Effect.provide(ElectronApp.layer)), | ||
| 183 | ); | ||
| 156 | }); | 184 | }); |
apps/desktop/src/electron/ElectronApp.ts+13| ... | @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service< | ... | @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service< |
| 66 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; | 66 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; |
| 67 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; | 67 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; |
| 68 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; | 68 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; |
| 69 | readonly onBeforeQuitForUpdate: ( | ||
| 70 | listener: () => void, | ||
| 71 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 69 | readonly on: <Args extends ReadonlyArray<unknown>>( | 72 | readonly on: <Args extends ReadonlyArray<unknown>>( |
| 70 | eventName: string, | 73 | eventName: string, |
| 71 | listener: (...args: Args) => void, | 74 | listener: (...args: Args) => void, |
| ... | @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ | ... | @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ |
| 178 | } | 181 | } |
| 179 | Electron.app.commandLine.appendSwitch(switchName, value); | 182 | Electron.app.commandLine.appendSwitch(switchName, value); |
| 180 | }), | 183 | }), |
| 184 | onBeforeQuitForUpdate: (listener) => | ||
| 185 | Effect.acquireRelease( | ||
| 186 | Effect.sync(() => { | ||
| 187 | Electron.autoUpdater.on("before-quit-for-update", listener); | ||
| 188 | }), | ||
| 189 | () => | ||
| 190 | Effect.sync(() => { | ||
| 191 | Electron.autoUpdater.removeListener("before-quit-for-update", listener); | ||
| 192 | }), | ||
| 193 | ).pipe(Effect.asVoid), | ||
| 181 | on: addScopedAppListener, | 194 | on: addScopedAppListener, |
| 182 | }); | 195 | }); |
| 183 | 196 |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | ... | @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 45 | setDesktopName: () => Effect.void, | 45 | setDesktopName: () => Effect.void, |
| 46 | setDockIcon: () => Effect.void, | 46 | setDockIcon: () => Effect.void, |
| 47 | appendCommandLineSwitch: () => Effect.void, | 47 | appendCommandLineSwitch: () => Effect.void, |
| 48 | onBeforeQuitForUpdate: () => Effect.void, | ||
| 48 | on: () => Effect.void, | 49 | on: () => Effect.void, |
| 49 | } satisfies ElectronApp.ElectronApp["Service"]); | 50 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 50 | 51 |