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
15 changes: 15 additions & 0 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens only the bare Grok Bot launch link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.forEach(
["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"],
(url) => electronShell.openExternal(url),
);

assert.deepEqual(results, [true, false, false]);
assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens local editor file deep links", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);

// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List
// each exact link shape the wizard sends, never a whole scheme.
const APP_LAUNCH_URLS = new Set(["grokbot://app"]);

// Zed embeds [user@]host in the path; reject passwords and ports there.
const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/;

Expand All @@ -46,6 +50,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
try {
const url = new URL(rawUrl);
return SAFE_WEB_PROTOCOLS.has(url.protocol) ||
APP_LAUNCH_URLS.has(url.href) ||
isRemoteEditorUrl(url) ||
isLocalEditorFileUrl(url)
? Option.some(url.href)
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/components/mcp/UseInAnotherAppDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,16 @@ function PrimaryAction({ app, mcpUrl }: { readonly app: McpApp; readonly mcpUrl:
</div>
);
}
const { copy, link } = action;
const { copy, link, appLink } = action;
// The desktop app reports a link nothing opens and falls back; a browser cannot tell, and
// opens an app link in place (a new tab for it would stay blank), so its toast keeps the web.
const openApp = () => {
if (appLink === null) return openLink(link);
if (!isElectron) return window.location.assign(appLink);
void readLocalApi()
?.shell.openExternal(appLink)
.catch(() => openLink(link));
};
const handOff = async () => {
if (copy !== null) {
// The app opens only once the copy lands, so it never gets an older clipboard.
Expand All @@ -678,13 +687,19 @@ function PrimaryAction({ app, mcpUrl }: { readonly app: McpApp; readonly mcpUrl:
toastManager.add({
type: "success",
title: copy.title,
// A browser can block an open that follows the copy; this keeps a way through.
...(isElectron
// A browser can block an open that follows the copy, and no platform reliably reports an
// app link nothing handles (Linux says it opened), so these keep a way through.
...(isElectron && appLink === null
? {}
: { actionProps: { children: `Open ${app.name}`, onClick: () => openLink(link) } }),
: {
actionProps: {
children: appLink === null ? `Open ${app.name}` : `Open ${app.name} on the web`,
onClick: () => openLink(link),
},
}),
});
}
openLink(link);
openApp();
};
return (
<div className="space-y-2">
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/mcp/mcpApps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ describe("MCP app links", () => {
expect(claude.searchParams.get("connectorUrl")).toBe(MCP_URL);
});

// The desktop app opens only http and https links.
// The desktop app opens only https links and the bare app launch links it lists.
it.each(MCP_APPS.map((app) => [app.name, app] as const))("%s opens only https", (_, app) => {
const link = mcpHandoffAction(app, MCP_URL)?.link;
for (const url of [link, app.docs].filter((value) => value != null)) {
const action = mcpHandoffAction(app, MCP_URL);
for (const url of [action?.link, app.docs].filter((value) => value != null)) {
expect(new URL(url).protocol).toBe("https:");
}
expect(action?.appLink ?? null).toBe(app.id === "grok-bot" ? "grokbot://app" : null);
});
});
26 changes: 21 additions & 5 deletions apps/web/src/components/mcp/mcpApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import museLogo from "./logos/muse.png";
import openClawLogo from "./logos/openclaw.png";
import pokeLogo from "./logos/poke.png";

/** How the Connect step hands T3 Code's MCP URL to an app. Every link is https. */
/** How the Connect step hands T3 Code's MCP URL to an app. Every `link` is https. */
export type McpAppHandoff =
/** Opens the app's add form with T3 Code filled in. */
| { readonly kind: "open"; readonly link: (mcpUrl: string) => string }
Expand All @@ -18,6 +18,11 @@ export type McpAppHandoff =
| {
readonly kind: "copy-message";
readonly link: string;
/**
* Opens the installed app first, given the message; `link` is the fallback. The desktop
* app opens only the link shapes `APP_LAUNCH_URLS` in ElectronShell lists.
*/
readonly appLink?: (message: string) => string;
readonly message: (mcpUrl: string) => string;
}
/** Commands to run where the app runs. */
Expand Down Expand Up @@ -83,10 +88,12 @@ export const MCP_APPS: readonly McpApp[] = [
id: "grok-bot",
name: "Grok Bot",
logo: grokBotLogo,
// The app has no web chat, and its prompt links need a Bot id.
// No web chat, and no link that prefills a message yet (Grok Bot plans one), so the app
// opens bare and the copied message is pasted in.
handoff: {
kind: "copy-message",
link: "https://x.ai/bot",
appLink: () => "grokbot://app",
message: (url) => `Add the MCP server ${url} and connect it.`,
},
steps: ["Send this message to any of your Grok Bots."],
Expand Down Expand Up @@ -182,19 +189,28 @@ export function mcpHandoffAction(app: McpApp, mcpUrl: string) {
const { handoff } = app;
switch (handoff.kind) {
case "open":
return { label: `Open in ${app.name}`, link: handoff.link(mcpUrl), copy: null };
return {
label: `Open in ${app.name}`,
link: handoff.link(mcpUrl),
appLink: null,
copy: null,
};
case "copy-url":
return {
label: `Copy URL and open ${app.name}`,
link: handoff.link,
appLink: null,
copy: { text: mcpUrl, title: "URL copied" },
};
case "copy-message":
case "copy-message": {
const message = handoff.message(mcpUrl);
return {
label: `Copy message and open ${app.name}`,
link: handoff.link,
copy: { text: handoff.message(mcpUrl), title: "Message copied" },
appLink: handoff.appLink?.(message) ?? null,
copy: { text: message, title: "Message copied" },
};
}
case "commands":
return null;
}
Expand Down
Loading