Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
Expand Down Expand Up @@ -324,6 +325,7 @@ const startup = Effect.gen(function* () {
yield* appIdentity.configure;
yield* previewPasskeys.configure;
yield* applicationMenu.configure;
yield* (yield* DesktopTray.DesktopTray).configure;
yield* updates.configure;
yield* DesktopRemoteUpdates.listen;
yield* DesktopStartupRecovery.listen;
Expand Down
75 changes: 74 additions & 1 deletion apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";

function layerElectronApp(
Expand Down Expand Up @@ -105,6 +106,49 @@ function layerDesktopWindow(
}

describe("DesktopLifecycle", () => {
it.effect.each([
{ platform: "win32", tray: true, quits: 0 },
{ platform: "win32", tray: false, quits: 1 },
{ platform: "linux", tray: false, quits: 1 },
{ platform: "darwin", tray: false, quits: 0 },
] as const)("handles last-window closure on $platform with tray=$tray", (input) =>
Effect.gen(function* () {
const listeners = new Map<string, (...args: readonly unknown[]) => void>();
const quits = yield* Ref.make(0);
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(
layerElectronApp(
listeners,
Ref.update(quits, (count) => count + 1),
),
),
Layer.provideMerge(layerElectronTheme),
Layer.provideMerge(layerElectronWindow()),
Layer.provideMerge(layerDesktopWindow()),
Layer.provideMerge(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: input.platform,
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]),
),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(DesktopTray.DesktopTray, {
configure: Effect.void,
keepsHostAlive: Effect.succeed(input.tray),
}),
),
);
yield* Effect.scoped(
Effect.gen(function* () {
yield* (yield* DesktopLifecycle.DesktopLifecycle).register;
listeners.get("window-all-closed")!();
assert.equal(yield* Ref.get(quits), input.quits);
}),
).pipe(Effect.provide(layer));
}),
);
it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray<NodeJS.Platform>)(
"lets the updater's quit event proceed on %s",
(platform) => {
Expand All @@ -129,6 +173,12 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(DesktopTray.DesktopTray, {
configure: Effect.void,
keepsHostAlive: Effect.succeed(false),
}),
),
);

return Effect.scoped(
Expand Down Expand Up @@ -201,6 +251,12 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(layerDesktopShutdown),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(DesktopTray.DesktopTray, {
configure: Effect.void,
keepsHostAlive: Effect.succeed(false),
}),
),
);

yield* Effect.scoped(
Expand Down Expand Up @@ -244,6 +300,12 @@ describe("DesktopLifecycle", () => {
// Nothing marks this shutdown complete, so it stays pending.
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(DesktopTray.DesktopTray, {
configure: Effect.void,
keepsHostAlive: Effect.succeed(false),
}),
),
);

yield* Effect.scoped(
Expand Down Expand Up @@ -296,18 +358,29 @@ describe("DesktopLifecycle", () => {
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
Layer.provideMerge(
Layer.succeed(DesktopTray.DesktopTray, {
configure: Effect.void,
keepsHostAlive: Effect.succeed(false),
}),
),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const state = yield* DesktopState.DesktopState;
yield* lifecycle.register;
appListeners.get("second-instance")?.({}, ["t3code"]);
assert.equal(activationCount, 1);
appListeners.get("second-instance")?.({}, ["t3code", "t3code://app/threads/1"]);
assert.equal(activationCount, 1);
yield* Ref.set(state.quitting, true);

appListeners.get("activate")?.();
appListeners.get("second-instance")?.({}, ["t3code"]);

assert.equal(activationCount, 0);
assert.equal(activationCount, 1);
}),
).pipe(Effect.provide(layer));
}),
Expand Down
20 changes: 18 additions & 2 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";

