Merge pull request #4990 from RSSNext/release/desktop/1.7.0

release(desktop): Release v1.7.0
This commit is contained in:
DIYgod 2026-04-30 19:25:42 +08:00 committed by GitHub
commit 99419bb998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1375 additions and 558 deletions

View File

@ -14,6 +14,7 @@ on:
default: preview
options:
- preview
- production-apk
- production
description: "Build profile"
release:
@ -28,7 +29,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:
@ -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' }}

View File

@ -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' }}
@ -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:

View File

@ -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 }}

View File

@ -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 <<EOF
This pull request was created automatically after a release branch update.
@ -31,18 +56,18 @@ jobs:
EOF
)
pr_number="$(gh pr list --base dev --head "${source_branch}" --state open --json number --jq '.[0].number')"
pr_number="$(gh pr list --repo "${GITHUB_REPOSITORY}" --base dev --head "${source_branch}" --state open --json number --jq '.[0].number')"
if [ -z "${pr_number}" ]; then
pr_url="$(gh pr create --base dev --head "${source_branch}" --title "${title}" --body "${body}")"
pr_number="$(gh pr view "${pr_url}" --json number --jq '.number')"
pr_url="$(gh pr create --repo "${GITHUB_REPOSITORY}" --base dev --head "${source_branch}" --title "${title}" --body "${body}")"
pr_number="${pr_url##*/}"
echo "Created sync PR: ${pr_url}"
else
gh pr edit "${pr_number}" --title "${title}" --body "${body}"
gh pr edit --repo "${GITHUB_REPOSITORY}" "${pr_number}" --title "${title}" --body "${body}"
echo "Updated existing sync PR: #${pr_number}"
fi
if gh pr merge "${pr_number}" --auto --merge; then
if gh pr merge --repo "${GITHUB_REPOSITORY}" "${pr_number}" --auto --merge; then
echo "Enabled auto-merge for sync PR #${pr_number}"
else
echo "Could not enable auto-merge for sync PR #${pr_number}. Merge it manually after required checks pass."

View File

@ -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'

View File

@ -21,7 +21,6 @@
<a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.mobile.android.version&prefix=v&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5C00&cacheSeconds=600"/></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.desktop.mas.version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.store.desktop.mss.version&prefix=v&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.github.mobile.version&prefix=v&style=flat-square&logo=github&label=Mobile&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fota.folo.is%2Fversions&query=%24.github.desktop.version&prefix=v&style=flat-square&logo=github&label=Desktop&labelColor=black&color=FF5C00&cacheSeconds=600" /></a>
<br />
<br />

View File

@ -0,0 +1,18 @@
# What's new in v1.7.0
## 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

View File

@ -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<void> {
@ -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,
),

View File

