diff --git a/packages/tool-server/src/tools/open-url/deep-link-note.ts b/packages/tool-server/src/tools/open-url/deep-link-note.ts new file mode 100644 index 000000000..60ca58a7c --- /dev/null +++ b/packages/tool-server/src/tools/open-url/deep-link-note.ts @@ -0,0 +1,26 @@ +/** + * An https:// (or http://) URL only reaches a native app when that app is + * installed and verified for the link's domain (iOS Universal Links / Android + * App Links). Otherwise the system opens it in the browser — and on iOS + * simulators `simctl openurl` routes Universal Links to Safari even when the + * owning app *is* installed, which is a long-standing simulator limitation. + * + * The tool has no reliable way to observe which app actually handled the URL + * (Safari and other apps are invisible to the native-devtools socket), so a web + * URL that silently fell back to the browser looks identical to a successful + * deep-link in the result. This caveat makes that ambiguity explicit to the + * caller instead of letting `opened: true` imply the native app was reached. + * + * Returns undefined for custom-scheme URLs (`scheme://…`), which route to their + * registered app reliably and need no caveat. + */ +export function httpDeepLinkNote(url: string): string | undefined { + if (!/^https?:\/\//i.test(url)) return undefined; + return ( + "This is a web URL — it opens the native app only if an app installed on this device is " + + "verified for the link's domain (iOS Universal Links / Android App Links); otherwise it " + + "opens in the browser. On iOS simulators it may open in Safari even when the owning app is " + + "installed. To reliably open an installed app, use its custom scheme (scheme://path) or " + + "launch-app with its bundle id." + ); +} diff --git a/packages/tool-server/src/tools/open-url/index.ts b/packages/tool-server/src/tools/open-url/index.ts index ceecd1105..4b67a5977 100644 --- a/packages/tool-server/src/tools/open-url/index.ts +++ b/packages/tool-server/src/tools/open-url/index.ts @@ -35,7 +35,8 @@ export const openUrlTool: ToolDefinition = { description: `Open a URL or URL scheme on the device. Use to navigate to a web page or deep-link into an app. On Chromium, this navigates the primary renderer to the given URL. Cross-platform schemes: https://, tel:, mailto:. iOS also: messages://, settings://, maps://. Android also: geo:, plus any app-specific deep link. -Returns { opened, url }. Fails if no app is registered to handle the URI (iOS/Android) or the renderer rejects the navigation (Chromium).`, +Deep-linking caveat: an https:// link opens the native app only when an installed app is verified for the link's domain (iOS Universal Links / Android App Links) — otherwise it opens in the browser, and on iOS simulators it may open in Safari even when the owning app is installed. To reliably open an installed app, use its custom scheme (scheme://path) or launch-app with its bundle id. +Returns { opened, url, note? }. note carries the deep-linking caveat when a web URL was opened on a native device. Fails if no app is registered to handle the URI (iOS/Android) or the renderer rejects the navigation (Chromium).`, zodSchema, capability, services: (params): Record => { diff --git a/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts b/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts index 9d92bb380..ac3b99350 100644 --- a/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts +++ b/packages/tool-server/src/tools/open-url/platforms/ios-remote.ts @@ -1,11 +1,12 @@ import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import { simctlOpenUrl } from "../../../utils/sim-remote"; import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; +import { httpDeepLinkNote } from "../deep-link-note"; export const iosRemoteImpl: PlatformImpl = { requires: ["sim-remote"], handler: async (_services, params) => { await simctlOpenUrl(params.udid, params.url); - return { opened: true, url: params.url }; + return { opened: true, url: params.url, note: httpDeepLinkNote(params.url) }; }, }; diff --git a/packages/tool-server/src/tools/open-url/platforms/ios.ts b/packages/tool-server/src/tools/open-url/platforms/ios.ts index 0bed1b6bd..a39dedab5 100644 --- a/packages/tool-server/src/tools/open-url/platforms/ios.ts +++ b/packages/tool-server/src/tools/open-url/platforms/ios.ts @@ -3,6 +3,7 @@ import { promisify } from "node:util"; import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; import type { OpenUrlParams, OpenUrlResult, OpenUrlServices } from "../types"; +import { httpDeepLinkNote } from "../deep-link-note"; const execFileAsync = promisify(execFile); @@ -24,6 +25,6 @@ export const iosImpl: PlatformImpl; diff --git a/packages/tool-server/test/open-url-deep-link-note.test.ts b/packages/tool-server/test/open-url-deep-link-note.test.ts new file mode 100644 index 000000000..4f3902e18 --- /dev/null +++ b/packages/tool-server/test/open-url-deep-link-note.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { DeviceInfo } from "@argent/registry"; + +// iosImpl shells out via execFileAsync(promisify(execFile)). Stub the round-trip +// so the handler resolves without a real `xcrun simctl openurl`, letting us +// assert the returned result shape (the note) rather than the subprocess. +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const result = execFileMock(cmd, args); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +import { httpDeepLinkNote } from "../src/tools/open-url/deep-link-note"; +import { iosImpl } from "../src/tools/open-url/platforms/ios"; + +const device = { platform: "ios", udid: "SIM" } as unknown as DeviceInfo; + +beforeEach(() => { + execFileMock.mockReset(); + execFileMock.mockReturnValue({ stdout: "", stderr: "" }); +}); + +describe("httpDeepLinkNote", () => { + it("returns the deep-linking caveat for http/https web URLs", () => { + for (const url of [ + "https://bsky.app/profile/tvpworld.bsky.social", + "http://example.com", + "HTTPS://EXAMPLE.COM", // scheme match is case-insensitive + ]) { + const note = httpDeepLinkNote(url); + expect(note, url).toBeTypeOf("string"); + expect(note).toMatch(/custom scheme|Universal Links|launch-app/); + } + }); + + it("returns undefined for custom schemes and non-web schemes", () => { + for (const url of [ + "bluesky://profile/tvpworld", // app custom scheme — routes reliably + "messages://", + "settings://", + "tel:5551234", + "mailto:a@b.com", + "geo:37.0,-122.0", + ]) { + expect(httpDeepLinkNote(url), url).toBeUndefined(); + } + }); +}); + +describe("open-url iOS handler surfaces the caveat only for web URLs", () => { + it("attaches note for an https Universal Link (the bsky.app repro)", async () => { + const res = await iosImpl.handler( + {}, + { udid: "SIM", url: "https://bsky.app/profile/tvpworld.bsky.social" }, + device + ); + expect(res.opened).toBe(true); + expect(res.url).toBe("https://bsky.app/profile/tvpworld.bsky.social"); + expect(res.note).toBeTypeOf("string"); + // The exact URL is still handed to simctl unchanged (no rewriting). + expect(execFileMock).toHaveBeenCalledWith("xcrun", [ + "simctl", + "openurl", + "SIM", + "https://bsky.app/profile/tvpworld.bsky.social", + ]); + }); + + it("omits note for a custom-scheme deep link", async () => { + const res = await iosImpl.handler({}, { udid: "SIM", url: "bluesky://profile/x" }, device); + expect(res.opened).toBe(true); + expect(res.note).toBeUndefined(); + }); +});