Skip to content
Merged
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
94 changes: 55 additions & 39 deletions apps/desktop/src/app/DesktopAppActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ export class DesktopAppActivation extends Context.Service<
DesktopAppActivation,
{
readonly start: Effect.Effect<void, DesktopAppActivationStartError, Scope.Scope>;
readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
readonly setRendererReady: (
ready: boolean,
webContents: Electron.WebContents,
) => Effect.Effect<void>;
readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect<void>;
}
>()("@t3tools/desktop/app/DesktopAppActivation") {}
Expand All @@ -316,16 +319,19 @@ export const make = Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const path = yield* Path.Path;
const userId = yield* HostProcessUserId;
const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());
const context = yield* Effect.context<never>();
const runPromise = Effect.runPromiseWith(context);
const runSync = Effect.runSyncWith(context);
const address = resolveDesktopAppControlAddress({
stateDir: path.resolve(desktopEnvironment.stateDir),
platform: desktopEnvironment.platform,
tempDir: NodeOS.tmpdir(),
userId,
joinPath: path.join,
});
let registeredWebContents: Electron.WebContents | null = null;
let detachRendererListeners: (() => void) | null = null;
// Every window's renderer can open projects; requests go to the main
// (most recently focused) window when its renderer is ready.
const readyRenderers = new Map<Electron.WebContents, () => void>();

