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/ios/Folo/Folo.entitlements b/apps/mobile/ios/Folo/Folo.entitlements
index 3f2ea618bc..c459a51605 100644
--- a/apps/mobile/ios/Folo/Folo.entitlements
+++ b/apps/mobile/ios/Folo/Folo.entitlements
@@ -9,4 +9,4 @@
Default
-
\ 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": [
{