From 5bf010e8048ce699e584eb034674b0796bcd7d4d Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 15 Apr 2026 11:20:31 +0800 Subject: [PATCH 01/19] fix(ota): harden store sync and observability --- apps/desktop/wrangler.jsonc | 9 ++ apps/landing/wrangler.jsonc | 9 ++ apps/ota/src/__tests__/policy.test.ts | 39 +++++ apps/ota/src/__tests__/sync.test.ts | 223 ++++++++++++++++++++++++++ apps/ota/src/lib/store-version.ts | 70 +++++--- apps/ota/src/lib/sync.ts | 35 +++- apps/ota/wrangler.jsonc | 9 ++ apps/ssr/wrangler.jsonc | 9 ++ 8 files changed, 376 insertions(+), 27 deletions(-) diff --git a/apps/desktop/wrangler.jsonc b/apps/desktop/wrangler.jsonc index 8bd1786d6..bdd94461f 100644 --- a/apps/desktop/wrangler.jsonc +++ b/apps/desktop/wrangler.jsonc @@ -2,6 +2,15 @@ "$schema": "../../node_modules/wrangler/config-schema.json", "name": "folo-web", "compatibility_date": "2026-02-01", + "observability": { + "logs": { + "enabled": true, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + }, + }, "account_id": "1f1d1678a2413a54c944b3081bab5c84", "assets": { "directory": "./out/web", diff --git a/apps/landing/wrangler.jsonc b/apps/landing/wrangler.jsonc index c43b19a8a..48c2ae982 100644 --- a/apps/landing/wrangler.jsonc +++ b/apps/landing/wrangler.jsonc @@ -4,6 +4,15 @@ "main": "./worker/index.js", "compatibility_date": "2026-02-01", "compatibility_flags": ["nodejs_compat"], + "observability": { + "logs": { + "enabled": true, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + }, + }, "account_id": "1f1d1678a2413a54c944b3081bab5c84", "assets": { "directory": "./dist/client", diff --git a/apps/ota/src/__tests__/policy.test.ts b/apps/ota/src/__tests__/policy.test.ts index dfd6a584a..7d33be0d2 100644 --- a/apps/ota/src/__tests__/policy.test.ts +++ b/apps/ota/src/__tests__/policy.test.ts @@ -143,6 +143,45 @@ describe("/policy", () => { }) }) + it("falls back to the iOS App Store page when the Apple lookup API fails", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url === "https://itunes.apple.com/lookup?id=6739802604") { + throw new Error("lookup unavailable") + } + + if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") { + return new Response( + '', + { status: 200, headers: { "content-type": "text/html" } }, + ) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + const response = await fetchWorker( + "/policy?product=mobile&platform=ios&channel=production&installedBinaryVersion=0.4.1", + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + action: "prompt", + targetVersion: "0.4.4", + message: null, + }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[ota] Apple lookup request failed for iOS store version, falling back", + expect.any(Error), + ) + }) + it("uses the cached iOS store version when KV is populated", async () => { const fetchSpy = vi.fn() vi.stubGlobal("fetch", fetchSpy) diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index 504f00445..22ed81c68 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -1687,6 +1687,229 @@ describe("syncStoreVersions", () => { ) expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String)) }) + + it("falls back to the iOS App Store page when the Apple lookup payload has no version", async () => { + const kvEntries = new Map() + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = String(input) + + if (url === "https://itunes.apple.com/lookup?id=6739802604") { + return new Response( + JSON.stringify({ + results: [{}], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + + if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") { + return new Response( + '', + { + status: 200, + headers: { "Content-Type": "text/html" }, + }, + ) + } + + if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") { + return new Response('[[["0.4.1"]],[[[36]]]', { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + } + + if ( + url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac" + ) { + return new Response('{"primarySubtitle":"Version 1.5.0"}', { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + } + + if ( + url === + "https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop" + ) { + return new Response( + JSON.stringify({ + Payload: { + Skus: [ + { + FulfillmentData: JSON.stringify({ + WuCategoryId: "wu-category-id", + PackageFamilyName: + "NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123", + }), + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + + if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") { + const body = String(_init?.body ?? "") + + if (body.includes("GetCookie")) { + return new Response("cookie-value", { + status: 200, + headers: { "Content-Type": "application/soap+xml" }, + }) + } + + return new Response( + '<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">', + { + status: 200, + headers: { "Content-Type": "application/soap+xml" }, + }, + ) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await syncStoreVersions(createEnv({ kvEntries })) + + expect(JSON.parse(String(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))))).toEqual( + expect.objectContaining({ + version: "0.5.0", + source: "app-store", + }), + ) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[ota] Apple lookup response did not include an iOS version, falling back", + ) + }) + + it("logs and skips failed storefronts while persisting successful updates", async () => { + const kvEntries = new Map() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { + const url = String(input) + + if (url === "https://itunes.apple.com/lookup?id=6739802604") { + throw new Error("lookup unavailable") + } + + if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") { + return new Response("service unavailable", { + status: 503, + headers: { "Content-Type": "text/html" }, + }) + } + + if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") { + return new Response('[[["0.4.1"]],[[[36]]]', { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + } + + if ( + url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac" + ) { + return new Response('{"primarySubtitle":"Version 1.5.0"}', { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + } + + if ( + url === + "https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop" + ) { + return new Response( + JSON.stringify({ + Payload: { + Skus: [ + { + FulfillmentData: JSON.stringify({ + WuCategoryId: "wu-category-id", + PackageFamilyName: + "NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123", + }), + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + + if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") { + const body = String(_init?.body ?? "") + + if (body.includes("GetCookie")) { + return new Response("cookie-value", { + status: 200, + headers: { "Content-Type": "application/soap+xml" }, + }) + } + + return new Response( + '<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">', + { + status: 200, + headers: { "Content-Type": "application/soap+xml" }, + }, + ) + } + + throw new Error(`Unhandled fetch URL: ${url}`) + }), + ) + + await expect(syncStoreVersions(createEnv({ kvEntries }))).resolves.toBeUndefined() + + expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))).toBeUndefined() + expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "android"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mas"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mss"))).toEqual(expect.any(String)) + expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[ota] Failed to refresh store version for mobile:ios", + expect.any(Error), + ) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[ota] Store version sync completed with 1 failure(s); refreshed 3/4 providers", + ) + }) + + it("fails when every storefront refresh fails", async () => { + const kvEntries = new Map() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network unavailable") + }), + ) + + await expect(syncStoreVersions(createEnv({ kvEntries }))).rejects.toThrow( + "Failed to refresh all store versions", + ) + + expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toBeUndefined() + expect(consoleErrorSpy).toHaveBeenCalledTimes(4) + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Store version sync completed with"), + ) + }) }) describe("internal routes", () => { diff --git a/apps/ota/src/lib/store-version.ts b/apps/ota/src/lib/store-version.ts index b61fa6ed5..81e4f01ee 100644 --- a/apps/ota/src/lib/store-version.ts +++ b/apps/ota/src/lib/store-version.ts @@ -7,7 +7,9 @@ const MICROSOFT_STORE_MARKET = "US" const MICROSOFT_STORE_LOCALE = "en-US" const MICROSOFT_STORE_RING = "Retail" const PLAY_VERSION_PATTERN = /\[\[\["(\d+\.\d+\.\d+)"\]\],\[\[\[36\]\]/ -const APPLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/ +const APPLE_LOOKUP_URL = `https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}` +const APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/ +const APPLE_TEXT_VERSION_PATTERN = /\bVersion (\d+\.\d+\.\d+)\b/ const MICROSOFT_VERSION_PATTERN = /^\d+(?:\.\d+)+$/ export type MobileStorePlatform = "ios" | "android" @@ -74,21 +76,31 @@ export function getDesktopStoreUrl(distribution: DesktopDistribution) { } async function fetchIosStoreVersion() { - const response = await fetch(`https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}`, { - headers: STORE_HEADERS, - }) - if (!response.ok) { - throw new Error(`App Store lookup failed (${response.status})`) + try { + const response = await fetch(APPLE_LOOKUP_URL, { + headers: STORE_HEADERS, + }) + if (!response.ok) { + throw new Error(`App Store lookup failed (${response.status})`) + } + + const payload = (await response.json()) as { + results?: Array<{ + version?: unknown + }> + } + + const version = payload.results?.[0]?.version + if (typeof version === "string") { + return version + } + + console.warn("[ota] Apple lookup response did not include an iOS version, falling back") + } catch (error) { + console.warn("[ota] Apple lookup request failed for iOS store version, falling back", error) } - const payload = (await response.json()) as { - results?: Array<{ - version?: unknown - }> - } - - const version = payload.results?.[0]?.version - return typeof version === "string" ? version : null + return fetchAppleStorefrontVersion(STORE_URLS.ios, "iOS App Store storefront request") } async function fetchGooglePlayVersion() { @@ -104,15 +116,7 @@ async function fetchGooglePlayVersion() { } async function fetchMacAppStoreVersion() { - const response = await fetch(STORE_URLS.mas, { - headers: STORE_HEADERS, - }) - if (!response.ok) { - throw new Error(`Mac App Store storefront request failed (${response.status})`) - } - - const html = await response.text() - return html.match(APPLE_VERSION_PATTERN)?.[1] ?? null + return fetchAppleStorefrontVersion(STORE_URLS.mas, "Mac App Store storefront request") } async function fetchMicrosoftStoreVersion() { @@ -186,6 +190,26 @@ async function fetchJson(url: string, context: string): Promise { return (await response.json()) as T } +async function fetchAppleStorefrontVersion(url: string, context: string) { + const response = await fetch(url, { + headers: STORE_HEADERS, + }) + if (!response.ok) { + throw new Error(`${context} failed (${response.status})`) + } + + const html = await response.text() + return extractAppleStoreVersion(html) +} + +function extractAppleStoreVersion(html: string) { + return ( + html.match(APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN)?.[1] ?? + html.match(APPLE_TEXT_VERSION_PATTERN)?.[1] ?? + null + ) +} + function resolveMicrosoftFulfillmentData(payload: { Payload?: { Skus?: Array<{ diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts index 5f9b2ea3b..7e0485360 100644 --- a/apps/ota/src/lib/sync.ts +++ b/apps/ota/src/lib/sync.ts @@ -130,11 +130,38 @@ async function runSyncStoreVersions(env: Env) { }), ) - const failures = results.filter((result) => result.status === "rejected") - if (failures.length > 0) { + const failures = results.flatMap((result, index) => { + const task = syncTasks[index] + + if (!task || result.status === "fulfilled") { + return [] + } + + return [ + { + task, + reason: result.reason, + }, + ] + }) + + for (const failure of failures) { + console.error( + `[ota] Failed to refresh store version for ${failure.task.product}:${failure.task.target}`, + failure.reason, + ) + } + + if (failures.length === syncTasks.length) { throw new AggregateError( - failures.map((result) => (result as PromiseRejectedResult).reason), - "Failed to refresh one or more store versions", + failures.map((failure) => failure.reason), + "Failed to refresh all store versions", + ) + } + + if (failures.length > 0) { + console.warn( + `[ota] Store version sync completed with ${failures.length} failure(s); refreshed ${syncTasks.length - failures.length}/${syncTasks.length} providers`, ) } diff --git a/apps/ota/wrangler.jsonc b/apps/ota/wrangler.jsonc index f44a76306..bcb63e917 100644 --- a/apps/ota/wrangler.jsonc +++ b/apps/ota/wrangler.jsonc @@ -4,6 +4,15 @@ "main": "src/index.ts", "compatibility_date": "2026-04-10", "compatibility_flags": ["nodejs_compat"], + "observability": { + "logs": { + "enabled": true, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + }, + }, "account_id": "1f1d1678a2413a54c944b3081bab5c84", "workers_dev": true, "routes": [ diff --git a/apps/ssr/wrangler.jsonc b/apps/ssr/wrangler.jsonc index 160c44cc5..97a7676b9 100644 --- a/apps/ssr/wrangler.jsonc +++ b/apps/ssr/wrangler.jsonc @@ -4,6 +4,15 @@ "main": "dist/worker/worker-entry.mjs", "compatibility_date": "2026-02-01", "compatibility_flags": ["nodejs_compat"], + "observability": { + "logs": { + "enabled": true, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + }, + }, "account_id": "1f1d1678a2413a54c944b3081bab5c84", "placement": { "mode": "smart", From a3577c4c4c2adc2375f76f63241f7527cd9911a1 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 15 Apr 2026 11:38:27 +0800 Subject: [PATCH 02/19] fix(ota): restore desktop binary manifest updates --- apps/ota/src/__tests__/manifest.test.ts | 60 ++++++++ apps/ota/src/lib/desktop.ts | 14 +- apps/ota/src/routes/manifest.ts | 197 +++++++++++++++++------- 3 files changed, 214 insertions(+), 57 deletions(-) diff --git a/apps/ota/src/__tests__/manifest.test.ts b/apps/ota/src/__tests__/manifest.test.ts index c073946b8..2653162f3 100644 --- a/apps/ota/src/__tests__/manifest.test.ts +++ b/apps/ota/src/__tests__/manifest.test.ts @@ -323,6 +323,66 @@ describe("/manifest", () => { }) }) + it("returns app payloads for desktop direct binary builds without an OTA pointer", async () => { + const response = await fetchWorker( + "/manifest", + { + headers: { + "x-app-platform": "desktop/windows/exe", + "x-app-version": "1.6.0", + "x-app-runtime-version": "1.6.0", + "x-app-renderer-version": "1.6.0", + "x-app-channel": "stable", + }, + }, + { + kvEntries: new Map([ + [ + KV_KEYS.policy("desktop", "stable"), + { + releaseVersion: "1.6.1", + required: false, + minSupportedBinaryVersion: "1.6.1", + message: null, + publishedAt: "2026-04-15T02:54:57.862Z", + distribution: null, + downloadUrl: null, + storeUrl: null, + }, + ], + [ + KV_KEYS.release("desktop", "1.6.1"), + createDesktopRelease({ + releaseVersion: "1.6.1", + releaseKind: "binary", + runtimeVersion: null, + desktop: { + renderer: null, + app: createDesktopRelease().desktop.app, + }, + }), + ], + ]), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + product: "desktop", + channel: "stable", + runtimeVersion: "1.6.0", + renderer: null, + app: { + platform: "windows-x64", + version: "1.6.1", + releaseVersion: "1.6.1", + manifest: { + name: "latest.yml", + }, + }, + }) + }) + it("returns only renderer for desktop store distributions", async () => { const response = await fetchWorker( "/manifest", diff --git a/apps/ota/src/lib/desktop.ts b/apps/ota/src/lib/desktop.ts index 593746c6d..b07e120e4 100644 --- a/apps/ota/src/lib/desktop.ts +++ b/apps/ota/src/lib/desktop.ts @@ -10,15 +10,19 @@ export function buildDesktopManifestResponse( distribution: "direct" | "mas" | "mss" installedBinaryVersion: string | null rendererVersion: string | null + runtimeVersion: string }, ) { - const rendererManifest = buildManifest(release, { - origin: input.origin, - platform: input.platform, - }) + const rendererManifest = release.desktop.renderer + ? buildManifest(release, { + origin: input.origin, + platform: input.platform, + }) + : null const renderer = release.desktop.renderer && + rendererManifest && isVersionOutdated(input.rendererVersion, release.desktop.renderer.version) ? { releaseVersion: release.releaseVersion, @@ -53,7 +57,7 @@ export function buildDesktopManifestResponse( createdAt: release.publishedAt, product: "desktop" as const, channel: release.channel, - runtimeVersion: release.runtimeVersion, + runtimeVersion: release.runtimeVersion ?? input.runtimeVersion, renderer, app, } diff --git a/apps/ota/src/routes/manifest.ts b/apps/ota/src/routes/manifest.ts index 2df8f35a7..88a8b5d9c 100644 --- a/apps/ota/src/routes/manifest.ts +++ b/apps/ota/src/routes/manifest.ts @@ -5,10 +5,15 @@ import type { Env } from "../env" import { createExpoSignatureHeader, OtaCodeSigningError } from "../lib/code-signing" import { KV_KEYS } from "../lib/constants" import { buildDesktopManifestResponse, isDesktopRelease } from "../lib/desktop" -import { getLatestReleasePointer } from "../lib/kv" +import { getBinaryPolicyRecord, getLatestReleasePointer } from "../lib/kv" import { buildManifest } from "../lib/manifest" import { parseDesktopRequest } from "../lib/request" -import type { OtaPlatform, OtaProjectedPlatforms, OtaRelease } from "../lib/schema" +import type { + DesktopOtaRelease, + OtaPlatform, + OtaProjectedPlatforms, + OtaRelease, +} from "../lib/schema" import { otaReleaseSchema } from "../lib/schema" const OTA_PLATFORMS: readonly OtaPlatform[] = ["ios", "android", "macos", "windows", "linux"] @@ -40,56 +45,25 @@ manifestRoute.get("/manifest", async (c) => { return c.json({ error: "Missing x-app-channel header" }, 400) } - const pointerRecord = await getLatestReleasePointer(c.env.OTA_KV, { - product: "desktop", - channel: desktopRequest.channel, - runtimeVersion: desktopRequest.runtimeVersion, - platform: desktopRequest.platform, - }) - - if (!pointerRecord) { - return c.body(null, 204) - } - - const parsedPointer = latestReleasePointerSchema.safeParse(pointerRecord) - - if (!parsedPointer.success) { - return c.body(null, 204) - } - - const releaseRecord = await c.env.OTA_KV.get( - KV_KEYS.release("desktop", parsedPointer.data.releaseVersion), - "json", - ) - - if (!releaseRecord) { - return c.body(null, 204) - } - - const parsedRelease = otaReleaseSchema.safeParse(releaseRecord) - - if (!parsedRelease.success || !isDesktopRelease(parsedRelease.data)) { - return c.body(null, 204) - } - - const release = parsedRelease.data - - if ( - release.releaseKind !== "ota" || - release.channel !== desktopRequest.channel || - release.releaseVersion !== parsedPointer.data.releaseVersion || - release.runtimeVersion !== desktopRequest.runtimeVersion - ) { - return c.body(null, 204) - } - - const payload = buildDesktopManifestResponse(release, { - origin: new URL(c.req.url).origin, - platform: desktopRequest.platform, - distribution, - installedBinaryVersion: desktopRequest.installedBinaryVersion, - rendererVersion: desktopRequest.rendererVersion, - }) + const payload = + (await resolveDesktopOtaManifestPayload(c.env.OTA_KV, { + channel: desktopRequest.channel, + platform: desktopRequest.platform, + distribution, + runtimeVersion: desktopRequest.runtimeVersion, + installedBinaryVersion: desktopRequest.installedBinaryVersion, + rendererVersion: desktopRequest.rendererVersion, + origin: new URL(c.req.url).origin, + })) ?? + (await resolveDesktopBinaryManifestPayload(c.env.OTA_KV, { + channel: desktopRequest.channel, + platform: desktopRequest.platform, + distribution, + runtimeVersion: desktopRequest.runtimeVersion, + installedBinaryVersion: desktopRequest.installedBinaryVersion, + rendererVersion: desktopRequest.rendererVersion, + origin: new URL(c.req.url).origin, + })) if (!payload) { return c.body(null, 204) @@ -216,6 +190,125 @@ manifestRoute.get("/manifest", async (c) => { }) }) +async function resolveDesktopOtaManifestPayload( + kv: KVNamespace, + input: { + channel: string + platform: "macos" | "windows" | "linux" + distribution: "direct" | "mas" | "mss" + runtimeVersion: string + installedBinaryVersion: string | null + rendererVersion: string | null + origin: string + }, +) { + const pointerRecord = await getLatestReleasePointer(kv, { + product: "desktop", + channel: input.channel, + runtimeVersion: input.runtimeVersion, + platform: input.platform, + }) + + if (!pointerRecord) { + return null + } + + const parsedPointer = latestReleasePointerSchema.safeParse(pointerRecord) + if (!parsedPointer.success) { + return null + } + + const release = await getDesktopRelease(kv, parsedPointer.data.releaseVersion) + if (!release) { + return null + } + + if ( + release.releaseKind !== "ota" || + release.channel !== input.channel || + release.releaseVersion !== parsedPointer.data.releaseVersion || + release.runtimeVersion !== input.runtimeVersion + ) { + return null + } + + return buildDesktopManifestResponse(release, { + origin: input.origin, + platform: input.platform, + distribution: input.distribution, + installedBinaryVersion: input.installedBinaryVersion, + rendererVersion: input.rendererVersion, + runtimeVersion: input.runtimeVersion, + }) +} + +async function resolveDesktopBinaryManifestPayload( + kv: KVNamespace, + input: { + channel: string + platform: "macos" | "windows" | "linux" + distribution: "direct" | "mas" | "mss" + runtimeVersion: string + installedBinaryVersion: string | null + rendererVersion: string | null + origin: string + }, +) { + if (input.distribution !== "direct") { + return null + } + + const policyRecord = + (await getBinaryPolicyRecord(kv, { + product: "desktop", + channel: input.channel, + distribution: "direct", + })) ?? + (await getBinaryPolicyRecord(kv, { + product: "desktop", + channel: input.channel, + })) + + if (!policyRecord) { + return null + } + + const release = await getDesktopRelease(kv, policyRecord.releaseVersion) + if (!release) { + return null + } + + if (release.releaseKind !== "binary" || release.channel !== input.channel) { + return null + } + + return buildDesktopManifestResponse(release, { + origin: input.origin, + platform: input.platform, + distribution: input.distribution, + installedBinaryVersion: input.installedBinaryVersion, + rendererVersion: input.rendererVersion, + runtimeVersion: input.runtimeVersion, + }) +} + +async function getDesktopRelease( + kv: KVNamespace, + releaseVersion: string, +): Promise { + const releaseRecord = await kv.get(KV_KEYS.release("desktop", releaseVersion), "json") + if (!releaseRecord) { + return null + } + + const parsedRelease = otaReleaseSchema.safeParse(releaseRecord) + if (!parsedRelease.success || !isDesktopRelease(parsedRelease.data)) { + return null + } + + return parsedRelease.data +} + function parsePlatform(value: string | undefined): OtaPlatform | null { return OTA_PLATFORMS.find((platform) => platform === value) ?? null } From 34fd039ff9dd23b97943b52049df8d90becda66e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 15 Apr 2026 15:44:01 +0800 Subject: [PATCH 03/19] fix(ci): restore release workflow triggers --- .github/workflows/build-android.yml | 2 +- .github/workflows/build-desktop.yml | 2 +- .github/workflows/build-ios.yml | 2 +- .github/workflows/sync.yaml | 37 ++++++++++++++++++++++++----- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 0590558f6..84618de2c 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -28,7 +28,7 @@ concurrency: jobs: build: name: Build Android apk for device - if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):')) + if: github.secret_source != 'None' && (github.event_name != 'push' || github.ref != 'refs/heads/mobile-main' || !contains(github.event.head_commit.message || '', 'release(mobile):')) runs-on: ubuntu-latest steps: diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 32720d405..6aa828fc3 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -39,7 +39,7 @@ env: jobs: release: - if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(desktop):')) + if: github.secret_source != 'None' && (github.event_name != 'push' || github.ref != 'refs/heads/main' || !contains(github.event.head_commit.message || '', 'release(desktop):')) runs-on: ${{ matrix.os }} env: PROD: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' || github.event.inputs.store == 'true' }} diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 3bbfafa9c..ab3a25e85 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -23,7 +23,7 @@ concurrency: jobs: check-runner: - if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):')) + if: github.secret_source != 'None' && (github.event_name != 'push' || github.ref != 'refs/heads/mobile-main' || !contains(github.event.head_commit.message || '', 'release(mobile):')) runs-on: ubuntu-latest outputs: runner-label: ${{ steps.set-runner.outputs.runner-label }} diff --git a/.github/workflows/sync.yaml b/.github/workflows/sync.yaml index 4ba68e2a1..c0b6364b0 100644 --- a/.github/workflows/sync.yaml +++ b/.github/workflows/sync.yaml @@ -17,11 +17,36 @@ jobs: (github.ref == 'refs/heads/main' && contains(github.event.head_commit.message || '', 'release(desktop):')) || (github.ref == 'refs/heads/mobile-main' && contains(github.event.head_commit.message || '', 'release(mobile):')) steps: - - name: Create or update sync pull request + - name: Check whether source branch is ahead of dev + id: compare env: GH_TOKEN: ${{ github.token }} + shell: bash run: | + set -euo pipefail + source_branch="${GITHUB_REF_NAME}" + compare_ref="$(printf '%s' "dev...${source_branch}" | jq -sRr @uri)" + ahead_by="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_ref}" --jq '.ahead_by')" + + echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT" + echo "ahead_by=${ahead_by}" >> "$GITHUB_OUTPUT" + + if [ "$ahead_by" -eq 0 ]; then + echo "No commits to sync from ${source_branch} into dev." + else + echo "${source_branch} is ${ahead_by} commit(s) ahead of dev." + fi + + - name: Create or update sync pull request + if: steps.compare.outputs.ahead_by != '0' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + source_branch="${{ steps.compare.outputs.source_branch }}" title="chore(sync): merge ${source_branch} into dev" body=$(cat < Date: Wed, 15 Apr 2026 15:57:51 +0800 Subject: [PATCH 04/19] fix(desktop): derive MAS review state from OTA versions --- .../renderer/src/atoms/server-configs.ts | 31 +++++--- .../src/lib/__tests__/mas-review.test.ts | 73 +++++++++++++++++++ .../layer/renderer/src/lib/mas-review.ts | 45 ++++++++++++ .../src/providers/server-configs-provider.tsx | 9 ++- .../renderer/src/queries/ota-versions.ts | 25 +++++++ 5 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/lib/__tests__/mas-review.test.ts create mode 100644 apps/desktop/layer/renderer/src/lib/mas-review.ts create mode 100644 apps/desktop/layer/renderer/src/queries/ota-versions.ts diff --git a/apps/desktop/layer/renderer/src/atoms/server-configs.ts b/apps/desktop/layer/renderer/src/atoms/server-configs.ts index 96b342b52..861d10225 100644 --- a/apps/desktop/layer/renderer/src/atoms/server-configs.ts +++ b/apps/desktop/layer/renderer/src/atoms/server-configs.ts @@ -4,6 +4,7 @@ import PKG from "@pkg" import { atomWithStorage } from "jotai/utils" import { createAtomHooks } from "~/lib/jotai" +import { isLocalMASVersionInReview } from "~/lib/mas-review" export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks( atomWithStorage>>( @@ -16,26 +17,32 @@ export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = crea ), ) +export const [, , useMASStoreVersion, , getMASStoreVersion, setMASStoreVersion] = createAtomHooks( + atomWithStorage(getStorageNS("mas-store-version"), null, undefined, { + getOnInit: true, + }), +) + export type ServerConfigs = ExtractResponseData export type PaymentPlan = ServerConfigs["PAYMENT_PLAN_LIST"][number] export type PaymentFeature = PaymentPlan["limit"] export const useIsInMASReview = () => { - const serverConfigs = useServerConfigs() - return ( - typeof process !== "undefined" && - process.mas && - serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version - ) + const masStoreVersion = useMASStoreVersion() + return isLocalMASVersionInReview({ + isMASBuild: typeof process !== "undefined" && !!process.mas, + localVersion: PKG.version, + storeVersion: masStoreVersion, + }) } export const getIsInMASReview = () => { - const serverConfigs = getServerConfigs() - return ( - typeof process !== "undefined" && - process.mas && - serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version - ) + const masStoreVersion = getMASStoreVersion() + return isLocalMASVersionInReview({ + isMASBuild: typeof process !== "undefined" && !!process.mas, + localVersion: PKG.version, + storeVersion: masStoreVersion, + }) } export const useIsPaymentEnabled = () => { diff --git a/apps/desktop/layer/renderer/src/lib/__tests__/mas-review.test.ts b/apps/desktop/layer/renderer/src/lib/__tests__/mas-review.test.ts new file mode 100644 index 000000000..3f85b17ad --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/__tests__/mas-review.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" + +import { getMASStoreVersionFromOTAVersions, isLocalMASVersionInReview } from "../mas-review" + +describe("getMASStoreVersionFromOTAVersions", () => { + it("reads the MAS version from the OTA payload", () => { + expect( + getMASStoreVersionFromOTAVersions({ + store: { + desktop: { + mas: { + version: "1.6.0", + }, + }, + }, + }), + ).toBe("1.6.0") + }) + + it("returns null when the OTA payload has no MAS version", () => { + expect(getMASStoreVersionFromOTAVersions({})).toBeNull() + }) +}) + +describe("isLocalMASVersionInReview", () => { + it("returns true when the local MAS build is newer than the store version", () => { + expect( + isLocalMASVersionInReview({ + isMASBuild: true, + localVersion: "1.6.1", + storeVersion: "1.6.0", + }), + ).toBe(true) + }) + + it("returns false when the local version matches the store version", () => { + expect( + isLocalMASVersionInReview({ + isMASBuild: true, + localVersion: "1.6.1", + storeVersion: "1.6.1", + }), + ).toBe(false) + }) + + it("returns false when the build is not a MAS build", () => { + expect( + isLocalMASVersionInReview({ + isMASBuild: false, + localVersion: "1.6.1", + storeVersion: "1.6.0", + }), + ).toBe(false) + }) + + it("returns false when the store version is missing or invalid", () => { + expect( + isLocalMASVersionInReview({ + isMASBuild: true, + localVersion: "1.6.1", + storeVersion: null, + }), + ).toBe(false) + + expect( + isLocalMASVersionInReview({ + isMASBuild: true, + localVersion: "1.6.1", + storeVersion: "latest", + }), + ).toBe(false) + }) +}) diff --git a/apps/desktop/layer/renderer/src/lib/mas-review.ts b/apps/desktop/layer/renderer/src/lib/mas-review.ts new file mode 100644 index 000000000..f1ac14c39 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/mas-review.ts @@ -0,0 +1,45 @@ +import { gt, valid } from "semver" + +export interface OTAVersionsResponse { + store?: { + desktop?: { + mas?: { + version?: null | string + } + } + } +} + +const normalizeVersion = (version?: null | string) => { + if (!version) { + return null + } + + return valid(version.trim()) +} + +export const getMASStoreVersionFromOTAVersions = (payload: OTAVersionsResponse) => + payload.store?.desktop?.mas?.version ?? null + +export const isLocalMASVersionInReview = ({ + isMASBuild, + localVersion, + storeVersion, +}: { + isMASBuild: boolean + localVersion: string + storeVersion?: null | string +}) => { + if (!isMASBuild) { + return false + } + + const normalizedLocalVersion = normalizeVersion(localVersion) + const normalizedStoreVersion = normalizeVersion(storeVersion) + + if (!normalizedLocalVersion || !normalizedStoreVersion) { + return false + } + + return gt(normalizedLocalVersion, normalizedStoreVersion) +} diff --git a/apps/desktop/layer/renderer/src/providers/server-configs-provider.tsx b/apps/desktop/layer/renderer/src/providers/server-configs-provider.tsx index 9e8a70443..e89a7aa03 100644 --- a/apps/desktop/layer/renderer/src/providers/server-configs-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/server-configs-provider.tsx @@ -1,11 +1,13 @@ import { useEffect } from "react" -import { setServerConfigs } from "~/atoms/server-configs" +import { setMASStoreVersion, setServerConfigs } from "~/atoms/server-configs" import { syncServerShortcuts } from "~/atoms/settings/ai" +import { useMASStoreVersionQuery } from "~/queries/ota-versions" import { useServerConfigsQuery } from "~/queries/server-configs" export const ServerConfigsProvider = () => { const serverConfigs = useServerConfigsQuery() + const masStoreVersion = useMASStoreVersionQuery() useEffect(() => { if (!serverConfigs) return @@ -13,5 +15,10 @@ export const ServerConfigsProvider = () => { syncServerShortcuts(serverConfigs.AI_SHORTCUTS) }, [serverConfigs]) + useEffect(() => { + if (masStoreVersion === undefined) return + setMASStoreVersion(masStoreVersion) + }, [masStoreVersion]) + return null } diff --git a/apps/desktop/layer/renderer/src/queries/ota-versions.ts b/apps/desktop/layer/renderer/src/queries/ota-versions.ts new file mode 100644 index 000000000..39ced69b7 --- /dev/null +++ b/apps/desktop/layer/renderer/src/queries/ota-versions.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query" +import { ofetch } from "ofetch" + +import type { OTAVersionsResponse } from "~/lib/mas-review" +import { getMASStoreVersionFromOTAVersions } from "~/lib/mas-review" + +const OTA_VERSIONS_URL = "https://ota.folo.is/versions" + +const isMASBuild = () => typeof process !== "undefined" && !!process.mas + +export const useMASStoreVersionQuery = () => { + const { data } = useQuery({ + queryKey: ["ota-versions", "store", "desktop", "mas"], + queryFn: async () => { + const response = await ofetch(OTA_VERSIONS_URL, { + cache: "no-store", + }) + + return getMASStoreVersionFromOTAVersions(response) + }, + enabled: isMASBuild(), + }) + + return data +} From 08c4615d0897aa6d692291771e6c1bd68d845f4d Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 15 Apr 2026 16:17:19 +0800 Subject: [PATCH 05/19] fix(readme): remove mobile version badge from README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index eec807bf5..47ddfda5b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ -

From b5c4ec96ef62260da217faf63859a10ac424abe5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:28:36 +0800 Subject: [PATCH 06/19] Merge pull request #4975 from RSSNext/dependabot/github_actions/dev/signpath/github-action-submit-signing-request-2.2 build(deps): bump signpath/github-action-submit-signing-request from 2.1 to 2.2 --- .github/workflows/build-desktop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6aa828fc3..1f9f633cf 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -278,7 +278,7 @@ jobs: apps/desktop/out/make/**/latest.yml retention-days: 90 - - uses: signpath/github-action-submit-signing-request@v2.1 + - uses: signpath/github-action-submit-signing-request@v2.2 continue-on-error: true if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true' with: From 3191794f82de75fc1e5305f17e5338e4dd75ab1f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sat, 18 Apr 2026 11:41:24 +0800 Subject: [PATCH 07/19] fix(ci): build release apk with production profile --- .github/workflows/build-android.yml | 10 ++++++++++ .github/workflows/tag.yml | 6 +++--- apps/mobile/eas.json | 7 +++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 84618de2c..534c43535 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -14,6 +14,7 @@ on: default: preview options: - preview + - production-apk - production description: "Build profile" release: @@ -75,6 +76,15 @@ jobs: - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer + - name: Validate release profile + if: github.event.inputs.release == 'true' + run: | + profile="${{ github.event.inputs.profile || 'preview' }}" + if [ "$profile" != "production-apk" ]; then + echo "GitHub Release APKs must use the production-apk profile." + exit 1 + fi + - name: 🔨 Build Android app working-directory: apps/mobile run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.${{ github.event.inputs.profile == 'production' && 'aab' || 'apk' }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index cce84ddf8..a37a6d53b 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -167,7 +167,7 @@ jobs: }); console.log('Desktop store build triggered successfully'); - - name: Trigger Mobile Preview Release Build + - name: Trigger Mobile Production APK Release Build if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' && steps.release_mode.outputs.trigger_store_builds == 'true' uses: actions/github-script@v9 with: @@ -179,11 +179,11 @@ jobs: workflow_id: 'build-android.yml', ref: 'mobile-main', inputs: { - profile: 'preview', + profile: 'production-apk', release: 'true' } }); - console.log('Mobile preview release build triggered successfully'); + console.log('Mobile production APK release build triggered successfully'); - name: Trigger Mobile Production Android Build if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' && steps.release_mode.outputs.trigger_store_builds == 'true' diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index c8085e043..a5760db4d 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -53,6 +53,13 @@ "env": { "PROFILE": "production" } + }, + "production-apk": { + "extends": "production", + "distribution": "internal", + "android": { + "buildType": "apk" + } } }, "submit": { From d3aa46e358ca54e3f8871173f0a47e62ba22d03a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 24 Apr 2026 12:51:05 +0800 Subject: [PATCH 08/19] fix: extend API request timeout --- apps/desktop/layer/renderer/src/lib/api-client.ts | 2 +- apps/mobile/src/lib/api-client.ts | 2 +- apps/ssr/client/lib/api-fetch.ts | 2 +- apps/ssr/src/lib/api-client.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index 21d29d740..66f12d758 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -13,7 +13,7 @@ import { getAuthSessionToken, getClientId, getSessionId } from "./client-session export const followClient = new FollowClient({ credentials: "include", - timeout: 30000, + timeout: 60_000, baseURL: env.VITE_API_URL, fetch: async (input, options = {}) => fetch(input.toString(), { diff --git a/apps/mobile/src/lib/api-client.ts b/apps/mobile/src/lib/api-client.ts index d2334f8e3..5d75539f4 100644 --- a/apps/mobile/src/lib/api-client.ts +++ b/apps/mobile/src/lib/api-client.ts @@ -15,7 +15,7 @@ import { proxyEnv } from "./proxy-env" export const followClient = new FollowClient({ credentials: "omit", - timeout: 30000, + timeout: 60_000, baseURL: proxyEnv.API_URL, fetch: async (input, options = {}) => fetch(input.toString(), options as any) as any, }) diff --git a/apps/ssr/client/lib/api-fetch.ts b/apps/ssr/client/lib/api-fetch.ts index 08154f89f..383b1c2b9 100644 --- a/apps/ssr/client/lib/api-fetch.ts +++ b/apps/ssr/client/lib/api-fetch.ts @@ -8,7 +8,7 @@ import PKG from "../../../desktop/package.json" export const followClient = new FollowClient({ credentials: "include", - timeout: 30000, + timeout: 60_000, baseURL: env.VITE_API_URL, fetch: async (input: any, options = {}) => fetch(input.toString(), { diff --git a/apps/ssr/src/lib/api-client.ts b/apps/ssr/src/lib/api-client.ts index cb896e80a..92811f484 100644 --- a/apps/ssr/src/lib/api-client.ts +++ b/apps/ssr/src/lib/api-client.ts @@ -54,7 +54,7 @@ export const createFollowClient = () => { const client = new FollowClient({ credentials: "include", - timeout: 30000, + timeout: 60_000, baseURL, fetch: async (input: any, options = {}) => fetch(input.toString(), options), }) From 1ade942ea8b2a41d57b45c9cce3a7a0a7e440ceb Mon Sep 17 00:00:00 2001 From: Kieran Cui <78460423+cuikaipeng@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:07:16 +0800 Subject: [PATCH 09/19] fix: enter and return via the "discover" route (#4984) --- .../subscription-column/SubscriptionColumnHeader.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx index c9ed343a3..cd7971105 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx @@ -7,7 +7,7 @@ import { m } from "motion/react" import type { FC, PropsWithChildren } from "react" import { memo, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router" +import { useLocation, useNavigate } from "react-router" import { toast } from "sonner" import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar" @@ -28,6 +28,7 @@ export const SubscriptionColumnHeader = memo(() => { const timelineId = useRouteParamsSelector((s) => s.timelineId) const navigateBackHome = useBackHome(timelineId) const navigate = useNavigate() + const location = useLocation() const normalStyle = !window.electron || window.electron.process.platform !== "darwin" const { t } = useTranslation() return ( @@ -57,7 +58,11 @@ export const SubscriptionColumnHeader = memo(() => { data-testid="subscription-discover-trigger" shortcut="$mod+T" tooltip={t("words.discover")} - onClick={() => navigate("/discover")} + onClick={() => { + if (location.pathname !== "/discover") { + navigate("/discover") + } + }} > From 18e7f76d6109ee286bae1317b820db06ebfb37da Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 24 Apr 2026 20:37:32 +0800 Subject: [PATCH 10/19] fix(auth): refresh session cookies on clients --- .../renderer/src/providers/user-provider.tsx | 3 +- .../layer/renderer/src/queries/auth.ts | 63 ++++++++++++++++++- apps/mobile/src/App.tsx | 2 + apps/mobile/src/lib/auth.ts | 10 +++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/providers/user-provider.tsx b/apps/desktop/layer/renderer/src/providers/user-provider.tsx index 0cbb7079d..6adb0d455 100644 --- a/apps/desktop/layer/renderer/src/providers/user-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/user-provider.tsx @@ -1,10 +1,11 @@ import { useEffect } from "react" import { setIntegrationIdentify } from "~/initialize/helper" -import { useSession } from "~/queries/auth" +import { useAuthSessionCookieRefresh, useSession } from "~/queries/auth" export const UserProvider = () => { const { session } = useSession() + useAuthSessionCookieRefresh(!!session?.user) useEffect(() => { if (!session?.user) return diff --git a/apps/desktop/layer/renderer/src/queries/auth.ts b/apps/desktop/layer/renderer/src/queries/auth.ts index 935ce318e..001181210 100644 --- a/apps/desktop/layer/renderer/src/queries/auth.ts +++ b/apps/desktop/layer/renderer/src/queries/auth.ts @@ -4,16 +4,51 @@ import { userSyncService } from "@follow/store/user/store" import { tracker } from "@follow/tracker" import { clearStorage } from "@follow/utils/ns" import type { FetchError } from "ofetch" +import { useEffect } from "react" import { setLoginModalShow } from "~/atoms/user" import { QUERY_PERSIST_KEY } from "~/constants" import { useAuthQuery } from "~/hooks/common" -import { deleteUserCustom as deleteUserFn, getAccountInfo, signOut as signOutFn } from "~/lib/auth" +import { + deleteUserCustom as deleteUserFn, + getAccountInfo, + getSession as refreshBetterAuthSession, + signOut as signOutFn, +} from "~/lib/auth" import { ipcServices } from "~/lib/client" import { clearAuthSessionToken, getAuthSessionToken } from "~/lib/client-session" import { defineQuery } from "~/lib/defineQuery" import { clearLocalPersistStoreData } from "~/store/utils/clear" +const sessionCookieRefreshInterval = 1000 * 60 * 60 * 12 + +let lastSessionCookieRefreshAt = 0 +let sessionCookieRefreshPromise: Promise | null = null + +const refreshAuthSessionCookie = async () => { + if (IN_ELECTRON && !getAuthSessionToken()) { + return + } + + if (Date.now() - lastSessionCookieRefreshAt < sessionCookieRefreshInterval) { + return + } + + sessionCookieRefreshPromise ??= refreshBetterAuthSession() + .then((result) => { + if (!result?.error) { + lastSessionCookieRefreshAt = Date.now() + } + return result + }) + .catch(() => null) + .finally(() => { + sessionCookieRefreshPromise = null + }) + + await sessionCookieRefreshPromise +} + export const auth = { getSession: () => defineQuery(whoamiQueryKey, () => userSyncService.whoami()), getAccounts: () => defineQuery(["auth", "accounts"], () => getAccountInfo()), @@ -92,6 +127,32 @@ export const useSession = (options?: { enabled?: boolean }) => { } as const } +export const useAuthSessionCookieRefresh = (enabled: boolean) => { + useEffect(() => { + if (!enabled) { + return + } + + const refresh = () => { + if (document.visibilityState !== "hidden") { + void refreshAuthSessionCookie() + } + } + + refresh() + + const interval = window.setInterval(refresh, sessionCookieRefreshInterval) + window.addEventListener("focus", refresh) + document.addEventListener("visibilitychange", refresh) + + return () => { + window.clearInterval(interval) + window.removeEventListener("focus", refresh) + document.removeEventListener("visibilitychange", refresh) + } + }, [enabled]) +} + export const handleSessionChanges = () => { setLoginModalShow(false) const authSessionToken = getAuthSessionToken() diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 016f312be..0fffc3a02 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -12,6 +12,7 @@ import { useIntentHandler } from "./hooks/useIntentHandler" import { useMessaging, useUpdateMessagingToken } from "./hooks/useMessaging" import { useOnboarding } from "./hooks/useOnboarding" import { useUnreadCountBadge } from "./hooks/useUnreadCountBadge" +import { useAuthSessionCookieRefresh } from "./lib/auth" import { DebugButton, EnvProfileIndicator } from "./modules/debug" import { ReviewPromptProvider } from "./modules/review-prompt/provider" @@ -53,6 +54,7 @@ const ScaleableWrapper: FC = ({ children }) => { } const SideEffect = () => { + useAuthSessionCookieRefresh() usePrefetchSessionUser() useUnreadCountBadge() useBackHandler() diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index 688128220..b1cdb7677 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -25,6 +25,7 @@ const storagePrefix = "follow_auth" export const cookieKey = `${storagePrefix}_cookie` export const sessionTokenKey = "__Secure-better-auth.session_token" const sessionDataKey = `${storagePrefix}_session_data` +const sessionCookieRefreshIntervalSeconds = 60 * 60 * 12 let authStateRevision = 0 let lastAuthStateChangeAt = 0 @@ -115,6 +116,10 @@ const plugins = [ export const authClient = createAuthClient({ baseURL: `${proxyEnv.API_URL}/better-auth`, + sessionOptions: { + refetchInterval: sessionCookieRefreshIntervalSeconds, + refetchOnWindowFocus: true, + }, fetchOptions: { cache: "no-store", // Learn more: https://better-fetch.vercel.app/docs/hooks @@ -169,6 +174,11 @@ export const { useSession, } = authClient +// Mount Better Auth's session atom so the Expo plugin can persist refreshed Set-Cookie metadata. +export const useAuthSessionCookieRefresh = () => { + useSession() +} + export const forgetPassword = authClient.requestPasswordReset export interface AuthProvider { From 88ad11a2ae9a4ca5380662eb98f3817be0b5c73b Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sun, 26 Apr 2026 09:57:42 +0800 Subject: [PATCH 11/19] fix(desktop): dedupe auth session cookies --- .../layer/main/src/ipc/services/auth.ts | 52 ++--- apps/desktop/layer/main/src/lib/api-client.ts | 6 +- .../layer/main/src/lib/auth-cookies.test.ts | 90 +++++++- .../layer/main/src/lib/auth-cookies.ts | 197 +++++++++++++++--- .../layer/main/src/lib/cli-session-sync.ts | 11 +- .../layer/main/src/manager/bootstrap.ts | 34 +-- .../layer/renderer/src/lib/api-client.ts | 3 +- apps/desktop/layer/renderer/src/lib/auth.ts | 3 +- .../layer/renderer/src/modules/auth/Form.tsx | 31 +-- packages/internal/shared/src/auth-cookie.ts | 15 ++ 10 files changed, 343 insertions(+), 99 deletions(-) create mode 100644 packages/internal/shared/src/auth-cookie.ts diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 3a868123d..02d962002 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -1,3 +1,7 @@ +import { + buildBetterAuthSessionTokenCookieHeader, + getBetterAuthSessionTokenCookieName, +} from "@follow/shared/auth-cookie" import { env } from "@follow/shared/env.desktop" import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers" import PKG from "@pkg" @@ -10,8 +14,10 @@ import { WindowManager } from "~/manager/window" import { buildManagedAuthCookieHeader, buildManagedAuthCookieHeaderFromSetCookieHeader, + dedupeManagedAuthCookies, getManagedAuthCookies, persistManagedAuthCookiesFromSetCookieHeader, + removeManagedAuthCookies, } from "../../lib/auth-cookies" import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" @@ -40,27 +46,25 @@ export class AuthService extends IpcService { const url = new URL(apiURL) const isSecure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1" - const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" - const cookieNames = [ - BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, - ...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []), - ] + const cookieName = getBetterAuthSessionTokenCookieName(apiURL) + const cookieSession = mainWindow.webContents.session - await Promise.all( - cookieNames.map((name) => - mainWindow.webContents.session.cookies.set({ - url: apiURL, - name, - value: token, - ...(isLocalhost ? {} : { domain: url.hostname }), - path: "/", - httpOnly: true, - secure: isSecure, - sameSite: "no_restriction", - expirationDate: new Date().setDate(new Date().getDate() + 30), - }), - ), - ) + await removeManagedAuthCookies({ + apiURL, + session: cookieSession, + names: [BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, "__Secure-better-auth.session_token"], + }) + await cookieSession.cookies.set({ + url: apiURL, + name: cookieName, + value: token, + path: "/", + httpOnly: true, + secure: isSecure, + sameSite: "no_restriction", + expirationDate: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, + }) + await dedupeManagedAuthCookies({ apiURL, session: cookieSession }) } private async clearSessionToken(): Promise { @@ -72,11 +76,7 @@ export class AuthService extends IpcService { const { session } = mainWindow.webContents const apiURL = env.VITE_API_URL - await Promise.allSettled([ - session.cookies.remove(apiURL, BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), - session.cookies.remove(apiURL, "__Secure-better-auth.session_token"), - session.cookies.remove(apiURL, "better-auth.last_used_login_method"), - ]) + await removeManagedAuthCookies({ apiURL, session }) } private async requestCredentialAuth( @@ -171,7 +171,7 @@ export class AuthService extends IpcService { headers: this.getAuthRequestHeaders( token ? { - Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, + Cookie: buildBetterAuthSessionTokenCookieHeader(env.VITE_API_URL, token), } : undefined, ), diff --git a/apps/desktop/layer/main/src/lib/api-client.ts b/apps/desktop/layer/main/src/lib/api-client.ts index 1c66241e1..59bb2004d 100644 --- a/apps/desktop/layer/main/src/lib/api-client.ts +++ b/apps/desktop/layer/main/src/lib/api-client.ts @@ -4,11 +4,11 @@ import { FollowClient } from "@follow-app/client-sdk" import PKG, { mainHash, version as appVersion } from "@pkg" import { gte } from "semver" -import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { getCurrentRendererManifest } from "~/updater/hot-updater" import { logger } from "../logger" +import { getPreferredSessionTokenCookie } from "./auth-cookies" export const followClient = new FollowClient({ credentials: "include", @@ -39,9 +39,7 @@ followClient.addRequestInterceptor(async (ctx) => { const cookies = await window?.webContents.session.cookies.get({ domain: new URL(env.VITE_API_URL).hostname, }) - const sessionCookie = cookies?.find((cookie) => - cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), - ) + const sessionCookie = cookies ? getPreferredSessionTokenCookie(cookies) : null const headerCookie = sessionCookie ? `${sessionCookie.name}=${sessionCookie.value}` : "" const userAgent = window?.webContents.getUserAgent() || `Folo/${PKG.version}` diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts index 9c27a33cb..a492182e6 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest" import { buildManagedAuthCookieHeader, buildManagedAuthCookieHeaderFromSetCookieHeader, + dedupeManagedAuthCookies, getManagedAuthCookieNames, persistManagedAuthCookiesFromSetCookieHeader, } from "./auth-cookies" @@ -22,6 +23,40 @@ describe("auth cookies", () => { ) }) + it("deduplicates session token cookies when building a cookie header", () => { + const header = buildManagedAuthCookieHeader([ + { + name: "better-auth.session_token", + value: "legacy-token", + domain: ".api.folo.is", + hostOnly: false, + path: "/", + secure: true, + }, + { + name: "__Secure-better-auth.session_token", + value: "domain-token", + domain: ".api.folo.is", + hostOnly: false, + path: "/", + secure: true, + }, + { + name: "__Secure-better-auth.session_token", + value: "host-token.signature", + domain: "api.folo.is", + hostOnly: true, + path: "/", + secure: true, + }, + { name: "two_factor", value: "two-factor-token" }, + ]) + + expect(header).toBe( + "__Secure-better-auth.session_token=host-token.signature; two_factor=two-factor-token", + ) + }) + it("includes the two-factor cookie in managed names", () => { expect(getManagedAuthCookieNames()).toContain("two_factor") }) @@ -42,11 +77,12 @@ describe("auth cookies", () => { it("persists managed auth cookies and removes expired ones from a set-cookie header", async () => { const set = vi.fn().mockImplementation(async () => {}) const remove = vi.fn().mockImplementation(async () => {}) + const get = vi.fn().mockResolvedValue([]) await persistManagedAuthCookiesFromSetCookieHeader({ apiURL: "https://api.folo.is", session: { - cookies: { set, remove }, + cookies: { get, set, remove }, } as unknown as Session, setCookieHeader: [ "two_factor=two-factor-token; Path=/; HttpOnly; Secure; SameSite=None", @@ -65,6 +101,56 @@ describe("auth cookies", () => { sameSite: "no_restriction", }), ) - expect(remove).toHaveBeenCalledWith("https://api.folo.is", "__Secure-better-auth.session_token") + expect(remove).not.toHaveBeenCalled() + }) + + it("removes stale duplicate session token cookies while keeping the secure host-only cookie", async () => { + const remove = vi.fn().mockImplementation(async () => {}) + const get = vi.fn().mockResolvedValue([ + { + name: "better-auth.session_token", + value: "legacy-token", + domain: ".api.folo.is", + hostOnly: false, + path: "/", + secure: true, + sameSite: "no_restriction", + }, + { + name: "__Secure-better-auth.session_token", + value: "domain-token", + domain: ".api.folo.is", + hostOnly: false, + path: "/", + secure: true, + sameSite: "no_restriction", + }, + { + name: "__Secure-better-auth.session_token", + value: "host-token.signature", + domain: "api.folo.is", + hostOnly: true, + path: "/", + secure: true, + sameSite: "no_restriction", + }, + ]) + + await dedupeManagedAuthCookies({ + apiURL: "https://api.folo.is", + session: { + cookies: { get, remove }, + } as unknown as Session, + }) + + expect(remove).toHaveBeenCalledWith( + "https://__folo_cookie_cleanup__.api.folo.is/", + "better-auth.session_token", + ) + expect(remove).toHaveBeenCalledWith( + "https://__folo_cookie_cleanup__.api.folo.is/", + "__Secure-better-auth.session_token", + ) + expect(remove).toHaveBeenCalledTimes(2) }) }) diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.ts b/apps/desktop/layer/main/src/lib/auth-cookies.ts index f8770b635..a79e089b9 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.ts @@ -1,8 +1,12 @@ +import { + BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME, + BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME, +} from "@follow/shared/auth-cookie" import type { Cookie, CookiesSetDetails, Session } from "electron" const MANAGED_AUTH_COOKIE_NAMES = [ - "__Secure-better-auth.session_token", - "better-auth.session_token", + BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME, + BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME, "__Secure-better-auth.session_data", "better-auth.session_data", "better-auth.last_used_login_method", @@ -18,6 +22,9 @@ const MANAGED_AUTH_COOKIE_NAMES = [ ] as const type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number] +type ManagedAuthCookie = Pick & + Partial> +type KnownManagedAuthCookie = ManagedAuthCookie & { name: ManagedAuthCookieName } type ParsedSetCookie = { domain?: string @@ -32,6 +39,7 @@ type ParsedSetCookie = { } const MANAGED_AUTH_COOKIE_NAME_SET = new Set(MANAGED_AUTH_COOKIE_NAMES) +const COOKIE_CLEANUP_SUBDOMAIN = "__folo_cookie_cleanup__" const splitSetCookieHeader = (header: string) => { const parts: string[] = [] @@ -151,6 +159,59 @@ const isManagedAuthCookie = (cookieName: string): cookieName is ManagedAuthCooki return MANAGED_AUTH_COOKIE_NAME_SET.has(cookieName) } +const getKnownManagedAuthCookies = (cookies: ManagedAuthCookie[]) => { + return cookies.filter((cookie): cookie is KnownManagedAuthCookie => + isManagedAuthCookie(cookie.name), + ) +} + +const getCookieHeaderPriority = (cookie: ManagedAuthCookie) => { + let priority = 0 + if (cookie.hostOnly) priority += 8 + if (cookie.value.includes(".")) priority += 4 + if (cookie.secure) priority += 2 + if ((cookie.path ?? "/") === "/") priority += 1 + return priority +} + +const getPreferredCookie = (cookies: TCookie[]) => { + return cookies.reduce((preferred, cookie) => { + if (!preferred) return cookie + return getCookieHeaderPriority(cookie) > getCookieHeaderPriority(preferred) ? cookie : preferred + }, null) +} + +const isSameStoredCookie = (a: ManagedAuthCookie, b: ManagedAuthCookie) => { + return ( + a.name === b.name && + a.value === b.value && + (a.domain ?? "") === (b.domain ?? "") && + (a.path ?? "/") === (b.path ?? "/") && + Boolean(a.hostOnly) === Boolean(b.hostOnly) + ) +} + +const buildCookieRemovalURL = (apiURL: string, cookie: ManagedAuthCookie) => { + const url = new URL(apiURL) + const domain = (cookie.domain || url.hostname).replace(/^\./, "") + const hostname = cookie.hostOnly ? domain : `${COOKIE_CLEANUP_SUBDOMAIN}.${domain}` + const path = cookie.path?.startsWith("/") ? cookie.path : "/" + + return `${url.protocol}//${hostname}${path}` +} + +const removeStoredCookie = async ({ + apiURL, + cookie, + session, +}: { + apiURL: string + cookie: ManagedAuthCookie + session: Session +}) => { + await session.cookies.remove(buildCookieRemovalURL(apiURL, cookie), cookie.name) +} + const shouldRemoveCookie = (cookie: ParsedSetCookie) => { if (cookie.maxAge !== undefined) { return cookie.maxAge <= 0 @@ -179,13 +240,48 @@ export const buildManagedAuthCookieHeaderFromSetCookieHeader = (setCookieHeader: .join("; ") } -export const buildManagedAuthCookieHeader = (cookies: Array>) => { - return cookies - .filter((cookie) => isManagedAuthCookie(cookie.name)) +export const buildManagedAuthCookieHeader = (cookies: ManagedAuthCookie[]) => { + const selectedCookies = new Map() + const selectedCookieNames: ManagedAuthCookieName[] = [] + + getKnownManagedAuthCookies(cookies).forEach((cookie) => { + const current = selectedCookies.get(cookie.name) + if (!current) { + selectedCookieNames.push(cookie.name) + selectedCookies.set(cookie.name, cookie) + return + } + + if (getCookieHeaderPriority(cookie) > getCookieHeaderPriority(current)) { + selectedCookies.set(cookie.name, cookie) + } + }) + + if (selectedCookies.has(BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME)) { + selectedCookies.delete(BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME) + } + + return selectedCookieNames + .map((name) => selectedCookies.get(name)) + .filter((cookie): cookie is ManagedAuthCookie => !!cookie) .map((cookie) => `${cookie.name}=${cookie.value}`) .join("; ") } +export const getPreferredSessionTokenCookie = (cookies: ManagedAuthCookie[]) => { + const managedCookies = getKnownManagedAuthCookies(cookies) + return ( + getPreferredCookie( + managedCookies.filter( + (cookie) => cookie.name === BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME, + ), + ) || + getPreferredCookie( + managedCookies.filter((cookie) => cookie.name === BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME), + ) + ) +} + export const getManagedAuthCookies = async ({ apiURL, session, @@ -198,6 +294,56 @@ export const getManagedAuthCookies = async ({ return cookies.filter((cookie) => isManagedAuthCookie(cookie.name)) } +export const removeManagedAuthCookies = async ({ + apiURL, + names, + session, +}: { + apiURL: string + names?: readonly string[] + session: Session +}) => { + const nameSet = names ? new Set(names) : null + const cookies = await getManagedAuthCookies({ apiURL, session }) + await Promise.all( + cookies + .filter((cookie) => !nameSet || nameSet.has(cookie.name)) + .map((cookie) => removeStoredCookie({ apiURL, cookie, session })), + ) +} + +export const dedupeManagedAuthCookies = async ({ + apiURL, + session, +}: { + apiURL: string + session: Session +}) => { + const cookies = await getManagedAuthCookies({ apiURL, session }) + const staleCookies = new Set() + + for (const name of MANAGED_AUTH_COOKIE_NAMES) { + const sameNameCookies = cookies.filter((cookie) => cookie.name === name) + const preferred = getPreferredCookie(sameNameCookies) + if (!preferred) continue + + sameNameCookies + .filter((cookie) => !isSameStoredCookie(cookie, preferred)) + .forEach((cookie) => staleCookies.add(cookie)) + } + + const secureSessionCookie = getPreferredSessionTokenCookie(cookies) + if (secureSessionCookie?.name === BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME) { + cookies + .filter((cookie) => cookie.name === BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME) + .forEach((cookie) => staleCookies.add(cookie)) + } + + await Promise.all( + [...staleCookies].map((cookie) => removeStoredCookie({ apiURL, cookie, session })), + ) +} + export const persistManagedAuthCookiesFromSetCookieHeader = async ({ apiURL, session, @@ -215,26 +361,27 @@ export const persistManagedAuthCookiesFromSetCookieHeader = async ({ isManagedAuthCookie(cookie.name), ) - await Promise.all( - cookies.map(async (cookie) => { - if (shouldRemoveCookie(cookie)) { - await session.cookies.remove(apiURL, cookie.name) - return - } + for (const cookie of cookies) { + await removeManagedAuthCookies({ apiURL, session, names: [cookie.name] }) - const details: CookiesSetDetails = { - url: apiURL, - name: cookie.name, - value: cookie.value, - path: cookie.path, - httpOnly: cookie.httpOnly, - secure: cookie.secure, - ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), - ...(cookie.domain ? { domain: cookie.domain } : {}), - ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}), - } + if (shouldRemoveCookie(cookie)) { + continue + } - await session.cookies.set(details) - }), - ) + const details: CookiesSetDetails = { + url: apiURL, + name: cookie.name, + value: cookie.value, + path: cookie.path, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}), + } + + await session.cookies.set(details) + } + + await dedupeManagedAuthCookies({ apiURL, session }) } diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index 81157be55..768360780 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -8,11 +8,14 @@ import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow import PKG from "@pkg" import { join } from "pathe" -import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { logger } from "../logger" -import { buildManagedAuthCookieHeader, getManagedAuthCookies } from "./auth-cookies" +import { + buildManagedAuthCookieHeader, + getManagedAuthCookies, + getPreferredSessionTokenCookie, +} from "./auth-cookies" import { resolveCliSessionToken } from "./cli-login-token" const execFileAsync = promisify(execFile) @@ -92,9 +95,7 @@ export const getSessionTokenFromCookies = async (): Promise domain: new URL(env.VITE_API_URL).hostname, }) - const sessionCookie = cookies.find((cookie) => - cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN), - ) + const sessionCookie = getPreferredSessionTokenCookie(cookies) return sessionCookie?.value } diff --git a/apps/desktop/layer/main/src/manager/bootstrap.ts b/apps/desktop/layer/main/src/manager/bootstrap.ts index e23e1ec27..88eb51a24 100644 --- a/apps/desktop/layer/main/src/manager/bootstrap.ts +++ b/apps/desktop/layer/main/src/manager/bootstrap.ts @@ -14,6 +14,7 @@ import { WindowManager } from "~/manager/window" import { isMacOS } from "../env" import { migrateAuthCookiesToNewApiDomain } from "../lib/auth-cookie-migration" +import { dedupeManagedAuthCookies } from "../lib/auth-cookies" import { handleUrlRouting } from "../lib/router" import { store } from "../lib/store" import { updateNotificationsToken } from "../lib/user" @@ -84,6 +85,10 @@ export class BootstrapManager { await migrateAuthCookiesToNewApiDomain(session.defaultSession, { currentApiURL: env.VITE_API_URL, }) + await dedupeManagedAuthCookies({ + apiURL: env.VITE_API_URL, + session: session.defaultSession, + }) await cleanupOldRender() @@ -206,18 +211,23 @@ export class BootstrapManager { if (ck && apiURL) { const cookie = parse(atob(ck), { decode: (value) => value }) - Object.keys(cookie).forEach(async (name) => { - const value = cookie[name]! - await mainWindow.webContents.session.cookies.set({ - url: apiURL, - name, - value, - secure: true, - httpOnly: true, - domain: new URL(apiURL).hostname, - sameSite: "no_restriction", - expirationDate: new Date().setDate(new Date().getDate() + 30), - }) + await Promise.all( + Object.keys(cookie).map(async (name) => { + const value = cookie[name]! + await mainWindow.webContents.session.cookies.set({ + url: apiURL, + name, + value, + secure: true, + httpOnly: true, + sameSite: "no_restriction", + expirationDate: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, + }) + }), + ) + await dedupeManagedAuthCookies({ + apiURL, + session: mainWindow.webContents.session, }) if (userId) { diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index 66f12d758..da32c92db 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -1,3 +1,4 @@ +import { buildBetterAuthSessionTokenCookieHeader } from "@follow/shared/auth-cookie" import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { whoami } from "@follow/store/user/getters" @@ -33,7 +34,7 @@ followClient.addRequestInterceptor(async (ctx) => { if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) { headers.set( "Cookie", - `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`, + buildBetterAuthSessionTokenCookieHeader(env.VITE_API_URL, authSessionToken), ) } diff --git a/apps/desktop/layer/renderer/src/lib/auth.ts b/apps/desktop/layer/renderer/src/lib/auth.ts index ecab9b295..22387fb6c 100644 --- a/apps/desktop/layer/renderer/src/lib/auth.ts +++ b/apps/desktop/layer/renderer/src/lib/auth.ts @@ -1,4 +1,5 @@ import { Auth } from "@follow/shared/auth" +import { buildBetterAuthSessionTokenCookieHeader } from "@follow/shared/auth-cookie" import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { createDesktopAPIHeaders } from "@follow/utils/headers" @@ -18,7 +19,7 @@ const auth = new Auth({ if (authSessionToken) { context.headers.set( "Cookie", - `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`, + buildBetterAuthSessionTokenCookieHeader(env.VITE_API_URL, authSessionToken), ) } }, diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 51c5c365e..f6913f4e4 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -37,14 +37,14 @@ const getAuthTokenFromResult = (result: unknown) => { return null } - if ("token" in result && typeof result.token === "string") { - return result.token - } - if ("sessionToken" in result && typeof result.sessionToken === "string") { return result.sessionToken } + if ("token" in result && typeof result.token === "string") { + return result.token + } + if ("session" in result && result.session && typeof result.session === "object") { const { token } = result.session as { token?: unknown } if (typeof token === "string") { @@ -63,6 +63,9 @@ const getAuthTokenFromResult = (result: unknown) => { token?: unknown session?: { token?: unknown } | unknown } + if (typeof sessionToken === "string") { + return sessionToken + } if (typeof token === "string") { return token } @@ -74,7 +77,7 @@ const getAuthTokenFromResult = (result: unknown) => { ) { return session.token } - return typeof sessionToken === "string" ? sessionToken : null + return null } return null @@ -104,27 +107,12 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => { } } -const setElectronSessionToken = async (token: string) => { - if (!ipcServices) { - return - } - - const authService = ipcServices.auth as - | (typeof ipcServices.auth & { - setSessionToken?: (token: string) => Promise - }) - | undefined - - await authService?.setSessionToken?.(token) -} - const getElectronAuthService = () => { if (!ipcServices) { return null } return ipcServices.auth as typeof ipcServices.auth & { - setSessionToken?: (token: string) => Promise signInWithCredential?: (payload: { email: string password: string @@ -231,7 +219,6 @@ export function LoginWithPassword({ const token = getAuthTokenFromResult(result) if (token) { setAuthSessionToken(token) - await setElectronSessionToken(token) } } }} @@ -247,7 +234,6 @@ export function LoginWithPassword({ const token = getAuthTokenFromResult(res) if (token) { setAuthSessionToken(token) - await setElectronSessionToken(token) } } handleSessionChanges() @@ -442,7 +428,6 @@ export function RegisterForm({ const token = getAuthTokenFromResult(result) if (token) { setAuthSessionToken(token) - await setElectronSessionToken(token) } } diff --git a/packages/internal/shared/src/auth-cookie.ts b/packages/internal/shared/src/auth-cookie.ts new file mode 100644 index 000000000..b2491a31c --- /dev/null +++ b/packages/internal/shared/src/auth-cookie.ts @@ -0,0 +1,15 @@ +export const BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME = "better-auth.session_token" +export const BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME = "__Secure-better-auth.session_token" + +const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1"]) + +export const getBetterAuthSessionTokenCookieName = (apiURL: string) => { + const url = new URL(apiURL) + return url.protocol === "https:" && !LOCALHOST_HOSTNAMES.has(url.hostname) + ? BETTER_AUTH_SECURE_SESSION_TOKEN_COOKIE_NAME + : BETTER_AUTH_SESSION_TOKEN_COOKIE_NAME +} + +export const buildBetterAuthSessionTokenCookieHeader = (apiURL: string, token: string) => { + return `${getBetterAuthSessionTokenCookieName(apiURL)}=${token}` +} From e1daec3013e4adc92eb8d55b7c60c42857792afa Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 14:23:23 +0800 Subject: [PATCH 12/19] feat(power): restrict power usage to wallet --- .../src/modules/discover/FeedForm.tsx | 8 +- .../power/my-wallet-section/withdraw.tsx | 35 +++--- .../src/modules/rsshub/add-modal-content.tsx | 5 +- .../src/modules/rsshub/set-modal-content.tsx | 115 ++---------------- .../renderer/src/modules/settings/control.tsx | 8 +- .../src/modules/user/ProfileButton.tsx | 2 +- .../(main)/(layer)/(subview)/rsshub/index.tsx | 12 +- apps/desktop/vite.config.ts | 2 +- .../widgets/landing/SocialProof.tsx | 2 +- apps/ssr/vite.config.mts | 3 + locales/app/en.json | 3 +- locales/app/fr-FR.json | 3 +- locales/app/ja.json | 3 +- locales/app/zh-CN.json | 3 +- locales/app/zh-TW.json | 3 +- locales/external/en.json | 2 +- locales/external/fr-FR.json | 2 +- locales/external/ja.json | 2 +- locales/external/zh-CN.json | 2 +- locales/external/zh-TW.json | 2 +- locales/settings/en.json | 10 +- locales/settings/fr-FR.json | 10 +- locales/settings/ja.json | 10 +- locales/settings/zh-CN.json | 10 +- locales/settings/zh-TW.json | 10 +- 25 files changed, 78 insertions(+), 189 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx index 59a4538ed..95b9f7144 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx @@ -117,7 +117,7 @@ export const FeedForm: Component<{ isError: feedQuery.isError, }) } - }, [feedQuery.isLoading]) + }, [feedQuery.data?.feed.url, feedQuery.isError, feedQuery.isLoading, id, url]) return (
{ setClickOutSideToDismiss(!form.formState.isDirty) - }, [form.formState.isDirty]) + }, [form.formState.isDirty, setClickOutSideToDismiss]) useEffect(() => { if (subscription) { @@ -262,7 +262,7 @@ const FeedInnerForm = ({ form.setValue("hideFromTimeline", subscription.hideFromTimeline) subscription?.title && form.setValue("title", subscription.title) } - }, [subscription]) + }, [form, subscription]) useEffect(() => { if ( @@ -272,7 +272,7 @@ const FeedInnerForm = ({ ) { form.setValue("view", `${analytics.view}`) } - }, [analytics, subscription, defaultValues?.view]) + }, [analytics, defaultValues?.view, form, subscription]) const followMutation = useMutation({ mutationFn: async (values: z.infer) => { diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx index 6fee1262d..cd69cc835 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx @@ -31,6 +31,8 @@ import { useTOTPModalWrapper } from "~/modules/profile/hooks" import { Balance } from "~/modules/wallet/balance" import { useWallet, wallet as walletActions } from "~/queries/wallet" +const RSS3_CONVERSION_RATE = 0.043 + export const WithdrawButton = () => { const { t } = useTranslation("settings") const { present } = useModalStack() @@ -54,18 +56,22 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { const wallet = useWallet() const cashablePowerTokenBigInt = [BigInt(wallet.data?.[0]!.cashablePowerToken || 0n), 18] as const const cashablePowerTokenNumber = toNumber(cashablePowerTokenBigInt) + const walletAddress = wallet.data?.[0]?.address ?? "-" const formSchema = z.object({ address: z.string().startsWith("0x").length(42), amount: z.number().positive().max(cashablePowerTokenNumber), - toRss3: z.boolean().optional(), + toRss3: z.boolean(), }) const form = useForm>({ resolver: zodResolver(formSchema), + defaultValues: { + toRss3: true, + }, }) - - const rss3ConversionRate: number | null = null + const withdrawAmount = form.watch("amount") + const receiveAmount = Number.isFinite(withdrawAmount) ? withdrawAmount * RSS3_CONVERSION_RATE : 0 const mutation = useMutation({ mutationFn: async ({ @@ -91,7 +97,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { const present = useTOTPModalWrapper(mutation.mutateAsync, { force: true }) const onSubmit = (values: z.infer) => { - present(values) + present({ ...values, toRss3: true }) } useEffect(() => { @@ -126,6 +132,9 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
+
+ {t("wallet.withdraw.gasFeeNotice", { address: walletAddress })} +
void }) => { ( + render={() => (
@@ -173,7 +182,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { - 1 POWER = {rss3ConversionRate ?? "-"} RSS3 + 1 POWER = {RSS3_CONVERSION_RATE} RSS3 @@ -181,17 +190,15 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { - +
- {field.value && rss3ConversionRate !== null && ( - - {t("wallet.withdraw.receiveRSS3", { - amount: ((form.watch("amount") || 0) * rss3ConversionRate).toFixed(4), - })} - - )} + + {t("wallet.withdraw.receiveRSS3", { + amount: receiveAmount.toFixed(4), + })} +
)} diff --git a/apps/desktop/layer/renderer/src/modules/rsshub/add-modal-content.tsx b/apps/desktop/layer/renderer/src/modules/rsshub/add-modal-content.tsx index 2f5dfbbea..f959ac143 100644 --- a/apps/desktop/layer/renderer/src/modules/rsshub/add-modal-content.tsx +++ b/apps/desktop/layer/renderer/src/modules/rsshub/add-modal-content.tsx @@ -55,7 +55,7 @@ export function AddModalContent({ if (addRSSHubMutation.isSuccess) { dismiss() } - }, [addRSSHubMutation.isSuccess]) + }, [addRSSHubMutation.isSuccess, dismiss]) useEffect(() => { if (details.data?.instance.baseUrl) { @@ -64,12 +64,11 @@ export function AddModalContent({ accessKey: details.data.instance.accessKey || undefined, }) } - }, [details.data]) + }, [details.data, form]) const codes = [ `FOLLOW_OWNER_USER_ID=${me?.handle || me?.id} # User id or handle of your follow account`, `FOLLOW_DESCRIPTION=${instance?.description || `${me?.name}'s instance`} # The description of your instance`, - `FOLLOW_PRICE=${instance?.price || 100} # The monthly price of your instance, set to 0 means free.`, `FOLLOW_USER_LIMIT=${instance?.userLimit || 1000} # The user limit of your instance, set it to 0 or 1 can make your instance private, leaving it empty means no restriction`, ] diff --git a/apps/desktop/layer/renderer/src/modules/rsshub/set-modal-content.tsx b/apps/desktop/layer/renderer/src/modules/rsshub/set-modal-content.tsx index 0b44afd51..67659cba6 100644 --- a/apps/desktop/layer/renderer/src/modules/rsshub/set-modal-content.tsx +++ b/apps/desktop/layer/renderer/src/modules/rsshub/set-modal-content.tsx @@ -1,25 +1,10 @@ import { Button } from "@follow/components/ui/button/index.js" import { Card, CardContent } from "@follow/components/ui/card/index.js" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@follow/components/ui/form/index.jsx" -import { Input } from "@follow/components/ui/input/Input.js" -import { whoami } from "@follow/store/user/getters" import type { RSSHubListItem } from "@follow-app/client-sdk" -import { zodResolver } from "@hookform/resolvers/zod" import { useEffect } from "react" -import { useForm } from "react-hook-form" -import { Trans, useTranslation } from "react-i18next" -import { z } from "zod" +import { useTranslation } from "react-i18next" -import { useAuthQuery } from "~/hooks/common" import { UserAvatar } from "~/modules/user/UserAvatar" -import { Queries } from "~/queries" import { useSetRSSHubMutation } from "~/queries/rsshub" import { useTOTPModalWrapper } from "../profile/hooks" @@ -34,35 +19,12 @@ export function SetModalContent({ const { t } = useTranslation("settings") const setRSSHubMutation = useSetRSSHubMutation() const preset = useTOTPModalWrapper(setRSSHubMutation.mutateAsync) - const details = useAuthQuery(Queries.rsshub.get({ id: instance.id })) - const hasPurchase = !!details.data?.purchase - const price = instance.ownerUserId === whoami()?.id ? 0 : instance.price - - const formSchema = z.object({ - months: z.coerce - .number() - .min(hasPurchase ? 0 : 1) - .max(12), - }) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - months: hasPurchase ? 0 : 1, - }, - }) - - const months = form.watch("months") - - const onSubmit = (data: z.infer) => { - preset({ id: instance.id, durationInMonths: data.months }) - } useEffect(() => { if (setRSSHubMutation.isSuccess) { dismiss() } - }, [setRSSHubMutation.isSuccess]) + }, [setRSSHubMutation.isSuccess, dismiss]) return (
@@ -85,12 +47,6 @@ export function SetModalContent({ {t("rsshub.table.description")} {instance.description} - - {t("rsshub.table.price")} - - {instance.price} - - {t("rsshub.table.userCount")} {instance.userCount} @@ -103,64 +59,15 @@ export function SetModalContent({ - {details.data?.purchase && ( -
-
- {t("rsshub.useModal.purchase_expires_at")} -
-
- {new Date(details.data.purchase.expiresAt).toLocaleString()} -
-
- )} - - - {price > 0 && ( - ( - - {t("rsshub.useModal.months_label")} - -
-
- - - {t("rsshub.useModal.month")} - -
-
-
- -
- )} - /> - )} -
- -
- - +
+ +
) } diff --git a/apps/desktop/layer/renderer/src/modules/settings/control.tsx b/apps/desktop/layer/renderer/src/modules/settings/control.tsx index e6707d5a8..d2a338505 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/control.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/control.tsx @@ -47,8 +47,12 @@ export const PaidBadge: Component<{ - {paidLevel === SettingPaidLevels.FreeLimited && t("control.paid_badge.free_limited")} - {paidLevel === SettingPaidLevels.Basic && t("control.paid_badge.basic_or_higher")} + {paidLevel === SettingPaidLevels.FreeLimited && ( + {t("control.paid_badge.free_limited")} + )} + {paidLevel === SettingPaidLevels.Basic && ( + {t("control.paid_badge.basic_or_higher")} + )} diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx index 3304b1c7e..52704c130 100644 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx @@ -139,7 +139,7 @@ export const ProfileButton: FC = memo((props) => { }} icon={} > - {t("user_button.power")} + {t("user_button.wallet")} )} { ) const title = isOfficial ? "Folo Official" : "" - const price = isOfficial ? 0 : instance.price const description = isOfficial ? "Folo Built-in RSSHub" : instance.description const usersStat = isOfficial ? "*" : instance.userCount || 0 @@ -166,11 +165,6 @@ const InstanceCard = memo(({ item }: { item: InstanceItem }) => {
{tags}
-
-
- {formatNumber(price ?? 0)} -
-

{description}

@@ -276,7 +270,7 @@ function List({ data }: { data?: RSSHubListItem[] }) { // full load last if (loadA === 1 && loadB === 1) { - return a.price - b.price + return 0 } if (loadA === 1) { return 1 @@ -285,7 +279,7 @@ function List({ data }: { data?: RSSHubListItem[] }) { return -1 } - return a.price - b.price || loadA - loadB + return loadA - loadB }) || []), ] diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f0258077c..5b67be288 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -108,7 +108,7 @@ export default ({ mode }) => { host: true, port: 2233, watch: { - ignored: ["**/dist/**", "**/out/**", "**/public/**", ".git/**"], + ignored: ["**/dist/**", "**/out/**", "**/public/**", ".git/**", "**/.env", "**/.env.*"], }, cors: true, headers: { diff --git a/apps/landing/src/components/widgets/landing/SocialProof.tsx b/apps/landing/src/components/widgets/landing/SocialProof.tsx index 3566b1f70..385acf130 100644 --- a/apps/landing/src/components/widgets/landing/SocialProof.tsx +++ b/apps/landing/src/components/widgets/landing/SocialProof.tsx @@ -7,7 +7,7 @@ import { MagicCard } from '~/components/ui/magic-card' const tweetList = [ { id: '1833056589135442345', - text: "Very nice news aggregation, and it gives 2 power token everyday, so far I just try move front end people I followed in, haven't done yet, will try move more rss subscribe.", + text: "Very nice news aggregation. So far I just try move front end people I followed in, haven't done yet, will try move more rss subscribe.", name: '🦋 AnneInCoding', screenName: '@anneincoding', profileImageUrl: diff --git a/apps/ssr/vite.config.mts b/apps/ssr/vite.config.mts index ea9dc82e2..b00b793b6 100644 --- a/apps/ssr/vite.config.mts +++ b/apps/ssr/vite.config.mts @@ -49,6 +49,9 @@ export default defineConfig({ ], server: { + watch: { + ignored: ["**/.env", "**/.env.*"], + }, proxy: { "/api": { target: "https://api.follow.is", diff --git a/locales/app/en.json b/locales/app/en.json index 60f1a7bc0..ddc439efc 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -241,7 +241,6 @@ "feed_form.feedback": "Feedback", "feed_form.fill_default": "Fill", "feed_form.follow": "Follow", - "feed_form.follow_with_fee": "Follow with {{fee}} Power", "feed_form.followed": "🎉 Followed.", "feed_form.hide_from_timeline": "Hide from Timeline", "feed_form.hide_from_timeline_description": "Whether this subscription's entries are visible on your main view timeline.", @@ -504,9 +503,9 @@ "user_button.ai": "AI", "user_button.download_desktop_app": "Download Desktop app", "user_button.log_out": "Log out", - "user_button.power": "Power", "user_button.preferences": "Preferences", "user_button.profile": "Profile", + "user_button.wallet": "Wallet", "user_profile.about": "About", "user_profile.close": "Close", "user_profile.created_lists": "Created Lists", diff --git a/locales/app/fr-FR.json b/locales/app/fr-FR.json index 9dd15929f..c98bab0c4 100644 --- a/locales/app/fr-FR.json +++ b/locales/app/fr-FR.json @@ -241,7 +241,6 @@ "feed_form.feedback": "Retour", "feed_form.fill_default": "Remplir", "feed_form.follow": "Suivre", - "feed_form.follow_with_fee": "Suivre avec {{fee}} Puissance", "feed_form.followed": "🎉 Suivi.", "feed_form.hide_from_timeline": "Masquer de la chronologie", "feed_form.hide_from_timeline_description": "Si les entrées de cet abonnement sont visibles sur votre chronologie principale.", @@ -503,9 +502,9 @@ "user_button.ai": "IA", "user_button.download_desktop_app": "Télécharger appli bureau", "user_button.log_out": "Déconnexion", - "user_button.power": "Puissance", "user_button.preferences": "Préférences", "user_button.profile": "Profil", + "user_button.wallet": "Portefeuille", "user_profile.about": "À propos", "user_profile.close": "Fermer", "user_profile.created_lists": "Listes créées", diff --git a/locales/app/ja.json b/locales/app/ja.json index f81e4439b..adc064008 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -241,7 +241,6 @@ "feed_form.feedback": "フィードバック", "feed_form.fill_default": "入力する", "feed_form.follow": "フォロー", - "feed_form.follow_with_fee": " {{fee}} Power で購読できます。", "feed_form.followed": "🎉 フォローしました。", "feed_form.hide_from_timeline": "タイムラインから非表示", "feed_form.hide_from_timeline_description": "このサブスクリプションのエントリーがメインビューのタイムラインに表示されるかどうか。", @@ -504,9 +503,9 @@ "user_button.ai": "AI", "user_button.download_desktop_app": "アプリをダウンロード", "user_button.log_out": "ログアウト", - "user_button.power": "Power", "user_button.preferences": "設定", "user_button.profile": "プロフィール", + "user_button.wallet": "ウォレット", "user_profile.about": "About", "user_profile.close": "閉じる", "user_profile.created_lists": "作成したリスト", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 740834a60..d43df8a17 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -241,7 +241,6 @@ "feed_form.feedback": "反馈", "feed_form.fill_default": "填充", "feed_form.follow": "订阅", - "feed_form.follow_with_fee": "使用 {{fee}} Power 订阅", "feed_form.followed": "🎉 订阅成功", "feed_form.hide_from_timeline": "在时间线上隐藏", "feed_form.hide_from_timeline_description": "开启后,此订阅将不再显示在主时间线中", @@ -504,9 +503,9 @@ "user_button.ai": "AI", "user_button.download_desktop_app": "下载客户端", "user_button.log_out": "登出", - "user_button.power": "Power", "user_button.preferences": "设置", "user_button.profile": "个人资料", + "user_button.wallet": "钱包", "user_profile.about": "关于", "user_profile.close": "关闭", "user_profile.created_lists": "创建的列表", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index fb4b66b53..a4e065b31 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -241,7 +241,6 @@ "feed_form.feedback": "回饋", "feed_form.fill_default": "填充", "feed_form.follow": "跟隨", - "feed_form.follow_with_fee": "使用 {{fee}} Power 跟隨", "feed_form.followed": "🎉 跟隨成功。", "feed_form.hide_from_timeline": "從時間軸隱藏", "feed_form.hide_from_timeline_description": "開啟後,此訂閱將不再顯示在主時間軸中", @@ -504,9 +503,9 @@ "user_button.ai": "AI", "user_button.download_desktop_app": "下載桌面應用程式", "user_button.log_out": "登出", - "user_button.power": "Power", "user_button.preferences": "偏好設定", "user_button.profile": "個人檔案", + "user_button.wallet": "錢包", "user_profile.about": "關於", "user_profile.close": "關閉", "user_profile.created_lists": "已創建列表", diff --git a/locales/external/en.json b/locales/external/en.json index b4e55e171..667701b51 100644 --- a/locales/external/en.json +++ b/locales/external/en.json @@ -34,7 +34,7 @@ "invitation.earlyAccess": "Folo is currently requires an invitation code to use.", "invitation.earlyAccessMessage": "😰 Sorry, Folo is currently requires an invitation code to use.", "invitation.generateButton": "Generate new code", - "invitation.generateCost": "You can spend {{INVITATION_PRICE}} Power to generate an invitation code for your friends.", + "invitation.generateCost": "You can generate an invitation code for your friends.", "invitation.getCodeMessage": "You can get an invitation code in the following ways:", "invitation.title": "Invitation Code", "login.backToWebApp": "Back To Web App", diff --git a/locales/external/fr-FR.json b/locales/external/fr-FR.json index 19e5daba8..c7ab7ccc4 100644 --- a/locales/external/fr-FR.json +++ b/locales/external/fr-FR.json @@ -34,7 +34,7 @@ "invitation.earlyAccess": "Folo nécessite actuellement un code d'invitation.", "invitation.earlyAccessMessage": "😰 Désolé, Folo nécessite actuellement un code d'invitation pour être utilisé.", "invitation.generateButton": "Générer un nouveau code", - "invitation.generateCost": "Vous pouvez dépenser {{INVITATION_PRICE}} Power pour générer un code d'invitation pour vos amis.", + "invitation.generateCost": "Vous pouvez générer un code d'invitation pour vos amis.", "invitation.getCodeMessage": "Vous pouvez obtenir un code d'invitation des manières suivantes :", "invitation.title": "Code d'invitation", "login.backToWebApp": "Retour à l'application Web", diff --git a/locales/external/ja.json b/locales/external/ja.json index 749e40f3b..7d24eb792 100644 --- a/locales/external/ja.json +++ b/locales/external/ja.json @@ -34,7 +34,7 @@ "invitation.earlyAccess": "現在、Folo はアーリーアクセス中で、利用には招待コードが必要です。", "invitation.earlyAccessMessage": "😰 申し訳ありませんが、Folo は現在アーリーアクセス中で、招待コードが必要です。", "invitation.generateButton": "新しいコードを生成", - "invitation.generateCost": "友達のために招待コードを生成するには、{{INVITATION_PRICE}} Power を消費できます。", + "invitation.generateCost": "友達のために招待コードを生成できます。", "invitation.getCodeMessage": "以下の方法で招待コードを入手できます:", "invitation.title": "招待コード", "login.backToWebApp": "ウェブアプリに戻る", diff --git a/locales/external/zh-CN.json b/locales/external/zh-CN.json index 9e1c76790..84f0de283 100644 --- a/locales/external/zh-CN.json +++ b/locales/external/zh-CN.json @@ -34,7 +34,7 @@ "invitation.earlyAccess": "Folo 目前处于早期体验阶段,需要邀请码才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,Folo 目前处于早期体验阶段,需要邀请码才能使用。", "invitation.generateButton": "生成邀请码", - "invitation.generateCost": "花费 {{INVITATION_PRICE}} Power 生成一个邀请码给你的朋友。", + "invitation.generateCost": "你可以为你的朋友生成一个邀请码。", "invitation.getCodeMessage": "通过以下方式获取:", "invitation.title": "邀请码", "login.backToWebApp": "返回网页版", diff --git a/locales/external/zh-TW.json b/locales/external/zh-TW.json index d0f6f5f98..71127d310 100644 --- a/locales/external/zh-TW.json +++ b/locales/external/zh-TW.json @@ -34,7 +34,7 @@ "invitation.earlyAccess": "Folo 目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,Folo 目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.generateButton": "產生新邀請碼", - "invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} Power 為您的朋友產生邀請碼。", + "invitation.generateCost": "您可以為朋友產生邀請碼。", "invitation.getCodeMessage": "您可以通過以下方式獲取邀請碼:", "invitation.title": "邀請碼", "login.backToWebApp": "返回網頁應用程式", diff --git a/locales/settings/en.json b/locales/settings/en.json index 228e491bc..6a759588b 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -530,14 +530,14 @@ "invitation.confirmModal.cancel": "Cancel", "invitation.confirmModal.confirm": "Do you want to continue?", "invitation.confirmModal.continue": "Continue", - "invitation.confirmModal.message": "Generating an invitation code will cost you {{INVITATION_PRICE}} Power.", + "invitation.confirmModal.message": "Generating an invitation code will use one invitation quota.", "invitation.confirmModal.title": "Confirm", "invitation.created_at": "Created at", "invitation.earlyAccess": "Folo is currently requires an invitation code to use.", "invitation.earlyAccessMessage": "😰 Sorry, Folo is currently requires an invitation code to use.", "invitation.generate": "Generate", "invitation.generateButton": "Generate New Code", - "invitation.generateCost": "You can spend {{INVITATION_PRICE}} Power to generate an invitation code for your friends.", + "invitation.generateCost": "You can generate an invitation code for your friends.", "invitation.getCodeMessage": "You can get an invitation code through the following methods:", "invitation.limitationMessage": "Based on your usage time, you can generate up to {{limitation}} invitation codes.", "invitation.newInvitationSuccess": "🎉 New invitation generated, invite code is copied", @@ -737,7 +737,6 @@ "rsshub.table.limit_reached": "Limit Reached", "rsshub.table.official": "Official", "rsshub.table.owner": "Owner", - "rsshub.table.price": "Monthly Price", "rsshub.table.private": "Private", "rsshub.table.unavailable": "Unavailable", "rsshub.table.unlimited": "Unlimited", @@ -746,11 +745,7 @@ "rsshub.table.userLimit": "User Limit", "rsshub.table.yours": "Yours", "rsshub.useModal.about": "About this Instance", - "rsshub.useModal.month": "month", - "rsshub.useModal.months_label": "The number of months you want to purchase", - "rsshub.useModal.purchase_expires_at": "You have purchased this Instance, and your purchase expires at", "rsshub.useModal.title": "RSSHub Instance", - "rsshub.useModal.useWith": "Use with {{amount}} ", "spotlight.add_rule": "Add rule", "spotlight.case_sensitive": "Case sensitive", "spotlight.color": "Color", @@ -865,6 +860,7 @@ "wallet.withdraw.availableBalance": "You have withdrawable Power in your wallet.", "wallet.withdraw.button": "Withdraw", "wallet.withdraw.error": "Withdrawal failed: {{error}}", + "wallet.withdraw.gasFeeNotice": "You are responsible for the Ethereum mainnet gas fee. Make sure this wallet address has enough ETH to submit one transaction before withdrawing: {{address}}.", "wallet.withdraw.modalTitle": "Withdraw Power", "wallet.withdraw.receiveRSS3": "You will receive {{amount}} RSS3", "wallet.withdraw.submitButton": "Submit", diff --git a/locales/settings/fr-FR.json b/locales/settings/fr-FR.json index ccdf478b9..68f139c08 100644 --- a/locales/settings/fr-FR.json +++ b/locales/settings/fr-FR.json @@ -526,14 +526,14 @@ "invitation.confirmModal.cancel": "Annuler", "invitation.confirmModal.confirm": "Voulez-vous continuer ?", "invitation.confirmModal.continue": "Continuer", - "invitation.confirmModal.message": "Générer un code d'invitation vous coûtera {{INVITATION_PRICE}} Puissance.", + "invitation.confirmModal.message": "Générer un code d'invitation utilisera un quota d'invitation.", "invitation.confirmModal.title": "Confirmer", "invitation.created_at": "Créé le", "invitation.earlyAccess": "Folo nécessite actuellement un code d'invitation pour être utilisé.", "invitation.earlyAccessMessage": "😰 Désolé, Folo nécessite actuellement un code d'invitation pour être utilisé.", "invitation.generate": "Générer", "invitation.generateButton": "Générer un nouveau code", - "invitation.generateCost": "Vous pouvez dépenser {{INVITATION_PRICE}} Puissance pour générer un code d'invitation pour vos amis.", + "invitation.generateCost": "Vous pouvez générer un code d'invitation pour vos amis.", "invitation.getCodeMessage": "Vous pouvez obtenir un code d'invitation via les méthodes suivantes :", "invitation.limitationMessage": "En fonction de votre temps d'utilisation, vous pouvez générer jusqu'à {{limitation}} codes d'invitation.", "invitation.newInvitationSuccess": "🎉 Nouvelle invitation générée, code copié", @@ -719,7 +719,6 @@ "rsshub.table.limit_reached": "Limite atteinte", "rsshub.table.official": "Officiel", "rsshub.table.owner": "Propriétaire", - "rsshub.table.price": "Prix mensuel", "rsshub.table.private": "Privé", "rsshub.table.unavailable": "Indisponible", "rsshub.table.unlimited": "Illimité", @@ -728,11 +727,7 @@ "rsshub.table.userLimit": "Limite d'utilisateurs", "rsshub.table.yours": "Le vôtre", "rsshub.useModal.about": "À propos de cette instance", - "rsshub.useModal.month": "mois", - "rsshub.useModal.months_label": "Le nombre de mois que vous souhaitez acheter", - "rsshub.useModal.purchase_expires_at": "Vous avez acheté cette instance, et votre achat expire le", "rsshub.useModal.title": "Instance RSSHub", - "rsshub.useModal.useWith": "Utiliser avec {{amount}} ", "subscription.actions.comingSoon": "Bientôt disponible", "subscription.actions.current": "Plan actuel", "subscription.actions.manage_error": "Une erreur s'est produite lors de l'ouverture de la gestion de l'abonnement.", @@ -830,6 +825,7 @@ "wallet.withdraw.availableBalance": "Vous avez puissance retirable dans votre portefeuille.", "wallet.withdraw.button": "Retirer", "wallet.withdraw.error": "Retrait échoué : {{error}}", + "wallet.withdraw.gasFeeNotice": "Vous êtes responsable des gas fees du réseau principal Ethereum. Avant le retrait, assurez-vous que cette adresse de portefeuille dispose d'assez d'ETH pour soumettre une transaction : {{address}}.", "wallet.withdraw.modalTitle": "Retirer de la puissance", "wallet.withdraw.receiveRSS3": "Vous recevrez {{amount}} RSS3", "wallet.withdraw.submitButton": "Soumettre", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 0c4c1834f..8019fa0cd 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -526,14 +526,14 @@ "invitation.confirmModal.cancel": "キャンセル", "invitation.confirmModal.confirm": "続けますか?", "invitation.confirmModal.continue": "続行", - "invitation.confirmModal.message": "招待コードを生成するには、{{INVITATION_PRICE}} Power が必要です。", + "invitation.confirmModal.message": "招待コードを生成すると招待枠を 1 つ使用します。", "invitation.confirmModal.title": "確認", "invitation.created_at": "作成日", "invitation.earlyAccess": "現在、Folo はアーリーアクセス中で、招待コードが必要です。", "invitation.earlyAccessMessage": "😰 申し訳ありません。Folo は現在アーリーアクセス中で、招待コードが必要です。", "invitation.generate": "生成", "invitation.generateButton": "新しいコードを生成", - "invitation.generateCost": "{{INVITATION_PRICE}} Power を消費して、友達のために招待コードを生成できます。", + "invitation.generateCost": "友達のために招待コードを生成できます。", "invitation.getCodeMessage": "以下の方法で招待コードを取得できます:", "invitation.limitationMessage": "あなたの使用時間に応じて、最大 {{limitation}} 個の招待コードを生成できます。", "invitation.newInvitationSuccess": "🎉 新しい招待コードが生成され、クリップボードにコピーされました", @@ -733,7 +733,6 @@ "rsshub.table.limit_reached": "制限に達しました", "rsshub.table.official": "公式", "rsshub.table.owner": "所有者", - "rsshub.table.price": "月額の費用", "rsshub.table.private": "プライベート", "rsshub.table.unavailable": "利用不可", "rsshub.table.unlimited": "無制限", @@ -742,11 +741,7 @@ "rsshub.table.userLimit": "ユーザー制限", "rsshub.table.yours": "あなたの", "rsshub.useModal.about": "このインスタンスについて", - "rsshub.useModal.month": "月", - "rsshub.useModal.months_label": "購入したい月数", - "rsshub.useModal.purchase_expires_at": "このインスタンスを購入しました、利用期限は", "rsshub.useModal.title": "RSSHub インスタンス", - "rsshub.useModal.useWith": "使用する {{amount}} ", "spotlight.add_rule": "ルールを追加", "spotlight.case_sensitive": "大文字と小文字を区別", "spotlight.color": "色", @@ -861,6 +856,7 @@ "wallet.withdraw.availableBalance": "引き出し可能な Power はです。", "wallet.withdraw.button": "引き出し", "wallet.withdraw.error": "引き出しに失敗しました:{{error}}", + "wallet.withdraw.gasFeeNotice": "Ethereum メインネットの gas fee はご自身で支払う必要があります。引き出し前に、このウォレットアドレスに 1 回のトランザクションを送信できるだけの ETH があることを確認してください: {{address}}。", "wallet.withdraw.modalTitle": "Power を引き出す", "wallet.withdraw.receiveRSS3": "{{amount}} RSS3を受け取ります", "wallet.withdraw.submitButton": "送信", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index e66711fed..6f749b4a1 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -530,14 +530,14 @@ "invitation.confirmModal.cancel": "取消", "invitation.confirmModal.confirm": "确认继续?", "invitation.confirmModal.continue": "继续", - "invitation.confirmModal.message": "生成邀请码将花费 {{INVITATION_PRICE}} Power。", + "invitation.confirmModal.message": "生成邀请码将消耗一个邀请码额度。", "invitation.confirmModal.title": "确认", "invitation.created_at": "创建于", "invitation.earlyAccess": "Folo 目前处于早期开发状态,需要邀请码才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,Folo 目前处于抢先体验阶段,需要邀请码才能使用。", "invitation.generate": "生成", "invitation.generateButton": "生成邀请码", - "invitation.generateCost": "你可以花费 {{INVITATION_PRICE}} Power 为你的朋友生成邀请码。", + "invitation.generateCost": "你可以为你的朋友生成一个邀请码。", "invitation.getCodeMessage": "通过以下方式获取邀请码:", "invitation.limitationMessage": "根据你的使用时间,你可以生成最多 {{limitation}} 个邀请码。", "invitation.newInvitationSuccess": "🎉 邀请码已生成,已复制到剪贴板", @@ -737,7 +737,6 @@ "rsshub.table.limit_reached": "达到限制", "rsshub.table.official": "官方", "rsshub.table.owner": "所有者", - "rsshub.table.price": "月度价格", "rsshub.table.private": "私有", "rsshub.table.unavailable": "不可用", "rsshub.table.unlimited": "无限制", @@ -746,11 +745,7 @@ "rsshub.table.userLimit": "用户限制", "rsshub.table.yours": "你的", "rsshub.useModal.about": "关于此实例", - "rsshub.useModal.month": "个月", - "rsshub.useModal.months_label": "你想购买的月份数量", - "rsshub.useModal.purchase_expires_at": "你已购买此实例,到期时间为", "rsshub.useModal.title": "RSSHub 实例", - "rsshub.useModal.useWith": "使用 {{amount}} ", "spotlight.add_rule": "添加规则", "spotlight.case_sensitive": "区分大小写", "spotlight.color": "颜色", @@ -865,6 +860,7 @@ "wallet.withdraw.availableBalance": "钱包中有 Power 可提现。", "wallet.withdraw.button": "提现", "wallet.withdraw.error": "提现失败:{{error}}", + "wallet.withdraw.gasFeeNotice": "你需要自行支付以太坊主网 gas fee。提现前请确保这个钱包地址有足够 ETH 发起一笔交易:{{address}}。", "wallet.withdraw.modalTitle": "提现 Power", "wallet.withdraw.receiveRSS3": "你将收到 {{amount}} RSS3", "wallet.withdraw.submitButton": "提交", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index ae5b95537..af0462345 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -526,14 +526,14 @@ "invitation.confirmModal.cancel": "取消", "invitation.confirmModal.confirm": "您想繼續嗎?", "invitation.confirmModal.continue": "繼續", - "invitation.confirmModal.message": "產生邀請碼將會花費您 {{INVITATION_PRICE}} Power。", + "invitation.confirmModal.message": "產生邀請碼將使用一個邀請碼額度。", "invitation.confirmModal.title": "確認", "invitation.created_at": "建立者:", "invitation.earlyAccess": "Folo 目前處於早期開發狀態,需要邀請碼才能使用。", "invitation.earlyAccessMessage": "😰 抱歉,關注目前處於搶先體驗階段,需要邀請碼才能使用。", "invitation.generate": "產生", "invitation.generateButton": "產生邀請碼", - "invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} Power 為您的朋友產生邀請碼。", + "invitation.generateCost": "您可以為朋友產生邀請碼。", "invitation.getCodeMessage": "您可以通過以下方式獲取邀請碼:", "invitation.limitationMessage": "基於您的使用時間,您最多可以產生 {{limitation}} 個邀請碼。", "invitation.newInvitationSuccess": "🎉 邀請碼已產生,邀請碼已複製", @@ -719,7 +719,6 @@ "rsshub.table.limit_reached": "達到限制", "rsshub.table.official": "官方", "rsshub.table.owner": "建立者", - "rsshub.table.price": "每月價格", "rsshub.table.private": "私人", "rsshub.table.unavailable": "不可用", "rsshub.table.unlimited": "無限制", @@ -728,11 +727,7 @@ "rsshub.table.userLimit": "使用者限制", "rsshub.table.yours": "你的", "rsshub.useModal.about": "關於此實例伺服器", - "rsshub.useModal.month": "個月", - "rsshub.useModal.months_label": "你想購買的月份數量", - "rsshub.useModal.purchase_expires_at": "你已購買此實例伺服器,到期時間為", "rsshub.useModal.title": "RSSHub 實例伺服器", - "rsshub.useModal.useWith": "使用 {{amount}} ", "subscription.actions.comingSoon": "即將推出", "subscription.actions.current": "目前方案", "subscription.actions.manage_error": "開啟訂閱管理時發生問題。", @@ -830,6 +825,7 @@ "wallet.withdraw.availableBalance": "錢包中有 Power 可提領。", "wallet.withdraw.button": "提領", "wallet.withdraw.error": "提領失敗:{{error}}", + "wallet.withdraw.gasFeeNotice": "你需要自行支付以太坊主網 gas fee。提領前請確保這個錢包地址有足夠 ETH 發起一筆交易:{{address}}。", "wallet.withdraw.modalTitle": "提領 Power", "wallet.withdraw.receiveRSS3": "你將收到 {{amount}} RSS3", "wallet.withdraw.submitButton": "送出", From 963f3af397eee2b9d4e2498e33c68d576cc27292 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 14:25:32 +0800 Subject: [PATCH 13/19] fix(desktop): update desktop download link --- apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx index 52704c130..ebca95607 100644 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx @@ -5,7 +5,6 @@ import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typogra import { useMeasure } from "@follow/hooks" import { useUserRole } from "@follow/store/user/hooks" import { cn } from "@follow/utils/utils" -import { repository } from "@pkg" import type { FC } from "react" import { memo, useCallback, useLayoutEffect, useState } from "react" import { useTranslation } from "react-i18next" @@ -193,7 +192,7 @@ export const ProfileButton: FC = memo((props) => { { - window.open(`${repository.url}/releases`) + window.open("https://folo.is/download", "_blank", "noopener,noreferrer") }} icon={} > From 8f56a51b93a3868e981ad2d1b6c382fa71007602 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 14:27:13 +0800 Subject: [PATCH 14/19] fix(desktop): remove connection status indicator --- .../layer/renderer/src/atoms/network.ts | 31 ------- .../layer/renderer/src/initialize/index.ts | 3 - .../layer/renderer/src/lib/api-client.ts | 21 ----- .../SubscriptionColumn.tsx | 3 - .../modules/app/NetworkStatusIndicator.tsx | 84 ------------------- 5 files changed, 142 deletions(-) delete mode 100644 apps/desktop/layer/renderer/src/atoms/network.ts delete mode 100644 apps/desktop/layer/renderer/src/modules/app/NetworkStatusIndicator.tsx diff --git a/apps/desktop/layer/renderer/src/atoms/network.ts b/apps/desktop/layer/renderer/src/atoms/network.ts deleted file mode 100644 index 75a38eb39..000000000 --- a/apps/desktop/layer/renderer/src/atoms/network.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { atom } from "jotai" - -import { createAtomHooks } from "~/lib/jotai" - -export enum NetworkStatus { - ONLINE, - OFFLINE, -} - -export const [, , useNetworkStatus, , getNetworkStatus, setNetworkStatus] = createAtomHooks( - atom(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE), -) - -export const [, , useApiStatus, , getApiStatus, setApiStatus] = createAtomHooks( - atom(NetworkStatus.ONLINE), -) - -export const subscribeNetworkStatus = () => { - const handleOnline = () => setNetworkStatus(NetworkStatus.ONLINE) - const handleOffline = () => setNetworkStatus(NetworkStatus.OFFLINE) - - window.addEventListener("online", handleOnline) - window.addEventListener("offline", handleOffline) - - setNetworkStatus(navigator.onLine ? NetworkStatus.ONLINE : NetworkStatus.OFFLINE) - - return () => { - window.removeEventListener("online", handleOnline) - window.removeEventListener("offline", handleOffline) - } -} diff --git a/apps/desktop/layer/renderer/src/initialize/index.ts b/apps/desktop/layer/renderer/src/initialize/index.ts index c5333468d..c73c7cb43 100644 --- a/apps/desktop/layer/renderer/src/initialize/index.ts +++ b/apps/desktop/layer/renderer/src/initialize/index.ts @@ -13,7 +13,6 @@ import { hydrateSessionsFromLocalDb } from "~/modules/ai-chat-session" import { settingSyncQueue } from "~/modules/settings/helper/sync-queue" import { ElectronCloseEvent, ElectronShowEvent } from "~/providers/invalidate-query-provider" -import { subscribeNetworkStatus } from "../atoms/network" import { appLog } from "../lib/log" import { initAnalytics } from "./analytics" import { registerHistoryStack } from "./history" @@ -79,8 +78,6 @@ export const initializeApp = async () => { // Enable Map/Set in immer enableMapSet() - subscribeNetworkStatus() - apm("initializeSettings", initializeSettings) await apm("i18n", initI18n) diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index da32c92db..33f489cad 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -7,7 +7,6 @@ import { createDesktopAPIHeaders } from "@follow/utils/headers" import { FollowClient } from "@follow-app/client-sdk" import PKG from "@pkg" -import { NetworkStatus, setApiStatus } from "~/atoms/network" import { setLoginModalShow } from "~/atoms/user" import { getAuthSessionToken, getClientId, getSessionId } from "./client-session" @@ -47,26 +46,6 @@ followClient.addRequestInterceptor(async (ctx) => { return ctx }) -followClient.addResponseInterceptor(({ response }) => { - setApiStatus(NetworkStatus.ONLINE) - return response -}) - -followClient.addErrorInterceptor(async ({ error, response }) => { - // If api is down - if ((!response || response.status === 0) && navigator.onLine) { - setApiStatus(NetworkStatus.OFFLINE) - } else { - setApiStatus(NetworkStatus.ONLINE) - } - - if (!response) { - return error - } - - return error -}) - followClient.addResponseInterceptor(async ({ response }) => { if (response.status === 401) { const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx index 40c9e5e50..9042333f2 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx @@ -24,7 +24,6 @@ import { import { FloatingLayerScope } from "~/constants" import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions" import { useI18n } from "~/hooks/common" -import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommandBinding } from "~/modules/command/hooks/use-command-binding" import { CornerPlayer } from "~/modules/player/corner-player" @@ -74,8 +73,6 @@ export const SubscriptionColumnContainer = () => { - - diff --git a/apps/desktop/layer/renderer/src/modules/app/NetworkStatusIndicator.tsx b/apps/desktop/layer/renderer/src/modules/app/NetworkStatusIndicator.tsx deleted file mode 100644 index 2e3ded1f4..000000000 --- a/apps/desktop/layer/renderer/src/modules/app/NetworkStatusIndicator.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx" -import { cn } from "@follow/utils/utils" - -import { NetworkStatus, useApiStatus, useNetworkStatus } from "~/atoms/network" - -export const NetworkStatusIndicator = () => { - const networkStatus = useNetworkStatus() - const apiStatus = useApiStatus() - - if (networkStatus === NetworkStatus.ONLINE && apiStatus === NetworkStatus.ONLINE) { - return null - } - - const isNetworkOffline = networkStatus === NetworkStatus.OFFLINE - const isApiOffline = apiStatus === NetworkStatus.OFFLINE - - // Determine status type for styling - const statusType = isNetworkOffline ? "offline" : isApiOffline ? "api-error" : "unknown" - - return ( - - -
- - - - {isNetworkOffline ? "Local Mode" : isApiOffline ? "API Error" : "Connection Issue"} - -
-
- -
-
- {isNetworkOffline - ? "🔄 Local Mode Active" - : isApiOffline - ? "⚠️ API Connection Error" - : "❌ Connection Problem"} -
-
- {isNetworkOffline - ? "Operating in local data mode due to network connection failure. Some features may be limited." - : isApiOffline - ? "Your network connection is stable, but our API servers are temporarily unreachable. Please try again later." - : "There's an issue with the connection. Please check your network settings."} -
-
-
-
- ) -} From 6e052e487ee51d7d24f8ac10e4c14af746faabfb Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 16:56:23 +0800 Subject: [PATCH 15/19] feat(mobile): show OTA version in about --- .../src/modules/settings/routes/About.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/modules/settings/routes/About.tsx b/apps/mobile/src/modules/settings/routes/About.tsx index c98150bb6..0cd56c39b 100644 --- a/apps/mobile/src/modules/settings/routes/About.tsx +++ b/apps/mobile/src/modules/settings/routes/About.tsx @@ -1,4 +1,6 @@ import { nativeApplicationVersion, nativeBuildVersion } from "expo-application" +import type { Manifest } from "expo-updates" +import * as Updates from "expo-updates" import { Trans, useTranslation } from "react-i18next" import { Linking, View } from "react-native" @@ -53,10 +55,36 @@ const links = [ iconColor: "#FFFFFF", }, ] + +const normalizeOtaVersion = (version: string | null | undefined) => { + const normalizedVersion = version?.trim() + return normalizedVersion || null +} + +const resolveOtaReleaseVersion = (manifest: Partial | undefined) => { + if (!manifest || !("metadata" in manifest)) { + return null + } + + const { metadata } = manifest + if (!metadata || typeof metadata !== "object") { + return null + } + + const releaseVersion = Reflect.get(metadata, "releaseVersion") + return typeof releaseVersion === "string" ? normalizeOtaVersion(releaseVersion) : null +} + export const AboutScreen = () => { const { t } = useTranslation("settings") const buildId = nativeBuildVersion const appVersion = nativeApplicationVersion + const { currentlyRunning } = Updates.useUpdates() + const otaVersion = + resolveOtaReleaseVersion(currentlyRunning.manifest) ?? + normalizeOtaVersion(currentlyRunning.runtimeVersion) ?? + normalizeOtaVersion(Updates.runtimeVersion) + const appVersionLabel = `${appVersion} (${buildId})${otaVersion ? ` · OTA ${otaVersion}` : ""}` const { distribution, platform, rateTarget, storageKey, userId } = useMobileReviewPromptState() const handleRateFolo = async () => { @@ -109,9 +137,7 @@ export const AboutScreen = () => { Folo - - {appVersion} ({buildId}) - + {appVersionLabel} Date: Thu, 30 Apr 2026 17:49:16 +0800 Subject: [PATCH 16/19] docs(desktop): prepare release inputs --- apps/desktop/changelog/next.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449..53ed6a55b 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,11 +1,18 @@ # What's new in vNEXT_VERSION -## Shiny new things - ## Improvements +- Removed the connection status indicator from the desktop app + ## No longer broken +- Fixed duplicate desktop auth session cookies +- Fixed session refresh after cookie updates +- Fixed returning through the Discover route +- Fixed desktop download link +- Fixed MAS review state detection from OTA versions +- Extended API request timeouts + ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributor @cuikaipeng for their valuable contribution From 1cf6d0b63749e5237298c5cfa1dfed6f3f2cd75e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 17:49:25 +0800 Subject: [PATCH 17/19] docs(mobile): prepare release metadata --- apps/mobile/changelog/next.md | 7 +++---- apps/mobile/release-plan.json | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 000f858e3..ad5323241 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -2,10 +2,9 @@ ## Shiny new things -## Improvements +- Added OTA version details to About ## No longer broken -## Thanks - -Special thanks to volunteer contributors @ for their valuable contributions +- Fixed session refresh after cookie updates +- Extended API request timeouts diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 0a4e0fb6f..74637e871 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "store", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "0.5.0", + "channel": "production" } From 91cba64c3dace6852c4ce2254e48ef9cf1a62eef Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 17:55:27 +0800 Subject: [PATCH 18/19] release(mobile): release v0.5.1 --- apps/mobile/changelog/0.5.1.md | 10 ++++++++++ apps/mobile/changelog/next.md | 7 ++++--- apps/mobile/ios/Folo/Info.plist | 4 ++-- apps/mobile/package.json | 2 +- apps/mobile/release-plan.json | 6 +++--- apps/mobile/release.json | 8 ++++---- 6 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/changelog/0.5.1.md diff --git a/apps/mobile/changelog/0.5.1.md b/apps/mobile/changelog/0.5.1.md new file mode 100644 index 000000000..39f611b06 --- /dev/null +++ b/apps/mobile/changelog/0.5.1.md @@ -0,0 +1,10 @@ +# What's New in v0.5.1 + +## Shiny new things + +- Added OTA version details to About + +## No longer broken + +- Fixed session refresh after cookie updates +- Extended API request timeouts diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index ad5323241..000f858e3 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -2,9 +2,10 @@ ## Shiny new things -- Added OTA version details to About +## Improvements ## No longer broken -- Fixed session refresh after cookie updates -- Extended API request timeouts +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index 8c92f272c..adbe405d6 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.0 + 0.5.1 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 3 + 4 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1556f1662..afdc09e01 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.0", + "version": "0.5.1", "private": true, "main": "src/main.tsx", "scripts": { diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 74637e871..0a4e0fb6f 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "0.5.0", - "channel": "production" + "mode": "store", + "runtimeVersion": null, + "channel": null } diff --git a/apps/mobile/release.json b/apps/mobile/release.json index ad73f619b..1c949a3d2 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,6 +1,6 @@ { - "version": "0.5.0", - "mode": "store", - "runtimeVersion": null, - "channel": null + "version": "0.5.1", + "mode": "ota", + "runtimeVersion": "0.5.0", + "channel": "production" } From 26099617ff4b6564d3f07fb87ed648b845c4cd4d Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 30 Apr 2026 17:57:25 +0800 Subject: [PATCH 19/19] docs(mobile): restore desktop release inputs --- apps/desktop/changelog/next.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 53ed6a55b..8f5eac449 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,18 +1,11 @@ # What's new in vNEXT_VERSION +## Shiny new things + ## Improvements -- Removed the connection status indicator from the desktop app - ## No longer broken -- Fixed duplicate desktop auth session cookies -- Fixed session refresh after cookie updates -- Fixed returning through the Discover route -- Fixed desktop download link -- Fixed MAS review state detection from OTA versions -- Extended API request timeouts - ## Thanks -Special thanks to volunteer contributor @cuikaipeng for their valuable contribution +Special thanks to volunteer contributors @ for their valuable contributions