const broker = new DesktopAppActivationBroker({
requestTimeoutMs: REQUEST_TIMEOUT_MS,
Expand All @@ -338,11 +344,30 @@ export const make = Effect.gen(function* () {
},
});

const clearRegisteredRenderer = () => {
detachRendererListeners?.();
detachRendererListeners = null;
registeredWebContents = null;
const pickRenderer = (): Electron.WebContents | null => {
const main = runSync(electronWindow.main);
if (Option.isSome(main) && readyRenderers.has(main.value.webContents)) {
return main.value.webContents;
}
return readyRenderers.keys().next().value ?? null;
};

const registerReadyRenderers = () => {
if (readyRenderers.size === 0) return;
broker.registerRenderer((request) => {
const webContents = pickRenderer();
if (webContents === null) throw new Error("No desktop renderer is ready.");
webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request);
});
};

const removeRenderer = (webContents: Electron.WebContents) => {
const detach = readyRenderers.get(webContents);
if (detach === undefined) return;
detach();
readyRenderers.delete(webContents);
broker.clearRenderer();
registerReadyRenderers();
};

return DesktopAppActivation.of({
Expand All @@ -369,37 +394,28 @@ export const make = Effect.gen(function* () {
Effect.ensuring(Effect.sync(() => broker.close())),
),
).pipe(Effect.asVoid),
setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) {
if (!ready) {
clearRegisteredRenderer();
return;
}
const main = yield* electronWindow.main;
if (Option.isNone(main)) return;
const webContents = main.value.webContents;
if (webContents.isDestroyed()) return;

if (registeredWebContents !== webContents) {
clearRegisteredRenderer();
registeredWebContents = webContents;
const onUnavailable = () => clearRegisteredRenderer();
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
) => {
if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer();
};
webContents.on("did-start-navigation", onNavigation);
webContents.once("destroyed", onUnavailable);
detachRendererListeners = () => {
webContents.removeListener("did-start-navigation", onNavigation);
webContents.removeListener("destroyed", onUnavailable);
};
}

broker.registerRenderer((request) => {
webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request);
});
}),
setRendererReady: (ready, webContents) =>
Effect.sync(() => {
if (!ready || webContents.isDestroyed()) {
removeRenderer(webContents);
return;
}
if (!readyRenderers.has(webContents)) {
const onUnavailable = () => removeRenderer(webContents);
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
) => {
if (event.isMainFrame && !event.isSameDocument) removeRenderer(webContents);
};
webContents.on("did-start-navigation", onNavigation);
webContents.once("destroyed", onUnavailable);
readyRenderers.set(webContents, () => {
webContents.removeListener("did-start-navigation", onNavigation);
webContents.removeListener("destroyed", onUnavailable);
});
}
registerReadyRenderers();
}).pipe(Effect.withSpan("DesktopAppActivation.setRendererReady")),
complete: (response) => Effect.sync(() => broker.complete(response)),
});
});
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function layerDesktopWindow(
revealOrCreateMain: Effect.die("unexpected window creation"),
activate: input.activate ?? Effect.void,
createMainIfBackendReady: Effect.void,
openWindow: () => Effect.die("unexpected window open"),
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
revealOrCreateMain: Effect.die("unexpected window reveal"),
activate: Effect.die("unexpected window activate"),
createMainIfBackendReady: Effect.die("unexpected window create"),
openWindow: () => Effect.die("unexpected window open"),
showConnectingSplash: Effect.void,
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/electron/ElectronWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import type * as Electron from "electron";
import { beforeEach, vi } from "vite-plus/test";

Expand Down Expand Up @@ -172,6 +173,27 @@ describe("ElectronWindow", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("treats the most recently focused open window as main", () =>
Effect.gen(function* () {
const first = makeBrowserWindow({ id: 1, destroyed: false });
const second = makeBrowserWindow({ id: 2, destroyed: false });
const electronWindow = yield* ElectronWindow.ElectronWindow;

yield* electronWindow.setMain(first);
yield* electronWindow.setMain(second);
assert.strictEqual((yield* electronWindow.main).pipe(Option.getOrNull), second);

yield* electronWindow.setMain(first);
assert.strictEqual((yield* electronWindow.main).pipe(Option.getOrNull), first);

yield* electronWindow.clearMain(Option.some(first));
assert.strictEqual((yield* electronWindow.main).pipe(Option.getOrNull), second);

yield* electronWindow.clearMain(Option.some(second));
assert.isTrue(Option.isNone(yield* electronWindow.main));
}).pipe(Effect.provide(layerTest)),
);

it.effect("skips windows destroyed before appearance sync runs", () =>
Effect.gen(function* () {
const liveWindow = makeBrowserWindow({ id: 1, destroyed: false });
Expand Down
31 changes: 16 additions & 15 deletions apps/desktop/src/electron/ElectronWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ export class ElectronWindow extends Context.Service<
readonly main: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
readonly currentMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
readonly focusedMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
// Registers an app window, or promotes it to main when it gains focus.
readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect<void>;
// Forgets one app window (the next most recent becomes main), or all of them.
readonly clearMain: (window: Option.Option<Electron.BrowserWindow>) => Effect.Effect<void>;
readonly prepareReveal: (window: Electron.BrowserWindow) => Effect.Effect<boolean>;
readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect<void>;
Expand All @@ -142,7 +144,9 @@ export const make = Effect.gen(function* () {
// Tracks a capture reveal in flight. Ordinary reveals keep Electron's native path.
const captureRevealWindows = new Set<number>();
yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close()));
const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
// App windows, most recently focused first. The head is the "main" window
// that app-level actions (dock click, CLI open, dialogs) target.
const appWindowsRef = yield* Ref.make<ReadonlyArray<Electron.BrowserWindow>>([]);

const listWindows = Effect.try({
try: () => Electron.BrowserWindow.getAllWindows(),
Expand Down Expand Up @@ -170,11 +174,10 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.orDie);

const liveMain = Effect.gen(function* () {
const main = yield* Ref.get(mainWindowRef);
if (Option.isNone(main) || (yield* isWindowDestroyed(main.value))) {
return Option.none<Electron.BrowserWindow>();
for (const window of yield* Ref.get(appWindowsRef)) {
if (!(yield* isWindowDestroyed(window))) return Option.some(window);
}
return main;
return Option.none<Electron.BrowserWindow>();
});

const currentMainOrFirst = Effect.gen(function* () {
Expand Down Expand Up @@ -241,17 +244,15 @@ export const make = Effect.gen(function* () {
main: liveMain,
currentMainOrFirst,
focusedMainOrFirst,
setMain: (window) => Ref.set(mainWindowRef, Option.some(window)),
setMain: (window) =>
Ref.update(appWindowsRef, (windows) => [
window,
...windows.filter((existing) => existing !== window),
]),
clearMain: (window) =>
Ref.update(mainWindowRef, (current) => {
if (Option.isNone(current)) {
return current;
}
if (Option.isSome(window) && current.value !== window.value) {
return current;
}
return Option.none();
}),
Ref.update(appWindowsRef, (windows) =>
Option.isSome(window) ? windows.filter((existing) => existing !== window.value) : [],
),
prepareReveal: (window) =>
Effect.promise(async () => {
if (platform !== "win32" || window.isDestroyed()) {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
getWindowFullscreenState,
openExternal,
openSystemSettings,
openWindow,
checkSystemPermission,
pasteAsText,
probeRemoteEditors,
Expand Down Expand Up @@ -143,6 +144,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(checkSystemPermission);
yield* ipc.handle(pasteAsText);
yield* ipc.handle(openWindow);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const OPEN_WINDOW_CHANNEL = "desktop:open-window";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/ipc/methods/appActivation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DesktopAppActivationResponse } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as Electron from "electron";

import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts";
import * as IpcChannels from "../channels.ts";
Expand All @@ -10,9 +11,12 @@ export const setReady = DesktopIpc.makeIpcMethod({
channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL,
payload: Schema.Boolean,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) {
handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready, event) {
const webContents =
event === undefined ? undefined : Electron.webContents.fromId(event.sender.id);
if (webContents === undefined) return;
const activation = yield* DesktopAppActivation.DesktopAppActivation;
yield* activation.setRendererReady(ready);
yield* activation.setRendererReady(ready, webContents);
}),
});

Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as Electron from "electron";
import * as MacPermissions from "../../permissions/MacPermissions.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
Expand Down Expand Up @@ -356,6 +357,16 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
}),
});

export const openWindow = DesktopIpc.makeIpcMethod({
channel: IpcChannels.OPEN_WINDOW_CHANNEL,
payload: Schema.Struct({ projectScopeKey: Schema.optional(Schema.String) }),
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.openWindow")(function* (input) {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.openWindow(input);
}),
});

export const pasteAsText = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PASTE_AS_TEXT_CHANNEL,
payload: Schema.Undefined,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
openWindow: (options) => ipcRenderer.invoke(IpcChannels.OPEN_WINDOW_CHANNEL, options),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
Loading
Loading