Merge pull request #4991 from RSSNext/release/mobile/0.5.1
release(mobile): Release v0.5.1
This commit is contained in:
commit
65fafb331a
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) || []),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# What's New in v0.5.1
|
||||
|
||||
## Shiny new things
|
||||
|
||||
- Added OTA version details to About
|
||||
|
||||
## No longer broken
|
||||
|
||||
- Fixed session refresh after cookie updates
|
||||
- Extended API request timeouts
|
||||
|
|
@ -53,6 +53,13 @@
|
|||
"env": {
|
||||
"PROFILE": "production"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "production",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.0</string>
|
||||
<string>0.5.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<string>4</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@follow/mobile",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"main": "src/main.tsx",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "0.5.0",
|
||||
"mode": "store",
|
||||
"runtimeVersion": null,
|
||||
"channel": null
|
||||
"version": "0.5.1",
|
||||
"mode": "ota",
|
||||
"runtimeVersion": "0.5.0",
|
||||
"channel": "production"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/soap+xml" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch URL: ${url}`)
|
||||
}),
|
||||
)
|
||||
|
||||
await syncStoreVersions(createEnv({ kvEntries }))
|
||||
|
||||
expect(JSON.parse(String(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))))).toEqual(
|
||||
expect.objectContaining({
|
||||
version: "0.5.0",
|
||||
source: "app-store",
|
||||
}),
|
||||
)
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"[ota] Apple lookup response did not include an iOS version, falling back",
|
||||
)
|
||||
})
|
||||
|
||||
it("logs and skips failed storefronts while persisting successful updates", async () => {
|
||||
const kvEntries = new Map<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(
|
||||
'<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/soap+xml" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch URL: ${url}`)
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(syncStoreVersions(createEnv({ kvEntries }))).resolves.toBeUndefined()
|
||||
|
||||
expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))).toBeUndefined()
|
||||
expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "android"))).toEqual(expect.any(String))
|
||||
expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mas"))).toEqual(expect.any(String))
|
||||
expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mss"))).toEqual(expect.any(String))
|
||||
expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String))
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[ota] Failed to refresh store version for mobile:ios",
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"[ota] Store version sync completed with 1 failure(s); refreshed 3/4 providers",
|
||||
)
|
||||
})
|
||||
|
||||
it("fails when every storefront refresh fails", async () => {
|
||||
const kvEntries = new Map<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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ export default defineConfig({
|
|||
],
|
||||
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ["**/.env", "**/.env.*"],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "https://api.follow.is",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "作成したリスト",
|
||||
|
|
|
|||
|
|
@ -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": "创建的列表",
|
||||
|
|
|
|||
|
|
@ -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": "已創建列表",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "ウェブアプリに戻る",
|
||||
|
|
|
|||
|
|
@ -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": "返回网页版",
|
||||
|
|
|
|||
|
|
@ -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": "返回網頁應用程式",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "送信",
|
||||
|
|
|
|||
|
|
@ -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": "提交",
|
||||
|
|
|
|||
|
|
@ -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": "送出",
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
Loading…
Reference in New Issue