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
60 changes: 59 additions & 1 deletion apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopWebLinks from "./DesktopWebLinks.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts";

/** Clerk forwards web links; tests that are not about links ignore them. */
const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({
receive: () => Effect.void,
setRendererReady: () => Effect.void,
});

const layerDesktopClerk = (
isDevelopment = true,
events: string[] = [],
Expand Down Expand Up @@ -220,11 +227,12 @@ describe("DesktopClerk", () => {

assert.isTrue(Exit.isSuccess(exit));
assert.equal(quit.mock.calls.length, 0);
assert.deepEqual(registeredEvents, ["open-url", "second-instance"]);
assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]);
}).pipe(
Effect.provide(layerDesktopClerk()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
});

Expand Down Expand Up @@ -253,6 +261,7 @@ describe("DesktopClerk", () => {
Effect.provide(layerDesktopClerk()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
});
});
Expand Down Expand Up @@ -299,6 +308,7 @@ it.effect(
Effect.provide(layerDesktopClerk()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
},
);
Expand Down Expand Up @@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)(
ElectronWindow.ElectronWindow,
{} as ElectronWindow.ElectronWindow["Service"],
),
Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks),
);
}).pipe(Effect.scoped),
);

it.effect("hands a web link to the renderer and leaves other links alone", () =>
Effect.gen(function* () {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const listeners = new Map<string, (...args: unknown[]) => void>();
const electronApp = {
whenReady: Effect.void,
on: (name: string, listener: (...args: unknown[]) => void) =>
Effect.sync(() => {
listeners.set(name, listener);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const received: Array<string> = [];
yield* Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
yield* clerk.configure;
const open = (url: string) => {
const event = { preventDefault: vi.fn() };
listeners.get("open-url")!(event, url);
return event.preventDefault.mock.calls.length;
};
// macOS hands the default browser every web link.
assert.strictEqual(open("https://example.com/page"), 1);
assert.strictEqual(open("http://localhost:3000/"), 1);
// Anything else is not a web page; Electron keeps its own handling.
assert.strictEqual(open("mailto:hello@example.com"), 0);
yield* Effect.yieldNow;
assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]);
}).pipe(
Effect.provide(layerDesktopClerk(true, [], "darwin")),
Effect.provideService(HostProcess.Arguments, ["t3"]),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(
ElectronWindow.ElectronWindow,
{} as ElectronWindow.ElectronWindow["Service"],
),
Effect.provideService(
DesktopWebLinks.DesktopWebLinks,
DesktopWebLinks.DesktopWebLinks.of({
receive: (url) => Effect.sync(() => void received.push(url)),
setRendererReady: () => Effect.void,
}),
),
);
}).pipe(Effect.scoped),
);
23 changes: 21 additions & 2 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createClerkBridge } from "@clerk/electron";
import * as NodeURL from "node:url";
import { storage } from "@clerk/electron/storage";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand All @@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopUserData from "./DesktopUserData.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopWebLinks from "./DesktopWebLinks.ts";

declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;

Expand Down Expand Up @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service<
readonly configure: Effect.Effect<
void,
never,
ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
| ElectronApp.ElectronApp
| ElectronWindow.ElectronWindow
| DesktopWebLinks.DesktopWebLinks
| Scope.Scope
>;
}
>()("@t3tools/desktop/app/DesktopClerk") {}
Expand Down Expand Up @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () {
configure: Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
const runPromise = Effect.runPromiseWith(context);

Expand Down Expand Up @@ -184,8 +190,21 @@ export const make = Effect.gen(function* () {
};
const args = yield* HostProcess.Arguments;
args.some((value) => startProviderAuthHandoff(value));
// As the default browser, macOS hands T3 Code every web link through the same event.
const openWebLink = (url: string) => {
if (!DesktopWebLinks.isWebLink(url)) return false;
void runPromise(webLinks.receive(url));
return true;
};
yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault();
if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url))
event.preventDefault();
});
// A browser opens HTML files too, which macOS hands over by path.
yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => {
if (!DesktopWebLinks.isWebPageFile(path)) return;
event.preventDefault();
void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
});
yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))
Expand Down
93 changes: 93 additions & 0 deletions apps/desktop/src/app/DesktopWebLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron webContents.
import { assert, describe, it } from "@effect/vitest";
import * as NodeEvents from "node:events";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts";
import * as DesktopWebLinks from "./DesktopWebLinks.ts";

const makeWindow = () => {
const sent: Array<string> = [];
const revealed: Array<unknown> = [];
const webContents = Object.assign(new NodeEvents.EventEmitter(), {
isDestroyed: () => false,
send: (channel: string, url: string) => {
if (channel === WEB_LINK_OPEN_CHANNEL) sent.push(url);
},
});
const window = { webContents } as unknown as Electron.BrowserWindow;
/** The web app starts loading a new page, as a reload does. */
const reload = () =>
webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
const layer = DesktopWebLinks.layer.pipe(
Layer.provide(
Layer.succeed(
ElectronWindow.ElectronWindow,
ElectronWindow.ElectronWindow.of({
currentMainOrFirst: Effect.succeed(Option.some(window)),
reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)),
} as unknown as ElectronWindow.ElectronWindow["Service"]),
),
),
);
return { sent, revealed, layer, reload };
};

