Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const devBundleIdSuffix = NodePath.basename(repoRoot)
export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
export const APP_BUNDLE_ID = isDevelopment
? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
: "com.t3tools.t3code";
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
: "com.t3tools.t3code.alpha";
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3", "t3code"];
const LAUNCHER_VERSION = 12;
const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
const developmentMacIconPngPath = NodePath.join(
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopDeepLinks from "./DesktopDeepLinks.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
Expand Down Expand Up @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () {
const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
const electronApp = yield* ElectronApp.ElectronApp;
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks;
const clerk = yield* DesktopClerk.DesktopClerk;
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
Expand All @@ -238,6 +240,7 @@ const startup = Effect.gen(function* () {

yield* appIdentity.configure;
yield* lifecycle.register;
yield* deepLinks.registerEarly;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium app/DesktopApp.ts:243

deepLinks.registerEarly is called after shellEnvironment.installIntoProcess, resolveUserDataPath, electronApp.setPath, desktopSettings.load, appIdentity.configure, and lifecycle.register. On a macOS cold start, Electron emits the open-url event very early — before this point — so a t3://... launch that triggers the initial open-url is dropped and the app opens without navigating to the deep link. Consider registering the open-url listener before these async initialization steps.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopApp.ts around line 243:

`deepLinks.registerEarly` is called after `shellEnvironment.installIntoProcess`, `resolveUserDataPath`, `electronApp.setPath`, `desktopSettings.load`, `appIdentity.configure`, and `lifecycle.register`. On a macOS cold start, Electron emits the `open-url` event very early — before this point — so a `t3://...` launch that triggers the initial `open-url` is dropped and the app opens without navigating to the deep link. Consider registering the `open-url` listener before these async initialization steps.

yield* clerk.configure;

yield* electronApp.whenReady.pipe(
Expand All @@ -247,6 +250,7 @@ const startup = Effect.gen(function* () {
yield* logStartupInfo("app ready");
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* deepLinks.configure;
yield* updates.configure;
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
}).pipe(Effect.withSpan("desktop.startup"));
Expand Down
114 changes: 114 additions & 0 deletions apps/desktop/src/app/DesktopDeepLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 Scope from "effect/Scope";

import type * as Electron from "electron";

import { makeComponentLogger } from "./DesktopObservability.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import {
DESKTOP_THREAD_DEEP_LINK_SCHEME,
findThreadDeepLinkInArgv,
parseThreadDeepLinkUrl,
} from "../deeplink/threadDeepLink.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import type { DesktopWindowError } from "../window/DesktopWindow.ts";

type DesktopDeepLinkRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopWindow.DesktopWindow
| ElectronApp.ElectronApp;

/**
* @effect-expect-leaking DesktopEnvironment | DesktopWindow | ElectronApp
*/
export class DesktopDeepLinks extends Context.Service<
DesktopDeepLinks,
{
readonly registerEarly: Effect.Effect<
void,
never,
Scope.Scope | DesktopDeepLinkRuntimeServices
>;
readonly configure: Effect.Effect<void, never, DesktopDeepLinkRuntimeServices>;
}
>()("@t3tools/desktop/app/DesktopDeepLinks") {}

const { logInfo: logDeepLinkInfo, logError: logDeepLinkError } =
makeComponentLogger("desktop-deeplink");

export const make = Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const electronApp = yield* ElectronApp.ElectronApp;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const context = yield* Effect.context<DesktopDeepLinkRuntimeServices>();
const runPromise = Effect.runPromiseWith(context);

const openThreadDeepLink = (threadId: string) =>
Effect.gen(function* () {
yield* logDeepLinkInfo("open thread deep link", { threadId });
yield* desktopWindow
.openThread(threadId)
.pipe(
Effect.catch((error: DesktopWindowError) =>
logDeepLinkError("failed to open thread deep link", { threadId, error }),
),
);
}).pipe(Effect.withSpan("desktop.deeplink.openThread"));

const handleDeepLinkUrl = (rawUrl: string) =>
Effect.gen(function* () {
const threadId = parseThreadDeepLinkUrl(rawUrl);
if (Option.isNone(threadId)) {
return;
}
yield* openThreadDeepLink(threadId.value);
}).pipe(Effect.withSpan("desktop.deeplink.handleUrl"));

return DesktopDeepLinks.of({
registerEarly: Effect.gen(function* () {
yield* electronApp.on("open-url", (event: Electron.Event, url: string) => {
event.preventDefault();
void runPromise(handleDeepLinkUrl(url));
});

yield* electronApp.on(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium app/DesktopDeepLinks.ts:78

The second-instance handler is registered in registerEarly before the single-instance lock is acquired, so a t3:// link opened during startup on Windows/Linux launches a second app process instead of routing the URL to the starting instance. registerEarly attaches the second-instance listener early, but the lock (requestSingleInstanceLock) is only taken later during configure, leaving a window where the second-instance event is never emitted and the deep link is lost. Consider registering the second-instance listener only after the single-instance lock has been acquired, or document the startup ordering if this gap is intentional.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopDeepLinks.ts around line 78:

The `second-instance` handler is registered in `registerEarly` before the single-instance lock is acquired, so a `t3://` link opened during startup on Windows/Linux launches a second app process instead of routing the URL to the starting instance. `registerEarly` attaches the `second-instance` listener early, but the lock (`requestSingleInstanceLock`) is only taken later during `configure`, leaving a window where the second-instance event is never emitted and the deep link is lost. Consider registering the `second-instance` listener only after the single-instance lock has been acquired, or document the startup ordering if this gap is intentional.

"second-instance",
(_event: Electron.Event, argv: readonly string[]) => {
const launchUrl = findThreadDeepLinkInArgv(argv);
if (Option.isSome(launchUrl)) {
void runPromise(openThreadDeepLink(launchUrl.value));
}
},
);
}).pipe(Effect.withSpan("desktop.deeplink.registerEarly")),

configure: Effect.gen(function* () {
// Pin registration to this executable on macOS so Launch Services routes
// `t3://` links to the running channel (Alpha/Nightly) instead of whichever
// app happens to share the production bundle id.
const registered = yield* environment.platform === "darwin"
? electronApp.setAsDefaultProtocolClient(
DESKTOP_THREAD_DEEP_LINK_SCHEME,
process.execPath,
[],
)
: electronApp.setAsDefaultProtocolClient(DESKTOP_THREAD_DEEP_LINK_SCHEME);
if (!registered) {
yield* logDeepLinkError("failed to register default protocol client", {
scheme: DESKTOP_THREAD_DEEP_LINK_SCHEME,
});
}

const launchThreadId = findThreadDeepLinkInArgv(process.argv);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium app/DesktopDeepLinks.ts:106

On startup, configure re-parses process.argv and treats any preserved t3://... argument as a fresh deep link. The app's own relaunch path restarts packaged builds with args: process.argv.slice(1), so if the app was originally launched from a t3:// deep link, subsequent relaunches (for example WSL/server-exposure changes) carry the stale URL forward and reopen that old thread unexpectedly. Consider checking only argv that was not carried over from a prior launch (for example the OS-provided launch args from the first instance), or stripping known deep-link arguments before relaunching.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopDeepLinks.ts around line 106:

On startup, `configure` re-parses `process.argv` and treats any preserved `t3://...` argument as a fresh deep link. The app's own relaunch path restarts packaged builds with `args: process.argv.slice(1)`, so if the app was originally launched from a `t3://` deep link, subsequent relaunches (for example WSL/server-exposure changes) carry the stale URL forward and reopen that old thread unexpectedly. Consider checking only argv that was not carried over from a prior launch (for example the OS-provided launch args from the first instance), or stripping known deep-link arguments before relaunching.

if (Option.isSome(launchThreadId)) {
yield* openThreadDeepLink(launchThreadId.value);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS cold start duplicate opens

Medium Severity

On macOS, launching via t3://thread/… can invoke openThreadDeepLink twice: once from findThreadDeepLinkInArgv(process.argv) in configure and again from the open-url handler. That can emit duplicate desktop:open-thread IPC and navigation for a single link.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a2103. Configure here.

}).pipe(Effect.withSpan("desktop.deeplink.configure")),
});
});

export const layer = Layer.effect(DesktopDeepLinks, make);
6 changes: 3 additions & 3 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Path from "effect/Path";

import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopConfig from "./DesktopConfig.ts";
import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
import { isNightlyDesktopVersion, resolveDesktopAppBundleId } from "../updates/updateChannels.ts";

export interface MakeDesktopEnvironmentInput {
readonly dirname: string;
Expand Down Expand Up @@ -157,7 +157,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
const displayName = branding.displayName;
const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata");
const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
const legacyUserDataDirName = branding.displayName;
const resourcesPath = input.resourcesPath;

return DesktopEnvironment.of({
Expand Down Expand Up @@ -197,7 +197,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
branding,
displayName,
appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
resolveDesktopAppBundleId({ isDevelopment, appVersion: input.appVersion }),
),
linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
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 @@ -77,6 +77,7 @@ function makePoolLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
openThread: () => Effect.die("unexpected open thread"),
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]),
),
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/deeplink/threadDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { assert, describe, it } from "@effect/vitest";
import * as Option from "effect/Option";

import { findThreadDeepLinkInArgv, parseThreadDeepLinkUrl } from "./threadDeepLink.ts";

describe("threadDeepLink", () => {
it("parses t3://thread/<threadId> URLs", () => {
assert.deepStrictEqual(
parseThreadDeepLinkUrl("t3://thread/thread-123"),
Option.some("thread-123"),
);
});

it("rejects unrelated protocols and hosts", () => {
assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3code://app/")));
assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("t3://settings/general")));
assert.isTrue(Option.isNone(parseThreadDeepLinkUrl("https://example.com/thread/abc")));
});

it("finds deep links in process argv", () => {
assert.deepStrictEqual(
findThreadDeepLinkInArgv([
"/Applications/T3 Code.app/Contents/MacOS/T3 Code",
"t3://thread/abc",
]),
Option.some("abc"),
);
});
});
30 changes: 30 additions & 0 deletions apps/desktop/src/deeplink/threadDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Option from "effect/Option";

