chore(sync): merge main into dev

# Conflicts:
#	apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx
This commit is contained in:
DIYgod 2026-04-03 13:28:15 +08:00
commit aea74f80a7
23 changed files with 660 additions and 77 deletions

View File

@ -1,7 +1,7 @@
{
"name": "folocli",
"type": "module",
"version": "0.0.1",
"version": "0.0.4",
"description": "Folo CLI for terminal workflows and automation",
"author": "Folo Team",
"license": "AGPL-3.0-only",

View File

@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("./browser-login", () => ({
loginWithBrowser: vi.fn(),
resolveBrowserLoginToken: vi.fn(),
}))
const { loginWithBrowser, resolveBrowserLoginToken } = await import("./browser-login")
const { resolveLoginToken } = await import("./commands/auth")
describe("resolveLoginToken", () => {
it("exchanges a provided one-time token into a session token", async () => {
vi.mocked(resolveBrowserLoginToken).mockResolvedValueOnce("session-token")
await expect(
resolveLoginToken({
inputToken: "one-time-token",
apiUrl: "https://api.folo.is",
timeoutMs: 180_000,
onStatus: vi.fn(),
}),
).resolves.toBe("session-token")
expect(resolveBrowserLoginToken).toHaveBeenCalledWith("https://api.folo.is", "one-time-token")
})
it("falls back to browser login when no token is provided", async () => {
vi.mocked(loginWithBrowser).mockResolvedValueOnce({
token: "browser-session-token",
callbackUrl: "http://127.0.0.1/callback",
loginUrl: "https://app.folo.is/login",
})
await expect(
resolveLoginToken({
apiUrl: "https://api.folo.is",
timeoutMs: 180_000,
onStatus: vi.fn(),
}),
).resolves.toBe("browser-session-token")
})
})

View File

@ -56,12 +56,15 @@ describe("browser login helpers", () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { token: "session-token" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
headers: {
"content-type": "application/json",
"set-cookie":
"__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None",
},
},
),
)
@ -72,6 +75,49 @@ describe("browser login helpers", () => {
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
})
it("falls back to verify when apply endpoint is unavailable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 404,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { token: "session-token" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.folo.is/better-auth/one-time-token/verify",
expect.objectContaining({
method: "POST",

View File

@ -8,6 +8,7 @@ import { CLIError } from "./output"
const LOCAL_CALLBACK_HOST = "127.0.0.1"
const LOCAL_CALLBACK_PATH = "/callback"
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
const ONE_TIME_TOKEN_APPLY_PATH = "/better-auth/one-time-token/apply"
const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify"
const SESSION_CHECK_PATH = "/better-auth/get-session"
@ -114,6 +115,38 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
const readSetCookieValues = (response: Response): string[] => {
if (typeof response.headers.getSetCookie === "function") {
return response.headers.getSetCookie()
}
const setCookie = response.headers.get("set-cookie")
return setCookie ? [setCookie] : []
}
const extractSessionTokenFromSetCookie = (setCookieValues: string[]): string | undefined => {
for (const setCookie of setCookieValues) {
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
if (match?.[1]) {
return match[1]
}
}
return undefined
}
const extractSessionTokenFromBody = (data: unknown): string | undefined => {
if (!isRecord(data)) {
return undefined
}
if (isRecord(data.session) && typeof data.session.token === "string") {
return data.session.token
}
return undefined
}
const extractErrorMessage = async (response: Response): Promise<string | undefined> => {
const contentType = response.headers.get("content-type") ?? ""
@ -147,42 +180,80 @@ const hasValidSessionToken = async (apiUrl: string, token: string): Promise<bool
}
export const resolveBrowserLoginToken = async (apiUrl: string, token: string): Promise<string> => {
const applyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_APPLY_PATH)
const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH)
let response: Response
const requestBody = JSON.stringify({ token })
let response: Response | undefined
let errorMessage: string | undefined
try {
response = await fetch(verifyUrl, {
response = await fetch(applyUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ token }),
body: requestBody,
})
} catch (error) {
throw new CLIError(
"NETWORK_ERROR",
`Failed to verify browser login token: ${(error as Error).message}`,
`Failed to apply browser login token: ${(error as Error).message}`,
)
}
if (response.ok) {
const data = (await response.json().catch(() => null)) as unknown
const sessionToken =
isRecord(data) && isRecord(data.session) && typeof data.session.token === "string"
? data.session.token
: undefined
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
extractSessionTokenFromBody(data)
if (!sessionToken) {
throw new CLIError(
"UNAUTHORIZED",
"Browser login verification succeeded without returning a session token.",
"Browser login token apply succeeded without returning a session token.",
)
}
return sessionToken
}
const errorMessage = await extractErrorMessage(response)
errorMessage = await extractErrorMessage(response)
if (response.status === 404) {
try {
response = await fetch(verifyUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: requestBody,
})
} catch (error) {
throw new CLIError(
"NETWORK_ERROR",
`Failed to verify browser login token: ${(error as Error).message}`,
)
}
if (response.ok) {
const data = (await response.json().catch(() => null)) as unknown
const sessionToken =
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
extractSessionTokenFromBody(data)
if (!sessionToken) {
throw new CLIError(
"UNAUTHORIZED",
"Browser login verification succeeded without returning a session token.",
)
}
return sessionToken
}
errorMessage = await extractErrorMessage(response)
}
try {
if (await hasValidSessionToken(apiUrl, token)) {

View File

@ -12,7 +12,7 @@ const readString = (value: unknown): string | undefined => {
return typeof value === "string" && value.length > 0 ? value : undefined
}
const normalizeToken = (token: string | undefined) => {
export const normalizeToken = (token: string | undefined) => {
if (!token || !token.includes("%")) {
return token
}
@ -42,6 +42,61 @@ export interface CommandContext {
token?: string
}
export interface CLIAuthSession {
user?: Record<string, unknown>
session?: Record<string, unknown>
role?: unknown
roleEndAt?: unknown
feedSubscriptionLimit?: unknown
rsshubSubscriptionLimit?: unknown
}
const readSessionErrorMessage = async (response: Response): Promise<string | undefined> => {
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null
return typeof data?.message === "string" ? data.message : undefined
}
const text = await response.text().catch(() => "")
return text || undefined
}
export const fetchAuthSession = async ({
apiUrl,
token,
verbose = false,
}: {
apiUrl: string
token: string
verbose?: boolean
}): Promise<CLIAuthSession> => {
const requestUrl = `${apiUrl}/better-auth/get-session`
if (verbose) {
console.error(`[request] GET ${requestUrl}`)
}
const response = await fetch(requestUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
},
})
if (verbose) {
console.error(`[response] GET ${requestUrl} -> ${response.status}`)
}
if (!response.ok) {
const message = await readSessionErrorMessage(response)
throw new CLIError("UNAUTHORIZED", message || "Token is invalid or expired.")
}
return (await response.json()) as CLIAuthSession
}
export const getGlobalOptions = (command: Command): GlobalOptions => {
const options = command.optsWithGlobals() as Record<string, unknown>

View File

@ -1,12 +1,36 @@
import type { Command } from "commander"
import { parsePositiveInt } from "../args"
import { loginWithBrowser } from "../browser-login"
import { getGlobalOptions } from "../client"
import { loginWithBrowser, resolveBrowserLoginToken } from "../browser-login"
import { fetchAuthSession, getGlobalOptions, normalizeToken } from "../client"
import { runCommand } from "../command"
import { clearToken, getConfigPath, updateConfig } from "../config"
import { CLIError } from "../output"
export const resolveLoginToken = async ({
inputToken,
apiUrl,
timeoutMs,
onStatus,
}: {
inputToken?: string
apiUrl: string
timeoutMs: number
onStatus: (message: string) => void
}) => {
if (inputToken) {
return await resolveBrowserLoginToken(apiUrl, inputToken)
}
const browserLogin = await loginWithBrowser({
apiUrl,
timeoutMs,
onStatus,
})
return browserLogin.token
}
interface AuthLoginOptions {
token?: string
timeout?: number
@ -16,21 +40,22 @@ const runLoginAction = async function (this: Command, options: AuthLoginOptions)
await runCommand(
this,
async ({ client, options: globalOptions }) => {
let token = options.token ?? getGlobalOptions(this).token
if (!token) {
const timeoutMs = (options.timeout ?? 180) * 1000
const browserLogin = await loginWithBrowser({
apiUrl: globalOptions.apiUrl,
timeoutMs,
onStatus: (message) => {
console.error(`[auth] ${message}`)
},
})
token = browserLogin.token
}
const resolvedToken = await resolveLoginToken({
inputToken: options.token ?? getGlobalOptions(this).token,
apiUrl: globalOptions.apiUrl,
timeoutMs: (options.timeout ?? 180) * 1000,
onStatus: (message) => {
console.error(`[auth] ${message}`)
},
})
const token = normalizeToken(resolvedToken) ?? resolvedToken
client.setAuthToken(token)
const session = await client.api.auth.getSession()
const session = await fetchAuthSession({
apiUrl: globalOptions.apiUrl,
token,
verbose: globalOptions.verbose,
})
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
@ -66,8 +91,16 @@ const runLogoutAction = async function (this: Command) {
}
const runWhoamiAction = async function (this: Command) {
await runCommand(this, async ({ client }) => {
const session = await client.api.auth.getSession()
await runCommand(this, async ({ token, options: globalOptions }) => {
if (!token) {
throw new CLIError("UNAUTHORIZED", "Missing token.")
}
const session = await fetchAuthSession({
apiUrl: globalOptions.apiUrl,
token,
verbose: globalOptions.verbose,
})
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
@ -87,7 +120,7 @@ const registerLoginCommand = (program: Command, name: string, description: strin
program
.command(name)
.description(description)
.option("--token <token>", "Session token from Folo")
.option("--token <token>", "Session or one-time token from Folo")
.option(
"--timeout <seconds>",
"Browser login timeout in seconds (default: 180)",

View File

@ -0,0 +1,22 @@
# What's new in v1.5.0
## Shiny new things
## Improvements
- Polished authentication, wallet, notifications, and discover surfaces
- Kept the AI chat input within the viewport during longer conversations
## No longer broken
- Fixed Electron sign-in for accounts using two-factor authentication
- Fixed PDF export through Electron IPC
- Fixed auth origin headers and restored renderer API requests
- Fixed theme preference persistence
- Fixed hovered unread entries disappearing in poor network conditions
- Fixed Obsidian vault selection and export reliability on macOS with native folder picking
- Fixed Android video playback changing the original audio pitch
## Thanks
Special thanks to volunteer contributor @Eumenides-K for their valuable contributions

View File

@ -9,16 +9,19 @@ import { WindowManager } from "~/manager/window"
import {
buildManagedAuthCookieHeader,
buildManagedAuthCookieHeaderFromSetCookieHeader,
getManagedAuthCookies,
persistManagedAuthCookiesFromSetCookieHeader,
} from "../../lib/auth-cookies"
import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync"
import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync"
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
import { logger } from "../../logger"
export class AuthService extends IpcService {
static override readonly groupName = "auth"
private pendingTwoFactorCookieHeader: string | null = null
private getAuthRequestHeaders(additionalHeaders?: Record<string, string>) {
return {
...createDesktopAPIHeaders({ version: PKG.version }),
@ -61,6 +64,7 @@ export class AuthService extends IpcService {
}
private async clearSessionToken(): Promise<void> {
this.pendingTwoFactorCookieHeader = null
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) {
return
@ -93,7 +97,12 @@ export class AuthService extends IpcService {
.json()
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
const setCookie = response.headers.get("set-cookie") || ""
const setCookieValues =
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []
const setCookie =
setCookieValues.length > 0
? setCookieValues.join(", ")
: response.headers.get("set-cookie") || ""
const mainWindow = WindowManager.getMainWindow()
if (response.ok && setCookie && mainWindow) {
await persistManagedAuthCookiesFromSetCookieHeader({
@ -102,6 +111,13 @@ export class AuthService extends IpcService {
setCookieHeader: setCookie,
})
}
const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie)
this.pendingTwoFactorCookieHeader =
response.ok && typeof data.twoFactorRedirect === "boolean" && data.twoFactorRedirect
? pendingTwoFactorCookieHeader || null
: null
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
const sessionToken = sessionCookieMatch?.[1] ?? null
const token = typeof data.token === "string" ? data.token : null
@ -126,11 +142,13 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async sessionChanged(_context: IpcContext): Promise<void> {
async sessionChanged(_context: IpcContext, preferredToken?: string): Promise<void> {
await updateNotificationsToken()
// Sync the current desktop session to the npm CLI login.
const token = await getSessionTokenFromCookies()
const token = await getCliSessionToken({
preferredToken,
})
await syncSessionToCliConfig(token).catch((err) => {
logger.error("Failed to sync session to CLI config:", err)
})
@ -168,14 +186,16 @@ export class AuthService extends IpcService {
payload: { code: string; trustDevice?: boolean; headers?: Record<string, string> },
) {
const mainWindow = WindowManager.getMainWindow()
const cookieHeader = mainWindow
? buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
}),
)
: ""
const cookieHeader =
this.pendingTwoFactorCookieHeader ||
(mainWindow
? buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
}),
)
: "")
const response = await fetch(`${env.VITE_API_URL}/better-auth/two-factor/verify-totp`, {
method: "POST",
@ -193,7 +213,10 @@ export class AuthService extends IpcService {
const data = (await response
.json()
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
const setCookie = response.headers.get("set-cookie") || ""
const setCookie =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie().join(", ")
: response.headers.get("set-cookie") || ""
if (response.ok && setCookie && mainWindow) {
await persistManagedAuthCookiesFromSetCookieHeader({
apiURL: env.VITE_API_URL,
@ -202,6 +225,22 @@ export class AuthService extends IpcService {
})
}
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
const sessionTokenFromCookie = sessionCookieMatch?.[1] ?? null
const sessionTokenFromBody =
data.session && typeof data.session === "object" && "token" in data.session
? (data.session as { token?: unknown }).token
: null
const sessionToken =
typeof sessionTokenFromBody === "string" ? sessionTokenFromBody : sessionTokenFromCookie
if (typeof sessionToken === "string") {
data.sessionToken = sessionToken
}
if (response.ok) {
this.pendingTwoFactorCookieHeader = null
}
return {
data,
error: response.ok

View File

@ -6,6 +6,7 @@ import {
getCliConfigPath,
getCliInstallCommand,
getCliLoginCommand,
getCliSessionToken,
getSessionTokenFromCookies,
isCliRunnerAvailable,
readCliConfig,
@ -45,13 +46,18 @@ export class CliService extends IpcService {
}
@IpcMethod()
async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
async installCli(
_context: IpcContext,
preferredToken?: string,
): Promise<{ success: boolean; error?: string }> {
try {
if (!(await isCliRunnerAvailable())) {
return { success: false, error: "npx is not available. Install Node.js and npm first." }
}
const token = await getSessionTokenFromCookies()
const token = await getCliSessionToken({
preferredToken,
})
if (!token) {
return { success: false, error: "Sign in to Folo Desktop first." }
}

View File

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"
import {
buildManagedAuthCookieHeader,
buildManagedAuthCookieHeaderFromSetCookieHeader,
getManagedAuthCookieNames,
persistManagedAuthCookiesFromSetCookieHeader,
} from "./auth-cookies"
@ -25,6 +26,19 @@ describe("auth cookies", () => {
expect(getManagedAuthCookieNames()).toContain("two_factor")
})
it("keeps prefixed two-factor cookies from a set-cookie header", () => {
const header = buildManagedAuthCookieHeaderFromSetCookieHeader(
[
"__Secure-better-auth.two_factor=signed-two-factor; Path=/; HttpOnly; Secure; SameSite=Lax",
"better-auth.last_used_login_method=email; Path=/; HttpOnly; Secure; SameSite=Lax",
].join(", "),
)
expect(header).toBe(
"__Secure-better-auth.two_factor=signed-two-factor; better-auth.last_used_login_method=email",
)
})
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 () => {})

View File

@ -7,8 +7,14 @@ const MANAGED_AUTH_COOKIE_NAMES = [
"better-auth.session_data",
"better-auth.last_used_login_method",
"dont_remember",
"__Secure-better-auth.dont_remember",
"better-auth.dont_remember",
"trust_device",
"__Secure-better-auth.trust_device",
"better-auth.trust_device",
"two_factor",
"__Secure-better-auth.two_factor",
"better-auth.two_factor",
] as const
type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number]
@ -161,6 +167,18 @@ export const getManagedAuthCookieNames = () => {
return [...MANAGED_AUTH_COOKIE_NAMES]
}
export const buildManagedAuthCookieHeaderFromSetCookieHeader = (setCookieHeader: string) => {
if (!setCookieHeader.trim()) {
return ""
}
return parseSetCookieHeader(setCookieHeader)
.filter((cookie) => isManagedAuthCookie(cookie.name))
.filter((cookie) => !shouldRemoveCookie(cookie))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ")
}
export const buildManagedAuthCookieHeader = (cookies: Array<Pick<Cookie, "name" | "value">>) => {
return cookies
.filter((cookie) => isManagedAuthCookie(cookie.name))

View File

@ -0,0 +1,7 @@
export const resolveCliSessionToken = ({
preferredToken,
cookieToken,
}: {
preferredToken?: string
cookieToken?: string
}) => cookieToken || preferredToken

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest"
import { resolveCliSessionToken } from "./cli-login-token"
describe("resolveCliSessionToken", () => {
it("prefers the desktop session cookie token", () => {
expect(
resolveCliSessionToken({
preferredToken: "one-time-token",
cookieToken: "session-token",
}),
).toBe("session-token")
})
it("falls back to the preferred token when no cookie token exists", () => {
expect(
resolveCliSessionToken({
preferredToken: "session-token",
}),
).toBe("session-token")
})
it("returns undefined when neither token is available", () => {
expect(resolveCliSessionToken({})).toBeUndefined()
})
})

View File

@ -4,12 +4,16 @@ import { homedir } from "node:os"
import { promisify } from "node:util"
import { env } from "@follow/shared/env.desktop"
import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers"
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 { resolveCliSessionToken } from "./cli-login-token"
const execFileAsync = promisify(execFile)
export const CLI_NPM_PACKAGE_NAME = "folocli"
@ -18,6 +22,12 @@ const CLI_CONFIG_DIR = join(homedir(), ".folo")
const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json")
const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx")
const getCliSyncRequestHeaders = (additionalHeaders?: Record<string, string>) => ({
...createDesktopAPIHeaders({ version: PKG.version }),
...createAuthRequestOriginHeaders(env.VITE_WEB_URL),
...additionalHeaders,
})
export interface CliConfig {
token?: string
apiUrl?: string
@ -41,8 +51,7 @@ export const getCliConfigPath = () => CLI_CONFIG_PATH
export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help`
export const getCliLoginCommand = () =>
`npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <session-token>`
export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <token>`
const runCliCommand = async (args: string[]) => {
await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], {
@ -90,6 +99,93 @@ export const getSessionTokenFromCookies = async (): Promise<string | undefined>
return sessionCookie?.value
}
const generateOneTimeTokenFromCurrentSession = async (): Promise<string | undefined> => {
const window = WindowManager.getMainWindow()
if (!window) return undefined
const cookieHeader = buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: window.webContents.session,
}),
)
if (!cookieHeader) {
return undefined
}
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/generate`, {
method: "GET",
headers: getCliSyncRequestHeaders({
Cookie: cookieHeader,
}),
})
if (!response.ok) {
return undefined
}
const data = (await response.json().catch(() => null)) as { token?: unknown } | null
return typeof data?.token === "string" ? data.token : undefined
}
const resolveSessionTokenFromOneTimeToken = async (
oneTimeToken: string,
): Promise<string | undefined> => {
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/apply`, {
method: "POST",
headers: getCliSyncRequestHeaders({
"content-type": "application/json",
}),
body: JSON.stringify({ token: oneTimeToken }),
})
if (!response.ok) {
return undefined
}
const setCookieValues =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: ([response.headers.get("set-cookie")].filter(Boolean) as string[])
for (const setCookie of setCookieValues) {
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
if (match?.[1]) {
return match[1]
}
}
const data = (await response.json().catch(() => null)) as { session?: { token?: unknown } } | null
return typeof data?.session?.token === "string" ? data.session.token : undefined
}
export const getCliSessionToken = async ({
preferredToken,
}: {
preferredToken?: string
} = {}): Promise<string | undefined> => {
const oneTimeToken = await generateOneTimeTokenFromCurrentSession().catch((error) => {
logger.error("Failed to generate one-time token for CLI sync:", error)
return
})
if (oneTimeToken) {
const sessionToken = await resolveSessionTokenFromOneTimeToken(oneTimeToken).catch((error) => {
logger.error("Failed to resolve session token from one-time token:", error)
return
})
if (sessionToken) {
return sessionToken
}
}
return resolveCliSessionToken({
preferredToken,
cookieToken: await getSessionTokenFromCookies(),
})
}
export const syncSessionToCliConfig = async (token?: string): Promise<void> => {
if (token) {
const config = await readCliConfig()

View File

@ -37,25 +37,44 @@ 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") {
return token
}
}
if (
"data" in result &&
result.data &&
typeof result.data === "object" &&
("sessionToken" in result.data || "token" in result.data)
("sessionToken" in result.data || "token" in result.data || "session" in result.data)
) {
const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown }
if (typeof sessionToken === "string") {
return sessionToken
const { sessionToken, token, session } = result.data as {
sessionToken?: unknown
token?: unknown
session?: { token?: unknown } | unknown
}
return typeof token === "string" ? token : null
if (typeof token === "string") {
return token
}
if (
session &&
typeof session === "object" &&
"token" in session &&
typeof session.token === "string"
) {
return session.token
}
return typeof sessionToken === "string" ? sessionToken : null
}
return null
@ -74,7 +93,15 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => {
return {}
}
return result as ElectronAuthResult
const normalized = result as ElectronAuthResult & Record<string, unknown>
if ("data" in normalized || "error" in normalized) {
return normalized
}
return {
data: normalized,
error: null,
}
}
const setElectronSessionToken = async (token: string) => {

View File

@ -283,6 +283,7 @@ const useRegisterObsidianCommands = () => {
author: string
publishedAt: string
vaultPath: string
description?: string
}) => {
return await ipcServices?.integration.saveToObsidian(data)
},

View File

@ -78,7 +78,7 @@ export function TOTPForm({
const updateMutation = useMutation({
mutationFn: onSubmitMutationFn,
onError: (error) => {
const { code } = getFetchErrorInfo(error)
const { code, message } = getFetchErrorInfo(error)
if (error.message === "invalid two factor authentication" || code === 4007) {
form.resetField("code")
form.setError("code", {
@ -90,7 +90,10 @@ export function TOTPForm({
form.setFocus("code")
}, 10)
controls.start("shake")
return
}
toast.error(message || t("profile.totp_code.invalid"))
},
onSuccess,
})
@ -135,6 +138,11 @@ export function TOTPForm({
</FormItem>
)}
/>
<div className="text-right">
<Button type="submit" isLoading={updateMutation.isPending}>
{t("profile.submit")}
</Button>
</div>
</form>
</Form>
)

View File

@ -0,0 +1,24 @@
import type { SVGProps } from "react"
export const Android2CuteReIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path fill="currentColor" fillOpacity="0.01" d="M24 0v24H0V0z" />
<path
fill="currentColor"
d="M9 13.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="m6 5 1.5 3m9 0L18 5m-9 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM4.833 18h14.334C20.179 18 21 17.18 21 16.167V16a9 9 0 0 0-9-9v0a9 9 0 0 0-9 9v.167C3 17.179 3.82 18 4.833 18Z"
/>
</svg>
)

View File

@ -4,10 +4,38 @@ import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { oneTimeToken } from "~/lib/auth"
import { ipcServices } from "~/lib/client"
import { getAuthSessionToken } from "~/lib/client-session"
import { copyToClipboard } from "~/lib/clipboard"
import { SettingSectionTitle } from "../section"
const getOneTimeTokenFromResult = (result: unknown) => {
if (!result || typeof result !== "object") {
return null
}
if ("token" in result && typeof result.token === "string") {
return result.token
}
if (
"data" in result &&
result.data &&
typeof result.data === "object" &&
"token" in result.data &&
typeof result.data.token === "string"
) {
return result.data.token
}
return null
}
const LATEST_WITH_NPX_COMMAND = "npx --yes folocli@latest --help"
const AGENT_PROMPT = "Read https://api.folo.is/skill.md and follow the instructions to use Folo."
export const SettingCli = () => {
interface CliInstallStatus {
connected: boolean
@ -36,7 +64,10 @@ export const SettingCli = () => {
const handleInstall = useCallback(async () => {
setLoading(true)
try {
const result = await ipcServices?.cli.installCli()
const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate())
const result = await ipcServices?.cli.installCli(
generatedOneTimeToken ?? getAuthSessionToken() ?? undefined,
)
if (result?.success) {
toast.success(t("cli.install_success"))
} else {
@ -102,24 +133,39 @@ export const SettingCli = () => {
<div className="grid gap-3">
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{t("cli.package")}
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
<span>RUN LATEST WITH NPX</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
onClick={() => {
void copyToClipboard(LATEST_WITH_NPX_COMMAND)
toast.success("Command copied")
}}
>
<i className="i-mgc-copy-2-cute-re text-sm" />
Copy
</button>
</div>
<code className="text-sm font-medium">{status.packageName}</code>
<code className="block break-all text-sm">{LATEST_WITH_NPX_COMMAND}</code>
</div>
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{t("cli.global_install")}
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
<span>AGENT PROMPT</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
onClick={() => {
void copyToClipboard(AGENT_PROMPT)
toast.success("Prompt copied")
}}
>
<i className="i-mgc-copy-2-cute-re text-sm" />
Copy
</button>
</div>
<code className="block break-all text-sm">{status.installCommand}</code>
</div>
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{t("cli.desktop_sync")}
</div>
<code className="block break-all text-sm">{status.loginCommand}</code>
<p className="text-sm text-text-secondary">{AGENT_PROMPT}</p>
</div>
</div>

View File

@ -14,7 +14,7 @@ import {
} from "@follow/components/ui/platform-icon/icons.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import type { FC } from "react"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"

View File

@ -1,14 +1,15 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { Android2CuteReIcon } from "~/modules/settings/icons/Android2CuteReIcon"
import { SettingCli } from "~/modules/settings/tabs/cli"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-terminal-cute-re"
const priority = (1000 << 1) + 25
export const loader = defineSettingPageData({
icon: iconName,
icon: <Android2CuteReIcon />,
headerIcon: <Android2CuteReIcon className="size-5 text-accent" />,
name: "titles.cli",
priority,
hideIf: () => !IN_ELECTRON,

View File

@ -94,7 +94,8 @@ export const useSession = (options?: { enabled?: boolean }) => {
export const handleSessionChanges = () => {
setLoginModalShow(false)
ipcServices?.auth.sessionChanged()
const authSessionToken = getAuthSessionToken()
ipcServices?.auth.sessionChanged(authSessionToken ?? undefined)
window.location.reload()
}

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",
@ -94,5 +94,5 @@
"vite-tsconfig-paths": "6.1.1"
},
"productName": "Folo",
"mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57"
"mainHash": "4778cc1cb43d08ed2a5edf391f0d22694641dc15eb27eaefb45bfa09ff176091"
}