@ -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}`

View File

@ -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)
})
})

View File

@ -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<Cookie, "name" | "value"> &
Partial<Pick<Cookie, "domain" | "hostOnly" | "path" | "secure">>
type KnownManagedAuthCookie = ManagedAuthCookie & { name: ManagedAuthCookieName }
type ParsedSetCookie = {
domain?: string
@ -32,6 +39,7 @@ type ParsedSetCookie = {
}
const MANAGED_AUTH_COOKIE_NAME_SET = new Set<string>(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 = <TCookie extends ManagedAuthCookie>(cookies: TCookie[]) => {
return cookies.reduce<TCookie | null>((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<Pick<Cookie, "name" | "value">>) => {
return cookies
.filter((cookie) => isManagedAuthCookie(cookie.name))
export const buildManagedAuthCookieHeader = (cookies: ManagedAuthCookie[]) => {
const selectedCookies = new Map<ManagedAuthCookieName, ManagedAuthCookie>()
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<Cookie>()
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 })
}

View File

@ -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<string | undefined>
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
}

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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<Nullable<ExtractResponseData<GetStatusConfigsResponse>>>(
@ -16,26 +17,32 @@ export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = crea
),
)
export const [, , useMASStoreVersion, , getMASStoreVersion, setMASStoreVersion] = createAtomHooks(
atomWithStorage<null | string>(getStorageNS("mas-store-version"), null, undefined, {
getOnInit: true,
}),
)
export type ServerConfigs = ExtractResponseData<GetStatusConfigsResponse>
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 = () => {

View File

@ -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)

View File

@ -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)
})
})

View File

@ -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"
@ -6,14 +7,13 @@ 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"
export const followClient = new FollowClient({
credentials: "include",
timeout: 30000,
timeout: 60_000,
baseURL: env.VITE_API_URL,
fetch: async (input, options = {}) =>
fetch(input.toString(), {
@ -33,7 +33,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),
)
}
@ -46,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

View File

@ -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),
)
}
},

View File

@ -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)
}

View File

@ -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 = () => {
<CornerPlayer />
<UpdateNotice />
<NetworkStatusIndicator />
</SubscriptionColumn>
</DndContext>
</FeedResponsiveResizerContainer>

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"fixed bottom-3 left-3 flex items-center gap-2 rounded-full border backdrop-blur-md transition-all duration-200 hover:scale-105",
"px-3 py-2 shadow-lg ring-1 ring-inset",
// Default styling
"border-fill bg-material-thick text-text-secondary",
// Network offline - more severe styling
statusType === "offline" && [
"border-red/30 bg-red/10 text-red ring-red/20",
"dark:border-red/40 dark:bg-red/15 dark:text-red dark:ring-red/25",
ELECTRON && "!bg-sidebar",
],
// API error - warning styling
statusType === "api-error" && [
"border-red/30 bg-red/10 text-red ring-red/20",
"dark:border-red/40 dark:bg-red/15 dark:text-red dark:ring-red/25",
ELECTRON && "!bg-sidebar",
],
ELECTRON && "backdrop-blur-none",
)}
>
<i
className={cn(
"size-4 shrink-0 transition-all duration-200",
statusType === "offline" && "i-mgc-wifi-off-cute-re",
statusType === "api-error" && "i-mgc-wifi-off-cute-re",
)}
/>
<span
className={cn(
"shrink-0 text-xs font-medium transition-colors duration-200",
statusType === "offline" && "text-orange",
statusType === "api-error" && "text-red",
)}
>
{isNetworkOffline ? "Local Mode" : isApiOffline ? "API Error" : "Connection Issue"}
</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch] text-sm" align="start" side="top" sideOffset={8}>
<div className="space-y-1">
<div className="font-medium">
{isNetworkOffline
? "🔄 Local Mode Active"
: isApiOffline
? "⚠️ API Connection Error"
: "❌ Connection Problem"}
</div>
<div className="text-xs leading-relaxed text-text-secondary">
{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."}
</div>
</div>
</TooltipContent>
</Tooltip>
)
}

View File

@ -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<void>
})
| undefined
await authService?.setSessionToken?.(token)
}
const getElectronAuthService = () => {
if (!ipcServices) {
return null
}
return ipcServices.auth as typeof ipcServices.auth & {
setSessionToken?: (token: string) => Promise<void>
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)
}
}

View File

@ -117,7 +117,7 @@ export const FeedForm: Component<{
isError: feedQuery.isError,
})
}
}, [feedQuery.isLoading])
}, [feedQuery.data?.feed.url, feedQuery.isError, feedQuery.isLoading, id, url])
return (
<div
@ -250,7 +250,7 @@ const FeedInnerForm = ({
useEffect(() => {
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<typeof formSchema>) => {

View File

@ -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<z.infer<typeof formSchema>>({
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<typeof formSchema>) => {
present(values)
present({ ...values, toRss3: true })
}
useEffect(() => {
@ -126,6 +132,9 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 lg:w-96">
<div className="rounded-md border border-orange/20 bg-orange/10 p-3 text-xs leading-relaxed text-text-secondary">
{t("wallet.withdraw.gasFeeNotice", { address: walletAddress })}
</div>
<FormField
control={form.control}
name="address"
@ -161,7 +170,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
<FormField
control={form.control}
name="toRss3"
render={({ field }) => (
render={() => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="flex items-center gap-1">
@ -173,7 +182,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
<TooltipPortal>
<TooltipContent>
<span className="text-xs text-gray-500">
<span>1 POWER = {rss3ConversionRate ?? "-"} RSS3</span>
<span>1 POWER = {RSS3_CONVERSION_RATE} RSS3</span>
</span>
</TooltipContent>
</TooltipPortal>
@ -181,17 +190,15 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
</FormLabel>
<FormControl className="!mt-0">
<span className="inline-flex">
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch checked={true} disabled />
</span>
</FormControl>
</div>
{field.value && rss3ConversionRate !== null && (
<span className="text-xs text-gray-500">
{t("wallet.withdraw.receiveRSS3", {
amount: ((form.watch("amount") || 0) * rss3ConversionRate).toFixed(4),
})}
</span>
)}
<span className="text-xs text-gray-500">
{t("wallet.withdraw.receiveRSS3", {
amount: receiveAmount.toFixed(4),
})}
</span>
<FormMessage />
</FormItem>
)}

View File

@ -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`,
]

