| 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 | 63 | calls.setDockIcon.push(iconPath); |
| 64 | 64 | }), |
| 65 | 65 | appendCommandLineSwitch: () => Effect.void, |
| 66 | onBeforeQuitForUpdate: () => Effect.void, | |
| 66 | 67 | on: () => Effect.void, |
| 67 | 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 | 176 | const context = yield* Effect.context<DesktopLifecycleRuntimeServices>(); |
| 177 | 177 | const runEffect = Effect.runPromiseWith(context); |
| 178 | 178 | let quitAllowed = false; |
| 179 | let updaterQuitAllowed = false; | |
| 179 | 180 | yield* electronTheme.onUpdated(() => { |
| 180 | 181 | void runEffect( |
| 181 | 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 | 196 | yield* electronApp.on("before-quit", (event: Electron.Event) => { |
| 185 | 197 | handleBeforeQuit( |
| 186 | 198 | event, |
| 187 | 199 | runEffect, |
| 188 | () => quitAllowed, | |
| 200 | () => quitAllowed || updaterQuitAllowed, | |
| 189 | 201 | () => { |
| 190 | 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 | 4 | |
| 5 | 5 | const { |
| 6 | 6 | appendSwitchMock, |
| 7 | autoUpdaterOnMock, | |
| 8 | autoUpdaterRemoveListenerMock, | |
| 7 | 9 | exitMock, |
| 8 | 10 | getAppPathMock, |
| 9 | 11 | getVersionMock, |
| ... | ... | @@ -23,6 +25,8 @@ const { |
| 23 | 25 | whenReadyMock, |
| 24 | 26 | } = vi.hoisted(() => ({ |
| 25 | 27 | appendSwitchMock: vi.fn(), |
| 28 | autoUpdaterOnMock: vi.fn(), | |
| 29 | autoUpdaterRemoveListenerMock: vi.fn(), | |
| 26 | 30 | exitMock: vi.fn(), |
| 27 | 31 | getAppPathMock: vi.fn(() => "/app"), |
| 28 | 32 | getVersionMock: vi.fn(() => "1.2.3"), |
| ... | ... | @@ -43,6 +47,10 @@ const { |
| 43 | 47 | })); |
| 44 | 48 | |
| 45 | 49 | vi.mock("electron", () => ({ |
| 50 | autoUpdater: { | |
| 51 | on: autoUpdaterOnMock, | |
| 52 | removeListener: autoUpdaterRemoveListenerMock, | |
| 53 | }, | |
| 46 | 54 | app: { |
| 47 | 55 | commandLine: { |
| 48 | 56 | appendSwitch: appendSwitchMock, |
| ... | ... | @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; |
| 77 | 85 | describe("ElectronApp", () => { |
| 78 | 86 | beforeEach(() => { |
| 79 | 87 | appendSwitchMock.mockClear(); |
| 88 | autoUpdaterOnMock.mockClear(); | |
| 89 | autoUpdaterRemoveListenerMock.mockClear(); | |
| 80 | 90 | exitMock.mockClear(); |
| 81 | 91 | onMock.mockClear(); |
| 82 | 92 | quitMock.mockClear(); |
| ... | ... | @@ -153,4 +163,22 @@ describe("ElectronApp", () => { |
| 153 | 163 | assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); |
| 154 | 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 | 66 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; |
| 67 | 67 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; |
| 68 | 68 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; |
| 69 | readonly onBeforeQuitForUpdate: ( | |
| 70 | listener: () => void, | |
| 71 | ) => Effect.Effect<void, never, Scope.Scope>; | |
| 69 | 72 | readonly on: <Args extends ReadonlyArray<unknown>>( |
| 70 | 73 | eventName: string, |
| 71 | 74 | listener: (...args: Args) => void, |
| ... | ... | @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ |
| 178 | 181 | } |
| 179 | 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 | 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 | 45 | setDesktopName: () => Effect.void, |
| 46 | 46 | setDockIcon: () => Effect.void, |
| 47 | 47 | appendCommandLineSwitch: () => Effect.void, |
| 48 | onBeforeQuitForUpdate: () => Effect.void, | |
| 48 | 49 | on: () => Effect.void, |
| 49 | 50 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 50 | 51 |