/** OS-registered scheme for thread deep links (`t3://thread/<threadId>`). */
export const DESKTOP_THREAD_DEEP_LINK_SCHEME = "t3";

export function parseThreadDeepLinkUrl(rawUrl: string): Option.Option<string> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return Option.none();
}

if (parsed.protocol !== `${DESKTOP_THREAD_DEEP_LINK_SCHEME}:` || parsed.hostname !== "thread") {
return Option.none();
}

const threadId = parsed.pathname.replace(/^\/+/, "");
return threadId.length > 0 ? Option.some(threadId) : Option.none();
}

export function findThreadDeepLinkInArgv(argv: readonly string[]): Option.Option<string> {
for (const arg of argv) {
const threadId = parseThreadDeepLinkUrl(arg);
if (Option.isSome(threadId)) {
return threadId;
}
}
return Option.none();
}
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const OPEN_THREAD_CHANNEL = "desktop:open-thread";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfigurat
import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts";
import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";
import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";
import * as DesktopDeepLinks from "./app/DesktopDeepLinks.ts";
import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
import * as DesktopShutdown from "./app/DesktopShutdown.ts";
Expand Down Expand Up @@ -172,6 +173,7 @@ const desktopApplicationLayer = Layer.mergeAll(
DesktopLifecycle.layer,
DesktopApplicationMenu.layer,
DesktopShellEnvironment.layer,
DesktopDeepLinks.layer,
desktopSshLayer,
).pipe(
Layer.provideMerge(DesktopUpdates.layer),
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
};
},
onOpenThread: (listener) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/preload.ts:119

