Skip to content
Draft
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
26 changes: 26 additions & 0 deletions packages/tool-server/src/tools/open-url/deep-link-note.ts
Original file line number Diff line number Diff line change
@@ -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."
);
}
3 changes: 2 additions & 1 deletion packages/tool-server/src/tools/open-url/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export const openUrlTool: ToolDefinition<Params, OpenUrlResult> = {
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<string, ServiceRef> => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OpenUrlServices, OpenUrlParams, OpenUrlResult> = {
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) };
},
};
3 changes: 2 additions & 1 deletion packages/tool-server/src/tools/open-url/platforms/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -24,6 +25,6 @@ export const iosImpl: PlatformImpl<OpenUrlServices, OpenUrlParams, OpenUrlResult
{ cause: err instanceof Error ? err : new Error(String(err)) }
);
}
return { opened: true, url: params.url };
return { opened: true, url: params.url, note: httpDeepLinkNote(params.url) };
},
};
6 changes: 6 additions & 0 deletions packages/tool-server/src/tools/open-url/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export interface OpenUrlParams {
export interface OpenUrlResult {
opened: boolean;
url: string;
/**
* Present when the URL was a web URL (http/https) opened on a native device:
* a caveat that it may have opened in the browser rather than deep-linked into
* a native app. Absent for custom schemes and for Chromium navigations.
*/
note?: string;
}

export type OpenUrlServices = Record<string, never>;
87 changes: 87 additions & 0 deletions packages/tool-server/test/open-url-deep-link-note.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:child_process")>("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();
});
});