export class DesktopLifecycleRelaunchError extends Schema.TaggedError<DesktopLifecycleRelaunchError>()(
Expand All @@ -40,10 +41,11 @@ export type DesktopLifecycleRuntimeServices =

type DesktopLifecycleRegistrationServices =
| DesktopLifecycleRuntimeServices
| DesktopTray.DesktopTray
| ElectronWindow.ElectronWindow;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow
* @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow | DesktopTray
*/
export class DesktopLifecycle extends Context.Service<
DesktopLifecycle,
Expand Down Expand Up @@ -282,12 +284,26 @@ export const make = DesktopLifecycle.of({
}).pipe(Effect.withSpan("desktop.lifecycle.activate")),
);
});
yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
if (argv.some((argument) => argument.includes("://"))) return;
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
if (!(yield* Ref.get(state.quitting))) yield* desktopWindow.activate;
}).pipe(Effect.withSpan("desktop.lifecycle.secondInstance")),
);
});
yield* electronApp.on("window-all-closed", () => {
void runEffect(
Effect.gen(function* () {
const app = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
const tray = yield* DesktopTray.DesktopTray;
if (
environment.platform !== "darwin" &&
!(yield* tray.keepsHostAlive) &&
!(yield* Ref.get(state.quitting))
) {
yield* app.quit;
}
}).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src/app/DesktopTray.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as DesktopAssets from "./DesktopAssets.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopTray from "./DesktopTray.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTray from "../electron/ElectronTray.ts";

describe("DesktopTray", () => {
it.effect.each([
{ platform: "win32", icon: true, native: true, enabled: true, creates: 1 },
{ platform: "win32", icon: false, native: true, enabled: false, creates: 0 },
{ platform: "win32", icon: true, native: false, enabled: false, creates: 2 },
{ platform: "darwin", icon: true, native: true, enabled: false, creates: 0 },
{ platform: "linux", icon: true, native: true, enabled: false, creates: 0 },
] as const)("keeps hosting only with a usable Windows tray: $platform/$icon/$native", (input) =>
Effect.gen(function* () {
let creates = 0;
let releases = 0;
let opens = 0;
let quits = 0;
let callbacks: Parameters<ElectronTray.ElectronTray["Service"]["create"]>[0] | undefined;
const stateLayer = DesktopState.layer;
const layer = DesktopTray.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
stateLayer,
Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: input.platform,
displayName: "T3 Code",
} as DesktopEnvironment.DesktopEnvironment["Service"]),
Layer.mock(DesktopAssets.DesktopAssets)({
iconPaths: Effect.succeed({
ico: input.icon ? Option.some("icon.ico") : Option.none(),
png: Option.none(),
icns: Option.none(),
}),
}),
Layer.mock(DesktopWindow.DesktopWindow)({
activate: Effect.sync(() => {
opens += 1;
}),
}),
Layer.mock(ElectronApp.ElectronApp)({
quit: Effect.sync(() => {
quits += 1;
}),
}),
Layer.succeed(ElectronTray.ElectronTray, {
create: (value) =>
Effect.acquireRelease(
Effect.sync(() => {
creates += 1;
callbacks = value;
return input.native;
}),
(created) =>
Effect.sync(() => {
if (created) releases += 1;
}),
),
}),
),
),
);
yield* Effect.scoped(
Effect.gen(function* () {
const tray = yield* DesktopTray.DesktopTray;
yield* tray.configure;
yield* tray.configure;
assert.equal(yield* tray.keepsHostAlive, input.enabled);
assert.equal(creates, input.creates);
if (input.enabled) {
callbacks!.open();
assert.equal(opens, 1);
yield* Ref.set((yield* DesktopState.DesktopState).quitting, true);
callbacks!.open();
assert.equal(opens, 1);
callbacks!.quit();
assert.equal(quits, 1);
}
}).pipe(Effect.provide(layer)),
);
assert.equal(releases, input.enabled ? 1 : 0);
}),
);
});
64 changes: 64 additions & 0 deletions apps/desktop/src/app/DesktopTray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Scope from "effect/Scope";
import * as DesktopAssets from "./DesktopAssets.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTray from "../electron/ElectronTray.ts";
import { makeComponentLogger } from "./DesktopObservability.ts";

export class DesktopTray extends Context.Service<
DesktopTray,
{
readonly configure: Effect.Effect<void, never, Scope.Scope>;
readonly keepsHostAlive: Effect.Effect<boolean>;
}
>()("@t3tools/desktop/app/DesktopTray") {}

const { logWarning } = makeComponentLogger("desktop-tray");

export const layer = Layer.effect(
DesktopTray,
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const assets = yield* DesktopAssets.DesktopAssets;
const window = yield* DesktopWindow.DesktopWindow;
const app = yield* ElectronApp.ElectronApp;
const state = yield* DesktopState.DesktopState;
const native = yield* ElectronTray.ElectronTray;
const enabled = yield* Ref.make(false);
const runFork = Effect.runForkWith(yield* Effect.context<never>());
const open = Effect.gen(function* () {
if (!(yield* Ref.get(state.quitting))) yield* window.activate;
}).pipe(Effect.catchCause((cause) => logWarning("failed to open window", { cause })));

return DesktopTray.of({
keepsHostAlive: Ref.get(enabled),
configure: Effect.gen(function* () {
if (environment.platform !== "win32" || (yield* Ref.get(enabled))) return;
const icon = (yield* assets.iconPaths).ico;
if (Option.isNone(icon)) {
yield* logWarning("tray icon unavailable; closing the last window will quit");
return;
}
const created = yield* native.create({
icon: icon.value,
name: environment.displayName,
open: () => {
runFork(open);
},
quit: () => {
runFork(app.quit);
},
});
yield* Ref.set(enabled, created);
if (!created) yield* logWarning("tray creation failed; closing the last window will quit");
}),
});
}),
);
43 changes: 43 additions & 0 deletions apps/desktop/src/electron/ElectronTray.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { vi } from "vite-plus/test";
import * as Electron from "electron";
import * as ElectronTray from "./ElectronTray.ts";

vi.mock("electron", () => ({ Tray: vi.fn(), Menu: { buildFromTemplate: vi.fn() } }));

describe("ElectronTray", () => {
it.effect.each(["success", "constructor", "menu"] as const)(
"releases native resources when setup outcome is %s",
(outcome) =>
Effect.gen(function* () {
const destroy = vi.fn();
const setContextMenu = vi.fn(() => {
if (outcome === "menu") throw new Error("menu unavailable");
});
vi.mocked(Electron.Tray).mockImplementation(function () {
if (outcome === "constructor") throw new Error("tray unavailable");
return {
destroy,
setContextMenu,
setToolTip: vi.fn(),
on: vi.fn(),
} as unknown as Electron.Tray;
});
yield* Effect.scoped(
Effect.gen(function* () {
const tray = yield* ElectronTray.ElectronTray;
const created = yield* tray.create({
icon: "icon.ico",
name: "T3 Code",
open: () => {},
quit: () => {},
});
assert.equal(created, outcome === "success");
assert.equal(destroy.mock.calls.length, outcome === "menu" ? 1 : 0);
}).pipe(Effect.provide(ElectronTray.layer)),
);
assert.equal(destroy.mock.calls.length, outcome === "constructor" ? 0 : 1);
}),
);
});
Loading
Loading