The onOpenThread listener is registered during useEffect in AppSidebarLayout, which runs after the renderer has loaded. The main process emits the thread-open event on did-finish-load, so a t3://thread/... deep link opened during cold start fires before this listener exists and is permanently dropped. Consider having the main process buffer early thread-open events (or replay the last one) so a late-subscribing renderer still receives them.

Also found in 1 other location(s)

apps/web/src/components/AppSidebarLayout.tsx:101

AppSidebarLayout registers window.desktopBridge.onOpenThread inside useEffect, so the listener is attached only after the initial React commit. On a cold start from a t3://thread/... link, the main process flushes the queued deep link on did-finish-load and the preload bridge forwards it immediately, but there is no replay/buffer in preload.ts. That means the first OPEN_THREAD_CHANNEL event can fire before this effect runs, and the deep link is silently dropped instead of navigating to the thread.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/preload.ts around line 119:

The `onOpenThread` listener is registered during `useEffect` in `AppSidebarLayout`, which runs after the renderer has loaded. The main process emits the thread-open event on `did-finish-load`, so a `t3://thread/...` deep link opened during cold start fires before this listener exists and is permanently dropped. Consider having the main process buffer early thread-open events (or replay the last one) so a late-subscribing renderer still receives them.

Also found in 1 other location(s):
- apps/web/src/components/AppSidebarLayout.tsx:101 -- `AppSidebarLayout` registers `window.desktopBridge.onOpenThread` inside `useEffect`, so the listener is attached only after the initial React commit. On a cold start from a `t3://thread/...` link, the main process flushes the queued deep link on `did-finish-load` and the preload bridge forwards it immediately, but there is no replay/buffer in `preload.ts`. That means the first `OPEN_THREAD_CHANNEL` event can fire before this effect runs, and the deep link is silently dropped instead of navigating to the thread.