View File

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
months: hasPurchase ? 0 : 1,
},
})
const months = form.watch("months")
const onSubmit = (data: z.infer<typeof formSchema>) => {
preset({ id: instance.id, durationInMonths: data.months })
}
useEffect(() => {
if (setRSSHubMutation.isSuccess) {
dismiss()
}
}, [setRSSHubMutation.isSuccess])
}, [setRSSHubMutation.isSuccess, dismiss])
return (
<div className="max-w-[550px] space-y-4 lg:min-w-[550px]">
@ -85,12 +47,6 @@ export function SetModalContent({
<td className="text-sm text-text-secondary">{t("rsshub.table.description")}</td>
<td className="line-clamp-2">{instance.description}</td>
</tr>
<tr>
<td className="text-sm text-text-secondary">{t("rsshub.table.price")}</td>
<td className="flex items-center gap-1">
{instance.price} <i className="i-mgc-power text-folo" />
</td>
</tr>
<tr>
<td className="text-sm text-text-secondary">{t("rsshub.table.userCount")}</td>
<td>{instance.userCount}</td>
@ -103,64 +59,15 @@ export function SetModalContent({
</table>
</CardContent>
</Card>
{details.data?.purchase && (
<div>
<div className="text-sm text-text-secondary">
{t("rsshub.useModal.purchase_expires_at")}
</div>
<div className="line-clamp-2">
{new Date(details.data.purchase.expiresAt).toLocaleString()}
</div>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{price > 0 && (
<FormField
control={form.control}
name="months"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-4">
<FormLabel>{t("rsshub.useModal.months_label")}</FormLabel>
<FormControl className="!mt-0">
<div className="flex items-center gap-10">
<div className="space-x-2">
<Input
className="w-24"
type="number"
inputMode="numeric"
pattern="[0-9]*"
max={12}
min={hasPurchase ? 0 : 1}
{...field}
/>
<span className="text-sm text-text-secondary">
{t("rsshub.useModal.month")}
</span>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex items-center justify-end">
<Button type="submit" isLoading={setRSSHubMutation.isPending}>
{price ? (
<Trans
ns="settings"
i18nKey={"rsshub.useModal.useWith"}
components={{ Power: <i className="i-mgc-power ml-1 text-white" /> }}
values={{ amount: price * months }}
/>
) : (
t("rsshub.table.use")
)}
</Button>
</div>
</form>
</Form>
<div className="flex items-center justify-end">
<Button
type="button"
isLoading={setRSSHubMutation.isPending}
onClick={() => preset({ id: instance.id })}
>
{t("rsshub.table.use")}
</Button>
</div>
</div>
)
}

View File

@ -47,8 +47,12 @@ export const PaidBadge: Component<{
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{paidLevel === SettingPaidLevels.FreeLimited && t("control.paid_badge.free_limited")}
{paidLevel === SettingPaidLevels.Basic && t("control.paid_badge.basic_or_higher")}
{paidLevel === SettingPaidLevels.FreeLimited && (
<span>{t("control.paid_badge.free_limited")}</span>
)}
{paidLevel === SettingPaidLevels.Basic && (
<span>{t("control.paid_badge.basic_or_higher")}</span>
)}
</TooltipContent>
</TooltipPortal>
</Tooltip>

View File

@ -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")
}
}}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>

View File

@ -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"
@ -139,7 +138,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
}}
icon={<i className="i-mgc-power-outline" />}
>
{t("user_button.power")}
{t("user_button.wallet")}
</DropdownMenuItem>
)}
<DropdownMenuItem
@ -193,7 +192,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuItem
className="pl-3"
onClick={() => {
window.open(`${repository.url}/releases`)
window.open("https://folo.is/download", "_blank", "noopener,noreferrer")
}}
icon={<i className="i-mgc-download-2-cute-re" />}
>

View File

@ -2,7 +2,7 @@ import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { RSSHubLogo } from "@follow/components/ui/platform-icon/icons.js"
import { whoami } from "@follow/store/user/getters"
import { cn, formatNumber } from "@follow/utils/utils"
import { cn } from "@follow/utils/utils"
import type { RSSHubListItem } from "@follow-app/client-sdk"
import { memo, useCallback, useEffect } from "react"
import { useTranslation } from "react-i18next"
@ -110,7 +110,6 @@ const InstanceCard = memo(({ item }: { item: InstanceItem }) => {
)
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 }) => {
<div className="flex items-center gap-1">{tags}</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-1 text-sm font-medium">
{formatNumber(price ?? 0)} <i className="i-mgc-power size-3 text-folo" />
</div>
</div>
</div>
<p className="mb-3 line-clamp-1 text-xs text-text-secondary">{description}</p>
@ -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
}) || []),
]