describe("DesktopWebLinks", () => {
it("treats only http and https as web links", () => {
assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/"));
assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1"));
assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome"));
assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com"));
assert.isFalse(DesktopWebLinks.isWebLink("not a url"));
});

it("opens HTML files the way a browser does, and nothing else by path", () => {
assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html"));
assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM"));
assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml"));
assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md"));
assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html"));
});

it.effect("holds links until the renderer listens, then delivers them in order", () => {
const { sent, revealed, layer } = makeWindow();
return Effect.gen(function* () {
const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
// The link that launched the app arrives before the web app has loaded.
yield* webLinks.receive("https://example.com/first");
yield* webLinks.receive("https://example.com/second");
assert.deepStrictEqual(sent, []);

yield* webLinks.setRendererReady(true);
assert.deepStrictEqual(sent, ["https://example.com/first", "https://example.com/second"]);
assert.strictEqual(revealed.length, 1);

yield* webLinks.receive("https://example.com/third");
assert.deepStrictEqual(sent.at(-1), "https://example.com/third");

// A reloading web app stops listening; links wait for it again.
yield* webLinks.setRendererReady(false);
yield* webLinks.receive("https://example.com/fourth");
assert.strictEqual(sent.length, 3);
}).pipe(Effect.provide(layer));
});

it.effect("holds links that arrive while the web app reloads until it listens again", () => {
const { sent, layer, reload } = makeWindow();
return Effect.gen(function* () {
const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
yield* webLinks.setRendererReady(true);
// The old page never says it stopped; the reload itself ends its listening.
reload();
yield* webLinks.receive("https://example.com/during-reload");
assert.deepStrictEqual(sent, []);

yield* webLinks.setRendererReady(true);
assert.deepStrictEqual(sent, ["https://example.com/during-reload"]);
}).pipe(Effect.provide(layer));
});
});
97 changes: 97 additions & 0 deletions apps/desktop/src/app/DesktopWebLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 ElectronWindow from "../electron/ElectronWindow.ts";
import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts";

/** An http(s) link the operating system handed to T3 Code as the default browser. */
export const isWebLink = (value: string) => {
if (!URL.canParse(value)) return false;
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
};

/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */
export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path);

/**
* Web links and HTML files macOS opens with T3 Code once it is the default
* browser. Files arrive as file:// URLs. They can
* arrive before the app is ready (the link that launched it) or while the web
* app reloads, so they wait until the renderer says it is listening.
*/
export class DesktopWebLinks extends Context.Service<
DesktopWebLinks,
{
/** Queues a link and delivers it as soon as the renderer is listening. */
readonly receive: (url: string) => Effect.Effect<void>;
/** The renderer started or stopped listening; listening flushes queued links. */
readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
}
>()("@t3tools/desktop/app/DesktopWebLinks") {}

const make = Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const pending: Array<string> = [];
/** The page that said it is listening; a reload or crash replaces it, and it must say so again. */
let listening: Electron.WebContents | null = null;
let detachListening: (() => void) | null = null;

const stopListening = () => {
detachListening?.();
detachListening = null;
listening = null;
};

const flush = Effect.gen(function* () {
if (listening === null || pending.length === 0) return;
const window = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(window) || window.value.webContents !== listening) return;
if (listening.isDestroyed()) {
stopListening();
return;
}
for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url);
yield* electronWindow.reveal(window.value);
});

const startListening = Effect.gen(function* () {
const window = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(window) || window.value.webContents.isDestroyed()) return;
const webContents = window.value.webContents;
if (listening === webContents) return;
stopListening();
listening = webContents;
// Links that arrive while a new page loads wait for it to listen again.
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
) => {
if (event.isMainFrame && !event.isSameDocument) stopListening();
};
webContents.on("did-start-navigation", onNavigation);
webContents.on("render-process-gone", stopListening);
webContents.once("destroyed", stopListening);
detachListening = () => {
webContents.removeListener("did-start-navigation", onNavigation);
webContents.removeListener("render-process-gone", stopListening);
webContents.removeListener("destroyed", stopListening);
};
});

return DesktopWebLinks.of({
receive: (url) =>
Effect.suspend(() => {
pending.push(url);
return flush;
}).pipe(Effect.withSpan("DesktopWebLinks.receive")),
setRendererReady: (ready) =>
(ready ? startListening : Effect.sync(stopListening)).pipe(
Effect.andThen(flush),
Effect.withSpan("DesktopWebLinks.setRendererReady"),
),
});
});

export const layer = Layer.effect(DesktopWebLinks, make);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from "./methods/snapShot.ts";
import * as PreviewIpc from "./methods/preview.ts";
import * as AppActivationIpc from "./methods/appActivation.ts";
import * as WebLinksIpc from "./methods/webLinks.ts";
import {
completeLegacyLocalStorage,
takeLegacyLocalStorage,
Expand All @@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"

yield* ipc.handle(AppActivationIpc.setReady);
yield* ipc.handle(AppActivationIpc.complete);
yield* ipc.handle(WebLinksIpc.setReady);

yield* ipc.handleSync(takeLegacyLocalStorage);
yield* ipc.handle(completeLegacyLocalStorage);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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 SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open";
export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/ipc/methods/webLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

export const setReady = DesktopIpc.makeIpcMethod({
channel: IpcChannels.WEB_LINK_READY_CHANNEL,
payload: Schema.Boolean,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.webLinks.setReady")(function* (ready) {
const webLinks = yield* DesktopWebLinks.DesktopWebLinks;
yield* webLinks.setRendererReady(ready);
}),
});
Loading
Loading