diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts index b8111f45587cf..9be2204d5132c 100644 --- a/apps/mobile/app.config.test.ts +++ b/apps/mobile/app.config.test.ts @@ -1,5 +1,9 @@ +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { afterEach, expect, it, vi } from "vite-plus/test"; +import appleAppSiteAssociation from "../web/public/.well-known/apple-app-site-association.json"; +import assetLinks from "../web/public/.well-known/assetlinks.json"; + // Keep config parsing independent of local env files and build credentials. vi.mock("../../scripts/lib/public-config.ts", () => ({ loadRepoEnv: () => ({ APP_VARIANT: process.env.APP_VARIANT }), @@ -30,4 +34,14 @@ it.each([ const config = await loadConfig(value); expect(config.ios?.bundleIdentifier).toBe(identifier); expect(config.android?.package).toBe(identifier); + // Copied thread links open this app only while app.t3.codes lists it. + const host = new URL(DEFAULT_HOSTED_APP_URL).host; + expect(config.ios?.associatedDomains).toContain(`applinks:${host}`); + expect(appleAppSiteAssociation.applinks.details[0]?.appIDs).toContain( + `${config.ios?.appleTeamId}.${identifier}`, + ); + expect(config.android?.intentFilters?.[0]?.data).toEqual([ + { scheme: "https", host, pathPrefix: "/threads/" }, + ]); + expect(assetLinks.map((link) => link.target.package_name)).toContain(identifier); }); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b94367a64ebf4..3d5920932736d 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -116,6 +116,8 @@ function resolveAppVariant(value: string | undefined): AppVariant { } const variant = VARIANT_CONFIG[APP_VARIANT]; +// Copied thread links open the app. app.t3.codes/.well-known lists every variant's identifiers. +const HOSTED_APP_HOST = "app.t3.codes"; const iosBundleIdentifier = isIosPersonalTeamBuild ? personalTeamBundleIdentifier! : variant.iosBundleIdentifier; @@ -252,6 +254,7 @@ const config: ExpoConfig = { associatedDomains: [ `applinks:${variant.relyingParty}`, `webcredentials:${variant.relyingParty}`, + `applinks:${HOSTED_APP_HOST}`, ], entitlements: { "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], @@ -302,6 +305,14 @@ const config: ExpoConfig = { predictiveBackGestureEnabled: true, // expo-sensors declares this for its pedometer, which the app does not use. blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"], + intentFilters: [ + { + action: "VIEW", + autoVerify: true, + data: [{ scheme: "https", host: HOSTED_APP_HOST, pathPrefix: "/threads/" }], + category: ["BROWSABLE", "DEFAULT"], + }, + ], }, web: { favicon: variant.assets.appIcon, diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 225496bb7a407..43b1c089c0b59 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -18,6 +18,7 @@ import { type NavigationState, } from "@react-navigation/native"; import { pipe } from "effect/Function"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -122,7 +123,13 @@ function getAppStateFromPath(path: string, options: Parameters shouldHandleAppLink(url, hasCloudPublicConfig()), getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), diff --git a/apps/mobile/src/lib/appLinking.test.ts b/apps/mobile/src/lib/appLinking.test.ts index 977a5a5e41ef5..8b873318d72aa 100644 --- a/apps/mobile/src/lib/appLinking.test.ts +++ b/apps/mobile/src/lib/appLinking.test.ts @@ -21,6 +21,7 @@ describe("shouldHandleAppLink", () => { "t3code://threads/env-1/thread-1", "t3code://pair?pairingUrl=x", "t3code-dev://settings/usage?tab=limits", + "https://app.t3.codes/threads/env/thread?prompt=Fix%20expo-development-client%20and%20t3code://expo-sharing", ])("handles path-bearing URL %s", (url) => { expect(shouldHandleAppLink(url, true)).toBe(true); }); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts index 9da768150f51b..570c86b36abf4 100644 --- a/apps/mobile/src/lib/appLinking.ts +++ b/apps/mobile/src/lib/appLinking.ts @@ -7,11 +7,13 @@ import type { NavigationState, PartialState } from "@react-navigation/native"; * to the NotFound wildcard route on every dev launch. */ export function shouldHandleAppLink(url: string, cloudConfigured: boolean): boolean { + // Only the route: a thread link's ?prompt= may mention either lifecycle URL. + const route = url.split(/[?#]/)[0] ?? ""; return ( - !url.includes("expo-development-client") && - !url.includes("://expo-sharing") && + !route.includes("expo-development-client") && + !route.includes("://expo-sharing") && !/^t3code(-dev|-preview)?:\/*$/.test(url) && - (cloudConfigured || !CLOUD_ONLY_LINK_PATH.test(url.split(/[?#]/)[0] ?? "")) + (cloudConfigured || !CLOUD_ONLY_LINK_PATH.test(route)) ); } diff --git a/apps/web/public/.well-known/apple-app-site-association.json b/apps/web/public/.well-known/apple-app-site-association.json new file mode 100644 index 0000000000000..b5bcbc50e573b --- /dev/null +++ b/apps/web/public/.well-known/apple-app-site-association.json @@ -0,0 +1,14 @@ +{ + "applinks": { + "details": [ + { + "appIDs": [ + "ARK85ZXQ4Z.com.t3tools.t3code", + "ARK85ZXQ4Z.com.t3tools.t3code.preview", + "ARK85ZXQ4Z.com.t3tools.t3code.dev" + ], + "components": [{ "/": "/threads/*" }] + } + ] + } +} diff --git a/apps/web/public/.well-known/assetlinks.json b/apps/web/public/.well-known/assetlinks.json new file mode 100644 index 0000000000000..317c04cf92d49 --- /dev/null +++ b/apps/web/public/.well-known/assetlinks.json @@ -0,0 +1,32 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.t3tools.t3code", + "sha256_cert_fingerprints": [ + "B9:F5:1E:D5:A7:6C:D1:86:C9:51:08:F2:19:91:B7:D5:3A:58:FD:A9:83:12:A9:E3:53:E2:1A:59:B2:42:03:B6" + ] + } + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.t3tools.t3code.preview", + "sha256_cert_fingerprints": [ + "BE:B8:BD:67:53:2F:12:95:0A:30:62:88:91:C1:2F:D0:CF:20:28:C3:81:5D:6D:31:45:E7:5A:AF:24:A2:8F:18" + ] + } + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.t3tools.t3code.dev", + "sha256_cert_fingerprints": [ + "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C" + ] + } + } +] diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts index 12a823a360e47..29684db87f063 100644 --- a/apps/web/vercel.ts +++ b/apps/web/vercel.ts @@ -62,5 +62,12 @@ export const config: VercelConfig = { dest: `${LATEST_ORIGIN}/$1`, }, ], - rewrites: [routes.rewrite("/(.*)", "/index.html")], + rewrites: [ + // Apple fetches it without an extension; the .json file is served as application/json. + routes.rewrite( + "/.well-known/apple-app-site-association", + "/.well-known/apple-app-site-association.json", + ), + routes.rewrite("/(.*)", "/index.html"), + ], }; diff --git a/docs/user/prompt-links.md b/docs/user/prompt-links.md index 4553e33e50880..2c5ab07bec939 100644 --- a/docs/user/prompt-links.md +++ b/docs/user/prompt-links.md @@ -58,6 +58,7 @@ malicious. ## Share a thread **Copy thread** copies a link such as `https://app.t3.codes/threads//`. Paste -it anywhere, such as Slack, a GitHub comment, or your notes. Opening it asks where to open it, like -a prompt link, then opens the thread. Only someone connected to the machine that runs the thread -can see it. In T3 Code, a thread link in a message opens the thread in the app. +it anywhere, such as Slack, a GitHub comment, or your notes. On a phone with T3 Code installed, it +opens the thread in the app. Elsewhere, opening it asks where to open it, like a prompt link, then +opens the thread. Only someone connected to the machine that runs the thread can see it. In T3 Code, +a thread link in a message opens the thread in the app.