View File

@ -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
}

View File

@ -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

View File

@ -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<unknown> | 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()

View File

@ -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<OTAVersionsResponse>(OTA_VERSIONS_URL, {
cache: "no-store",
})
return getMASStoreVersionFromOTAVersions(response)
},
enabled: isMASBuild(),
})
return data
}

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "1.6.1",
"version": "1.7.0",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",
@ -96,7 +96,7 @@
"vite-plugin-route-builder": "0.4.1",
"vite-tsconfig-paths": "6.1.1"
},
"runtimeVersion": "1.6.1",
"runtimeVersion": "1.7.0",
"productName": "Folo",
"mainHash": "6135b4a64577e6f5b0668b388e8defb987c4a52626594c7eb1b8684c49efe0e6"
"mainHash": "524fdb3b17478aa5705bc86d0a1550b322c58e1605f1cd28acbf88dbdbb18629"
}

View File

@ -1,5 +1,5 @@
{
"version": "1.6.1",
"version": "1.7.0",
"mode": "build",
"runtimeVersion": null,
"channel": null

View File

@ -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: {

View File

@ -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",

View File

@ -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:

View File

@ -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",

View File

@ -53,6 +53,13 @@
"env": {
"PROFILE": "production"
}
},
"production-apk": {
"extends": "production",
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
},
"submit": {

View File

@ -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<PropsWithChildren> = ({ children }) => {
}
const SideEffect = () => {
useAuthSessionCookieRefresh()
usePrefetchSessionUser()
useUnreadCountBadge()
useBackHandler()

View File

@ -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,
})

View File

@ -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 {

View File

@ -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<Manifest> | 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 = () => {
<View className="flex-1 items-center justify-center">
<Logo height={80} width={80} />
<Text className="mt-4 text-2xl font-semibold text-label">Folo</Text>
<Text className="font-mono text-sm text-tertiary-label">
{appVersion} ({buildId})
</Text>
<Text className="font-mono text-sm text-tertiary-label">{appVersionLabel}</Text>
</View>
<View className="mt-6 flex-1">
<Trans

View File

@ -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<string, unknown>([
[
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",

View File

@ -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(
'<script type="application/json">{"primarySubtitle":"Version 0.4.4"}</script>',
{ 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)

View File

@ -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<string, unknown>()
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(
'<script type="application/json">{"primarySubtitle":"Version 0.5.0"}</script>',
{
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("<EncryptedData>cookie-value</EncryptedData>", {
status: 200,
headers: { "Content-Type": "application/soap+xml" },
})
}
return new Response(
'&lt;PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123"&gt;',
{
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<string, unknown>()
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("<EncryptedData>cookie-value</EncryptedData>", {
status: 200,
headers: { "Content-Type": "application/soap+xml" },
})
}
return new Response(
'&lt;PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123"&gt;',
{
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<string, unknown>()
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", () => {

View File

@ -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,
}

View File

@ -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<T>(url: string, context: string): Promise<T> {
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<{

View File

@ -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`,
)
}

View File

@ -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<DesktopOtaRelease | null> {
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
}

View File

@ -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": [

View File

@ -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(), {

View File

@ -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),
})

View File

@ -49,6 +49,9 @@ export default defineConfig({
],
server: {
watch: {
ignored: ["**/.env", "**/.env.*"],
},
proxy: {
"/api": {
target: "https://api.follow.is",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "作成したリスト",

View File

@ -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": "创建的列表",

View File

@ -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": "已創建列表",

View File

@ -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",

View File

@ -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",

View File

@ -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": "ウェブアプリに戻る",

View File

@ -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": "返回网页版",

View File

@ -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": "返回網頁應用程式",

View File

@ -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}} <PowerIcon /> 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}} <PowerIcon /> 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}} <Power />",
"spotlight.add_rule": "Add rule",
"spotlight.case_sensitive": "Case sensitive",
"spotlight.color": "Color",
@ -865,6 +860,7 @@
"wallet.withdraw.availableBalance": "You have <Balance></Balance> 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",

View File

@ -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}} <PowerIcon /> 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}} <PowerIcon /> 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}} <Power />",
"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 <Balance></Balance> 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",

View File

@ -526,14 +526,14 @@
"invitation.confirmModal.cancel": "キャンセル",
"invitation.confirmModal.confirm": "続けますか?",
"invitation.confirmModal.continue": "続行",
"invitation.confirmModal.message": "招待コードを生成するには、{{INVITATION_PRICE}} <PowerIcon /> Power が必要です。",
"invitation.confirmModal.message": "招待コードを生成すると招待枠を 1 つ使用します。",
"invitation.confirmModal.title": "確認",
"invitation.created_at": "作成日",
"invitation.earlyAccess": "現在、Folo は<strong>アーリーアクセス</strong>中で、招待コードが必要です。",
"invitation.earlyAccessMessage": "😰 申し訳ありません。Folo は現在アーリーアクセス中で、招待コードが必要です。",
"invitation.generate": "生成",
"invitation.generateButton": "新しいコードを生成",
"invitation.generateCost": "{{INVITATION_PRICE}} <PowerIcon /> 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}} <Power />",
"spotlight.add_rule": "ルールを追加",
"spotlight.case_sensitive": "大文字と小文字を区別",
"spotlight.color": "色",
@ -861,6 +856,7 @@
"wallet.withdraw.availableBalance": "引き出し可能な Power は<Balance></Balance>です。",
"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": "送信",

View File

@ -530,14 +530,14 @@
"invitation.confirmModal.cancel": "取消",
"invitation.confirmModal.confirm": "确认继续?",
"invitation.confirmModal.continue": "继续",
"invitation.confirmModal.message": "生成邀请码将花费 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon>。",
"invitation.confirmModal.message": "生成邀请码将消耗一个邀请码额度。",
"invitation.confirmModal.title": "确认",
"invitation.created_at": "创建于",
"invitation.earlyAccess": "Folo 目前处于<strong>早期开发</strong>状态,需要邀请码才能使用。",
"invitation.earlyAccessMessage": "😰 抱歉Folo 目前处于抢先体验阶段,需要邀请码才能使用。",
"invitation.generate": "生成",
"invitation.generateButton": "生成邀请码",
"invitation.generateCost": "你可以花费 {{INVITATION_PRICE}} <PowerIcon /> 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}} <Power />",
"spotlight.add_rule": "添加规则",
"spotlight.case_sensitive": "区分大小写",
"spotlight.color": "颜色",
@ -865,6 +860,7 @@
"wallet.withdraw.availableBalance": "钱包中有 <Balance></Balance> 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": "提交",

View File

@ -526,14 +526,14 @@
"invitation.confirmModal.cancel": "取消",
"invitation.confirmModal.confirm": "您想繼續嗎?",
"invitation.confirmModal.continue": "繼續",
"invitation.confirmModal.message": "產生邀請碼將會花費您 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon>。",
"invitation.confirmModal.message": "產生邀請碼將使用一個邀請碼額度。",
"invitation.confirmModal.title": "確認",
"invitation.created_at": "建立者:",
"invitation.earlyAccess": "Folo 目前處於<strong>早期開發</strong>狀態,需要邀請碼才能使用。",
"invitation.earlyAccessMessage": "😰 抱歉,關注目前處於搶先體驗階段,需要邀請碼才能使用。",
"invitation.generate": "產生",
"invitation.generateButton": "產生邀請碼",
"invitation.generateCost": "您可以花費 {{INVITATION_PRICE}} <PowerIcon>Power</PowerIcon> 您的朋友產生邀請碼。",
"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}} <Power />",
"subscription.actions.comingSoon": "即將推出",
"subscription.actions.current": "目前方案",
"subscription.actions.manage_error": "開啟訂閱管理時發生問題。",
@ -830,6 +825,7 @@
"wallet.withdraw.availableBalance": "錢包中有 <Balance></Balance> 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": "送出",

View File

@ -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}`
}