From c7ea0f8ae68ca19b8ba3f22e2bcd6898080cd94f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 24 Feb 2026 18:03:15 +0800 Subject: [PATCH 1/2] feat: add smart banner and universal links for folo domains --- apps/desktop/build/entitlements.mac.plist | 6 + apps/desktop/build/entitlements.mas.plist | 6 + apps/desktop/forge.config.cts | 1 + apps/desktop/layer/renderer/index.html | 2 +- apps/mobile/app.config.ts | 1 + apps/mobile/ios/Folo/Folo.entitlements | 8 +- apps/mobile/src/hooks/useIntentHandler.ts | 169 +++++++++++++----- .../pages/(main)/share/feeds/[id]/metadata.ts | 2 +- .../pages/(main)/share/lists/[id]/metadata.ts | 2 +- apps/ssr/index.html | 2 +- apps/ssr/index.ts | 15 ++ .../ssr/src/lib/apple-app-site-association.ts | 11 ++ apps/ssr/worker-entry.ts | 13 ++ apps/ssr/wrangler.jsonc | 8 + 14 files changed, 196 insertions(+), 50 deletions(-) create mode 100644 apps/ssr/src/lib/apple-app-site-association.ts diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist index 446fe171da..cd208ecbf3 100644 --- a/apps/desktop/build/entitlements.mac.plist +++ b/apps/desktop/build/entitlements.mac.plist @@ -4,5 +4,11 @@ com.apple.security.cs.allow-jit + com.apple.developer.associated-domains + + applinks:folo.is + applinks:app.folo.is + applinks:dev.folo.is + diff --git a/apps/desktop/build/entitlements.mas.plist b/apps/desktop/build/entitlements.mas.plist index a101240dda..211f904262 100644 --- a/apps/desktop/build/entitlements.mas.plist +++ b/apps/desktop/build/entitlements.mas.plist @@ -10,5 +10,11 @@ com.apple.security.network.client + com.apple.developer.associated-domains + + applinks:folo.is + applinks:app.folo.is + applinks:dev.folo.is + diff --git a/apps/desktop/forge.config.cts b/apps/desktop/forge.config.cts index 8237ffa685..d68dbec19b 100644 --- a/apps/desktop/forge.config.cts +++ b/apps/desktop/forge.config.cts @@ -124,6 +124,7 @@ const config: ForgeConfig = { prune: false, extendInfo: { ITSAppUsesNonExemptEncryption: false, + NSUserActivityTypes: ["NSUserActivityTypeBrowsingWeb"], }, osxSign: { optionsForFile: diff --git a/apps/desktop/layer/renderer/index.html b/apps/desktop/layer/renderer/index.html index b9aa2c8ccc..aeabc2e13e 100644 --- a/apps/desktop/layer/renderer/index.html +++ b/apps/desktop/layer/renderer/index.html @@ -32,7 +32,7 @@ - + diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8413bbe266..712588f816 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -49,6 +49,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { ios: { supportsTablet: true, bundleIdentifier: "is.follow", + associatedDomains: ["applinks:folo.is", "applinks:app.folo.is", "applinks:dev.folo.is"], usesAppleSignIn: true, infoPlist: { LSApplicationCategoryType: "public.app-category.news", diff --git a/apps/mobile/ios/Folo/Folo.entitlements b/apps/mobile/ios/Folo/Folo.entitlements index 3f2ea618bc..23a9932d0a 100644 --- a/apps/mobile/ios/Folo/Folo.entitlements +++ b/apps/mobile/ios/Folo/Folo.entitlements @@ -8,5 +8,11 @@ Default + com.apple.developer.associated-domains + + applinks:folo.is + applinks:app.folo.is + applinks:dev.folo.is + - \ No newline at end of file + diff --git a/apps/mobile/src/hooks/useIntentHandler.ts b/apps/mobile/src/hooks/useIntentHandler.ts index d408b1b3e5..29804ca0ae 100644 --- a/apps/mobile/src/hooks/useIntentHandler.ts +++ b/apps/mobile/src/hooks/useIntentHandler.ts @@ -5,6 +5,23 @@ import { useEffect } from "react" import { useNavigation } from "../lib/navigation/hooks" import { FollowScreen } from "../screens/(modal)/FollowScreen" +const SUPPORTED_WEB_HOSTS = new Set([ + "app.folo.is", + "dev.folo.is", + "folo.is", + "www.folo.is", + "app.follow.is", + "dev.follow.is", + "follow.is", +]) + +type DeepLinkParams = { + id: string | null + type: string | null + url?: string | null + view?: string | null +} + // This needs to stay outside of react to persist between account switches let previousIntentUrl = "" export const resetIntentUrl = () => { @@ -46,59 +63,121 @@ export function useIntentHandler() { // follow://add?type=url&url=rsshub://rsshub/routes/en // follow://list?id=60580187699502080 // follow://feed?id=60580187699502080&view=1 +// https://app.folo.is/share/feeds/60580187699502080 +// https://app.folo.is/share/lists/60580187699502080 +// https://app.folo.is/add?type=feed&id=60580187699502080 const extractParamsFromDeepLink = ( incomingUrl: string | null, -): - | { id: string | null; type: string | null; url?: string | null; view?: string | null } - | "refresh" - | null => { +): DeepLinkParams | "refresh" | null => { if (!incomingUrl) return null try { const url = new URL(incomingUrl) - if (url.protocol !== "follow:" && url.protocol !== "folo:") return null - - switch (url.hostname) { - case "add": { - const { searchParams } = url - if (!searchParams.has("id") && !searchParams.has("url")) return null - - return { - id: searchParams.get("id"), - type: searchParams.get("type"), - url: searchParams.get("url"), - view: searchParams.get("view"), - } - } - case "list": { - const { searchParams } = url - if (!searchParams.has("id") && !searchParams.has("url")) return null - - return { - id: searchParams.get("id"), - type: "list", - view: searchParams.get("view"), - } - } - case "feed": { - const { searchParams } = url - if (!searchParams.has("id") && !searchParams.has("url")) return null - - return { - id: searchParams.get("id"), - type: "feed", - url: searchParams.get("url"), - view: searchParams.get("view"), - } - } - case "refresh": { - return "refresh" - } - default: { - return null - } + + if (url.protocol === "follow:" || url.protocol === "folo:") { + return extractParamsFromSchemeUrl(url) } + + if ( + (url.protocol === "https:" || url.protocol === "http:") && + SUPPORTED_WEB_HOSTS.has(url.hostname) + ) { + return extractParamsFromWebUrl(url) + } + + return null } catch { return null } } + +const extractParamsFromSchemeUrl = (url: URL): DeepLinkParams | "refresh" | null => { + switch (url.hostname) { + case "add": { + return extractParamsFromAddSearchParams(url.searchParams) + } + case "list": { + const { searchParams } = url + if (!searchParams.has("id") && !searchParams.has("url")) return null + + return { + id: searchParams.get("id"), + type: "list", + view: searchParams.get("view"), + } + } + case "feed": { + const { searchParams } = url + if (!searchParams.has("id") && !searchParams.has("url")) return null + + return { + id: searchParams.get("id"), + type: "feed", + url: searchParams.get("url"), + view: searchParams.get("view"), + } + } + case "refresh": { + return "refresh" + } + default: { + return null + } + } +} + +const extractParamsFromWebUrl = (url: URL): DeepLinkParams | "refresh" | null => { + const pathname = normalizePathname(url.pathname) + + if (pathname === "/refresh") { + return "refresh" + } + + if (pathname === "/add") { + return extractParamsFromAddSearchParams(url.searchParams) + } + + const feedMatch = pathname.match(/^\/(?:share\/feeds|feed)\/([^/]+)$/) + if (feedMatch) { + const feedId = feedMatch[1] + if (!feedId) return null + + return { + id: decodeURIComponent(feedId), + type: "feed", + view: url.searchParams.get("view"), + } + } + + const listMatch = pathname.match(/^\/(?:share\/lists|list)\/([^/]+)$/) + if (listMatch) { + const listId = listMatch[1] + if (!listId) return null + + return { + id: decodeURIComponent(listId), + type: "list", + view: url.searchParams.get("view"), + } + } + + return null +} + +const extractParamsFromAddSearchParams = (searchParams: URLSearchParams): DeepLinkParams | null => { + if (!searchParams.has("id") && !searchParams.has("url")) return null + + return { + id: searchParams.get("id"), + type: searchParams.get("type"), + url: searchParams.get("url"), + view: searchParams.get("view"), + } +} + +const normalizePathname = (pathname: string) => { + if (pathname.length > 1 && pathname.endsWith("/")) { + return pathname.slice(0, -1) + } + return pathname +} diff --git a/apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts b/apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts index 19d15818c0..253c7affe5 100644 --- a/apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts +++ b/apps/ssr/client/pages/(main)/share/feeds/[id]/metadata.ts @@ -31,7 +31,7 @@ const meta = defineMetadata(async ({ params, apiClient, origin }) => { { type: "meta", property: "apple-itunes-app", - content: `app-id=${APPLE_APP_STORE_ID}, app-argument=follow://add?id=${feedId}&type=feed`, + content: `app-id=${APPLE_APP_STORE_ID}, app-argument=folo://add?id=${feedId}&type=feed`, }, ] as const }) diff --git a/apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts b/apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts index 9919a2d743..7a5fed2895 100644 --- a/apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts +++ b/apps/ssr/client/pages/(main)/share/lists/[id]/metadata.ts @@ -28,7 +28,7 @@ export default defineMetadata(async ({ params, apiClient, origin }) => { { type: "meta", property: "apple-itunes-app", - content: `app-id=${APPLE_APP_STORE_ID}, app-argument=follow://add?id=${listId}&type=list`, + content: `app-id=${APPLE_APP_STORE_ID}, app-argument=folo://add?id=${listId}&type=list`, }, ] }) diff --git a/apps/ssr/index.html b/apps/ssr/index.html index f3211ea169..5a74d31e16 100644 --- a/apps/ssr/index.html +++ b/apps/ssr/index.html @@ -17,7 +17,7 @@ Folo - + diff --git a/apps/ssr/index.ts b/apps/ssr/index.ts index 638b8fde09..e708882905 100644 --- a/apps/ssr/index.ts +++ b/apps/ssr/index.ts @@ -10,6 +10,7 @@ import Fastify from "fastify" import { nanoid } from "nanoid" import { FetchError } from "ofetch" +import { APPLE_APP_SITE_ASSOCIATION } from "./src/lib/apple-app-site-association" import { MetaError } from "./src/meta-handler" import { globalRoute } from "./src/router/global" import { ogRoute } from "./src/router/og" @@ -60,6 +61,20 @@ export const createApp = async () => { done() }) + app.get("/.well-known/apple-app-site-association", async (_req, reply) => { + return reply + .type("application/json") + .header("Cache-Control", "public, max-age=300") + .send(APPLE_APP_SITE_ASSOCIATION) + }) + + app.get("/apple-app-site-association", async (_req, reply) => { + return reply + .type("application/json") + .header("Cache-Control", "public, max-age=300") + .send(APPLE_APP_SITE_ASSOCIATION) + }) + if (__DEV__) { const devVite = await import("./src/lib/dev-vite") await devVite.registerDevViteServer(app) diff --git a/apps/ssr/src/lib/apple-app-site-association.ts b/apps/ssr/src/lib/apple-app-site-association.ts new file mode 100644 index 0000000000..22dae4d7a5 --- /dev/null +++ b/apps/ssr/src/lib/apple-app-site-association.ts @@ -0,0 +1,11 @@ +export const APPLE_APP_SITE_ASSOCIATION = { + applinks: { + apps: [], + details: [ + { + appID: "492J8Q67PF.is.follow", + paths: ["*"], + }, + ], + }, +} as const diff --git a/apps/ssr/worker-entry.ts b/apps/ssr/worker-entry.ts index dd7dbd50db..29eaedf3f5 100644 --- a/apps/ssr/worker-entry.ts +++ b/apps/ssr/worker-entry.ts @@ -12,6 +12,7 @@ import xss from "xss" import resvgWasm from "./resvg.wasm" // OG image rendering import { createFollowClient } from "./src/lib/api-client" +import { APPLE_APP_SITE_ASSOCIATION } from "./src/lib/apple-app-site-association" import { NotFoundError } from "./src/lib/not-found" import { setFontsBucket } from "./src/lib/og/fonts.worker" import { setWasmModule } from "./src/lib/og/resvg-wasm-shim" @@ -56,6 +57,18 @@ app.get("/profile/:path{.*}", (c) => { return c.redirect(`/share/users/${c.req.param("path")}`, 301) }) +app.get("/.well-known/apple-app-site-association", (c) => { + return c.json(APPLE_APP_SITE_ASSOCIATION, 200, { + "Cache-Control": "public, max-age=300", + }) +}) + +app.get("/apple-app-site-association", (c) => { + return c.json(APPLE_APP_SITE_ASSOCIATION, 200, { + "Cache-Control": "public, max-age=300", + }) +}) + // Middleware: set up env vars and request context app.use("*", async (c, next) => { if (!envInitialized) { diff --git a/apps/ssr/wrangler.jsonc b/apps/ssr/wrangler.jsonc index 513147edf1..4737e1f80a 100644 --- a/apps/ssr/wrangler.jsonc +++ b/apps/ssr/wrangler.jsonc @@ -30,6 +30,14 @@ "pattern": "app.folo.is/*", "zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63", }, + { + "pattern": "folo.is/.well-known/apple-app-site-association*", + "zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63", + }, + { + "pattern": "folo.is/apple-app-site-association*", + "zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63", + }, ], "r2_buckets": [ { From a9e2cb46894194b2c85759e77ddf167ee9996d83 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 24 Feb 2026 18:47:16 +0800 Subject: [PATCH 2/2] fix(ci): remove iOS associated domains entitlement --- apps/mobile/app.config.ts | 1 - apps/mobile/ios/Folo/Folo.entitlements | 6 ------ 2 files changed, 7 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 712588f816..8413bbe266 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -49,7 +49,6 @@ export default ({ config }: ConfigContext): ExpoConfig => { ios: { supportsTablet: true, bundleIdentifier: "is.follow", - associatedDomains: ["applinks:folo.is", "applinks:app.folo.is", "applinks:dev.folo.is"], usesAppleSignIn: true, infoPlist: { LSApplicationCategoryType: "public.app-category.news", diff --git a/apps/mobile/ios/Folo/Folo.entitlements b/apps/mobile/ios/Folo/Folo.entitlements index 23a9932d0a..c459a51605 100644 --- a/apps/mobile/ios/Folo/Folo.entitlements +++ b/apps/mobile/ios/Folo/Folo.entitlements @@ -8,11 +8,5 @@ Default - com.apple.developer.associated-domains - - applinks:folo.is - applinks:app.folo.is - applinks:dev.folo.is -