const wrappedListener = (_event: Electron.IpcRendererEvent, threadId: unknown) => {
if (typeof threadId !== "string") return;
listener(threadId);
};

ipcRenderer.on(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.OPEN_THREAD_CHANNEL, wrappedListener);
};
},
getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
setUpdateChannel: (channel) =>
ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/updates/updateChannels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { assert, describe, it } from "@effect/vitest";

import { resolveDesktopAppBundleId, resolveDesktopBuildAppId } from "./updateChannels.ts";

describe("updateChannels bundle ids", () => {
it("uses dev, nightly, and alpha bundle ids per channel", () => {
assert.equal(
resolveDesktopAppBundleId({ isDevelopment: true, appVersion: "0.0.28" }),
"com.t3tools.t3code.dev",
);
assert.equal(
resolveDesktopAppBundleId({
isDevelopment: false,
appVersion: "0.0.28-nightly.20260702.1",
}),
"com.t3tools.t3code.nightly",
);
assert.equal(
resolveDesktopAppBundleId({ isDevelopment: false, appVersion: "0.0.28" }),
"com.t3tools.t3code.alpha",
);
assert.equal(
resolveDesktopBuildAppId("0.0.28-nightly.20260702.1"),
"com.t3tools.t3code.nightly",
);
assert.equal(resolveDesktopBuildAppId("0.0.28"), "com.t3tools.t3code.alpha");
});
});
24 changes: 24 additions & 0 deletions apps/desktop/src/updates/updateChannels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DesktopUpdateChannel } from "@t3tools/contracts";

const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
const PRODUCTION_BUNDLE_ID = "com.t3tools.t3code";

export function isNightlyDesktopVersion(version: string): boolean {
return NIGHTLY_VERSION_PATTERN.test(version);
Expand All @@ -9,3 +10,26 @@ export function isNightlyDesktopVersion(version: string): boolean {
export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
}

export function resolveDesktopAppBundleId(input: {
readonly isDevelopment: boolean;
readonly appVersion: string;
}): string {
if (input.isDevelopment) {
return "com.t3tools.t3code.dev";
}

if (isNightlyDesktopVersion(input.appVersion)) {
return "com.t3tools.t3code.nightly";
}

// Alpha-channel builds use a distinct bundle id so they can run alongside
// Nightly and receive `t3://` deep links without single-instance conflicts.
return "com.t3tools.t3code.alpha";
}

export function resolveDesktopBuildAppId(version: string): string {
return resolveDesktopAppBundleId({ isDevelopment: false, appVersion: version });
}

export { PRODUCTION_BUNDLE_ID };
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
openThread: () => Effect.void,
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]);

Expand Down
Loading
Loading