From 151662c8dd167992c83f3a36c798560013b3385c Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 18:07:36 +0800 Subject: [PATCH 01/15] docs(desktop): prepare release changelog --- apps/desktop/changelog/next.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449..71557977e 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -4,8 +4,17 @@ ## 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 + ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributor @Eumenides-K for their valuable contributions From 80936d27f029af4671466f9ad857c374c4b83443 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 18:09:19 +0800 Subject: [PATCH 02/15] release(desktop): release v1.5.0 --- apps/desktop/changelog/1.5.0.md | 20 ++++++++++++++++++++ apps/desktop/changelog/next.md | 11 +---------- apps/desktop/package.json | 4 ++-- 3 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/changelog/1.5.0.md diff --git a/apps/desktop/changelog/1.5.0.md b/apps/desktop/changelog/1.5.0.md new file mode 100644 index 000000000..7bd807d91 --- /dev/null +++ b/apps/desktop/changelog/1.5.0.md @@ -0,0 +1,20 @@ +# 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 + +## Thanks + +Special thanks to volunteer contributor @Eumenides-K for their valuable contributions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 71557977e..8f5eac449 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -4,17 +4,8 @@ ## 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 - ## Thanks -Special thanks to volunteer contributor @Eumenides-K for their valuable contributions +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2c5137fe6..99b995dc5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" } From cda954e7b0cc9509ed2ec85312a568f9da322aed Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 19:13:39 +0800 Subject: [PATCH 03/15] fix(mobile): refresh session queries on cookie updates --- apps/mobile/src/lib/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index 5a5ac3a28..688128220 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -75,7 +75,7 @@ const plugins = [ } } bumpAuthStateRevision() - const authStateChanged = !previousValue + const authStateChanged = previousValue !== value if (authStateChanged) { void refreshSessionQueries() } From c799152a1e28657b32c9144981142001acede988 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 19:43:05 +0800 Subject: [PATCH 04/15] fix(desktop): repair cli settings sync --- .../layer/main/src/ipc/services/auth.ts | 13 +++++++--- .../layer/main/src/ipc/services/cli.ts | 11 +++++++-- .../layer/main/src/lib/cli-session-sync.ts | 8 +++++++ .../settings/icons/Android2CuteReIcon.tsx | 24 +++++++++++++++++++ .../src/modules/settings/tabs/cli.tsx | 4 +++- .../src/pages/settings/(settings)/cli.tsx | 5 ++-- .../layer/renderer/src/queries/auth.ts | 3 ++- 7 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 1bb2620dc..3082bf9c5 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -12,7 +12,11 @@ import { getManagedAuthCookies, persistManagedAuthCookiesFromSetCookieHeader, } from "../../lib/auth-cookies" -import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync" +import { + getSessionTokenFromCookies, + resolveCliSessionToken, + syncSessionToCliConfig, +} from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" import { logger } from "../../logger" @@ -126,11 +130,14 @@ export class AuthService extends IpcService { } @IpcMethod() - async sessionChanged(_context: IpcContext): Promise { + async sessionChanged(_context: IpcContext, preferredToken?: string): Promise { await updateNotificationsToken() // Sync the current desktop session to the npm CLI login. - const token = await getSessionTokenFromCookies() + const token = resolveCliSessionToken({ + preferredToken, + cookieToken: await getSessionTokenFromCookies(), + }) await syncSessionToCliConfig(token).catch((err) => { logger.error("Failed to sync session to CLI config:", err) }) diff --git a/apps/desktop/layer/main/src/ipc/services/cli.ts b/apps/desktop/layer/main/src/ipc/services/cli.ts index feef0aa8d..50f1577eb 100644 --- a/apps/desktop/layer/main/src/ipc/services/cli.ts +++ b/apps/desktop/layer/main/src/ipc/services/cli.ts @@ -9,6 +9,7 @@ import { getSessionTokenFromCookies, isCliRunnerAvailable, readCliConfig, + resolveCliSessionToken, syncSessionToCliConfig, } from "../../lib/cli-session-sync" @@ -45,13 +46,19 @@ 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 = resolveCliSessionToken({ + preferredToken, + cookieToken: await getSessionTokenFromCookies(), + }) if (!token) { return { success: false, error: "Sign in to Folo Desktop first." } } diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index 79fcdf0c9..c848049d3 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -90,6 +90,14 @@ export const getSessionTokenFromCookies = async (): Promise return sessionCookie?.value } +export const resolveCliSessionToken = ({ + preferredToken, + cookieToken, +}: { + preferredToken?: string + cookieToken?: string +}) => preferredToken || cookieToken + export const syncSessionToCliConfig = async (token?: string): Promise => { if (token) { const config = await readCliConfig() diff --git a/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx b/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx new file mode 100644 index 000000000..634b49525 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from "react" + +export const Android2CuteReIcon = (props: SVGProps) => ( + + + + + +) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx index 0ff314e64..dbfa5b2a7 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { ipcServices } from "~/lib/client" +import { getAuthSessionToken } from "~/lib/client-session" import { SettingSectionTitle } from "../section" @@ -36,7 +37,8 @@ export const SettingCli = () => { const handleInstall = useCallback(async () => { setLoading(true) try { - const result = await ipcServices?.cli.installCli() + const authSessionToken = getAuthSessionToken() + const result = await ipcServices?.cli.installCli(authSessionToken ?? undefined) if (result?.success) { toast.success(t("cli.install_success")) } else { diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx index a64966178..cad159987 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx @@ -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: , + headerIcon: , name: "titles.cli", priority, hideIf: () => !IN_ELECTRON, diff --git a/apps/desktop/layer/renderer/src/queries/auth.ts b/apps/desktop/layer/renderer/src/queries/auth.ts index 7562e152c..935ce318e 100644 --- a/apps/desktop/layer/renderer/src/queries/auth.ts +++ b/apps/desktop/layer/renderer/src/queries/auth.ts @@ -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() } From 8b3a1b8965c7b3f4d31d2b55530c8b9ea70c0d6e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 21:02:46 +0800 Subject: [PATCH 05/15] fix(desktop): use one-time token for cli sync --- .../layer/main/src/ipc/services/auth.ts | 9 +- .../layer/main/src/ipc/services/cli.ts | 5 +- .../layer/main/src/lib/cli-session-sync.ts | 85 +++++++++++++++++++ .../layer/renderer/src/modules/auth/Form.tsx | 14 +-- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 3082bf9c5..3fe79e9ad 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -12,11 +12,7 @@ import { getManagedAuthCookies, persistManagedAuthCookiesFromSetCookieHeader, } from "../../lib/auth-cookies" -import { - getSessionTokenFromCookies, - resolveCliSessionToken, - syncSessionToCliConfig, -} from "../../lib/cli-session-sync" +import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" import { logger } from "../../logger" @@ -134,9 +130,8 @@ export class AuthService extends IpcService { await updateNotificationsToken() // Sync the current desktop session to the npm CLI login. - const token = resolveCliSessionToken({ + const token = await getCliSessionToken({ preferredToken, - cookieToken: await getSessionTokenFromCookies(), }) await syncSessionToCliConfig(token).catch((err) => { logger.error("Failed to sync session to CLI config:", err) diff --git a/apps/desktop/layer/main/src/ipc/services/cli.ts b/apps/desktop/layer/main/src/ipc/services/cli.ts index 50f1577eb..09dd2f0be 100644 --- a/apps/desktop/layer/main/src/ipc/services/cli.ts +++ b/apps/desktop/layer/main/src/ipc/services/cli.ts @@ -6,10 +6,10 @@ import { getCliConfigPath, getCliInstallCommand, getCliLoginCommand, + getCliSessionToken, getSessionTokenFromCookies, isCliRunnerAvailable, readCliConfig, - resolveCliSessionToken, syncSessionToCliConfig, } from "../../lib/cli-session-sync" @@ -55,9 +55,8 @@ export class CliService extends IpcService { return { success: false, error: "npx is not available. Install Node.js and npm first." } } - const token = resolveCliSessionToken({ + const token = await getCliSessionToken({ preferredToken, - cookieToken: await getSessionTokenFromCookies(), }) if (!token) { return { success: false, error: "Sign in to Folo Desktop first." } diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index c848049d3..1c5c90456 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -4,12 +4,15 @@ 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" const execFileAsync = promisify(execFile) export const CLI_NPM_PACKAGE_NAME = "folocli" @@ -18,6 +21,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) => ({ + ...createDesktopAPIHeaders({ version: PKG.version }), + ...createAuthRequestOriginHeaders(env.VITE_WEB_URL), + ...additionalHeaders, +}) + export interface CliConfig { token?: string apiUrl?: string @@ -98,6 +107,82 @@ export const resolveCliSessionToken = ({ cookieToken?: string }) => preferredToken || cookieToken +const generateOneTimeTokenFromCurrentSession = async (): Promise => { + 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 => { + const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/verify`, { + method: "POST", + headers: getCliSyncRequestHeaders({ + "content-type": "application/json", + }), + body: JSON.stringify({ token: oneTimeToken }), + }) + + if (!response.ok) { + return undefined + } + + 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 => { + 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 => { if (token) { const config = await readCliConfig() diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 6d54589c6..822428640 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -37,14 +37,14 @@ const getAuthTokenFromResult = (result: unknown) => { return null } - if ("sessionToken" in result && typeof result.sessionToken === "string") { - return result.sessionToken - } - if ("token" in result && typeof result.token === "string") { return result.token } + if ("sessionToken" in result && typeof result.sessionToken === "string") { + return result.sessionToken + } + if ( "data" in result && result.data && @@ -52,10 +52,10 @@ const getAuthTokenFromResult = (result: unknown) => { ("sessionToken" in result.data || "token" in result.data) ) { const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown } - if (typeof sessionToken === "string") { - return sessionToken + if (typeof token === "string") { + return token } - return typeof token === "string" ? token : null + return typeof sessionToken === "string" ? sessionToken : null } return null From 0508144f8dc438a680d80063d38b0d477fbda490 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 21:11:58 +0800 Subject: [PATCH 06/15] fix(desktop): use one-time token in cli settings --- .../src/modules/settings/tabs/cli.tsx | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx index dbfa5b2a7..d5a359b30 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx @@ -4,11 +4,34 @@ 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 { 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 +} + export const SettingCli = () => { interface CliInstallStatus { connected: boolean @@ -37,8 +60,11 @@ export const SettingCli = () => { const handleInstall = useCallback(async () => { setLoading(true) try { - const authSessionToken = getAuthSessionToken() - const result = await ipcServices?.cli.installCli(authSessionToken ?? undefined) + const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate()) + const fallbackToken = getAuthSessionToken() + const result = await ipcServices?.cli.installCli( + generatedOneTimeToken ?? fallbackToken ?? undefined, + ) if (result?.success) { toast.success(t("cli.install_success")) } else { From 69bed891a32aab8eeb17343fcc34b0b730efb4a1 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 22:16:26 +0800 Subject: [PATCH 07/15] fix(desktop): add explicit 2fa submit --- .../layer/renderer/src/modules/profile/two-factor.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx index 6144ff7fc..87b22252d 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx @@ -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({ )} /> +
+ +
) From 43b5052b06e08ae7996d654e6bd2109dd4f04884 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 22:19:36 +0800 Subject: [PATCH 08/15] fix(desktop): persist session token after 2fa --- .../layer/main/src/ipc/services/auth.ts | 8 +++++++ .../layer/renderer/src/modules/auth/Form.tsx | 23 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 3fe79e9ad..d0244b2c7 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -204,6 +204,14 @@ export class AuthService extends IpcService { }) } + const sessionToken = + data.session && typeof data.session === "object" && "token" in data.session + ? (data.session as { token?: unknown }).token + : null + if (typeof sessionToken === "string") { + data.sessionToken = sessionToken + } + return { data, error: response.ok diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 822428640..f36c0f6ad 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -45,16 +45,35 @@ const getAuthTokenFromResult = (result: unknown) => { return result.sessionToken } + 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 } + const { sessionToken, token, session } = result.data as { + sessionToken?: unknown + token?: unknown + session?: { token?: unknown } | unknown + } 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 } From 4cb761a5015e7138860d3076a34680394cb6e0b6 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Apr 2026 11:24:49 +0800 Subject: [PATCH 09/15] fix(desktop): persist two-factor challenge cookies --- .../layer/main/src/ipc/services/auth.ts | 51 +++++++++++++++---- .../layer/main/src/lib/auth-cookies.test.ts | 14 +++++ .../layer/main/src/lib/auth-cookies.ts | 21 ++++++-- .../layer/renderer/src/modules/auth/Form.tsx | 10 +++- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index d0244b2c7..3a868123d 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -9,6 +9,7 @@ import { WindowManager } from "~/manager/window" import { buildManagedAuthCookieHeader, + buildManagedAuthCookieHeaderFromSetCookieHeader, getManagedAuthCookies, persistManagedAuthCookiesFromSetCookieHeader, } from "../../lib/auth-cookies" @@ -19,6 +20,8 @@ import { logger } from "../../logger" export class AuthService extends IpcService { static override readonly groupName = "auth" + private pendingTwoFactorCookieHeader: string | null = null + private getAuthRequestHeaders(additionalHeaders?: Record) { return { ...createDesktopAPIHeaders({ version: PKG.version }), @@ -61,6 +64,7 @@ export class AuthService extends IpcService { } private async clearSessionToken(): Promise { + 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 - 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 @@ -170,14 +186,16 @@ export class AuthService extends IpcService { payload: { code: string; trustDevice?: boolean; headers?: Record }, ) { 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", @@ -195,7 +213,10 @@ export class AuthService extends IpcService { const data = (await response .json() .catch(async () => ({ message: await response.text() }))) as Record - 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, @@ -204,14 +225,22 @@ export class AuthService extends IpcService { }) } - const sessionToken = + 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 diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts index d8290360c..9c27a33cb 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts @@ -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 () => {}) diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.ts b/apps/desktop/layer/main/src/lib/auth-cookies.ts index 698ed3b19..3401b244a 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.ts @@ -6,9 +6,12 @@ const MANAGED_AUTH_COOKIE_NAMES = [ "__Secure-better-auth.session_data", "better-auth.session_data", "better-auth.last_used_login_method", - "dont_remember", - "trust_device", - "two_factor", + "__Secure-better-auth.dont_remember", + "better-auth.dont_remember", + "__Secure-better-auth.trust_device", + "better-auth.trust_device", + "__Secure-better-auth.two_factor", + "better-auth.two_factor", ] as const type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number] @@ -161,6 +164,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>) => { return cookies .filter((cookie) => isManagedAuthCookie(cookie.name)) diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index f36c0f6ad..51c5c365e 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -93,7 +93,15 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => { return {} } - return result as ElectronAuthResult + const normalized = result as ElectronAuthResult & Record + if ("data" in normalized || "error" in normalized) { + return normalized + } + + return { + data: normalized, + error: null, + } } const setElectronSessionToken = async (token: string) => { From b7ccf7b872e8e1a7820d5da88d2faf4d047c6e00 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Apr 2026 12:37:22 +0800 Subject: [PATCH 10/15] fix(desktop): repair auth and cli sync --- apps/cli/package.json | 2 +- apps/cli/src/auth-command.test.ts | 42 +++++++++ apps/cli/src/browser-login.test.ts | 50 ++++++++++- apps/cli/src/browser-login.ts | 89 +++++++++++++++++-- apps/cli/src/client.ts | 57 +++++++++++- apps/cli/src/commands/auth.ts | 69 ++++++++++---- .../layer/main/src/lib/cli-login-token.ts | 7 ++ .../main/src/lib/cli-session-sync.test.ts | 26 ++++++ .../layer/main/src/lib/cli-session-sync.ts | 25 +++--- .../src/modules/settings/tabs/cli.tsx | 48 ++++++---- 10 files changed, 358 insertions(+), 57 deletions(-) create mode 100644 apps/cli/src/auth-command.test.ts create mode 100644 apps/desktop/layer/main/src/lib/cli-login-token.ts create mode 100644 apps/desktop/layer/main/src/lib/cli-session-sync.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 836893178..e81ff96a3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/src/auth-command.test.ts b/apps/cli/src/auth-command.test.ts new file mode 100644 index 000000000..9dace6bf8 --- /dev/null +++ b/apps/cli/src/auth-command.test.ts @@ -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") + }) +}) diff --git a/apps/cli/src/browser-login.test.ts b/apps/cli/src/browser-login.test.ts index 7fc46a685..d4d8def79 100644 --- a/apps/cli/src/browser-login.test.ts +++ b/apps/cli/src/browser-login.test.ts @@ -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", diff --git a/apps/cli/src/browser-login.ts b/apps/cli/src/browser-login.ts index d19a863f6..856ccc1ac 100644 --- a/apps/cli/src/browser-login.ts +++ b/apps/cli/src/browser-login.ts @@ -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 => { 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 => { const contentType = response.headers.get("content-type") ?? "" @@ -147,42 +180,80 @@ const hasValidSessionToken = async (apiUrl: string, token: string): Promise => { + 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)) { diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 2cfa020d0..d690f819c 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -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 + session?: Record + role?: unknown + roleEndAt?: unknown + feedSubscriptionLimit?: unknown + rsshubSubscriptionLimit?: unknown +} + +const readSessionErrorMessage = async (response: Response): Promise => { + const contentType = response.headers.get("content-type") ?? "" + + if (contentType.includes("application/json")) { + const data = (await response.json().catch(() => null)) as Record | 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 => { + 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 diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 19c92ad15..e6f2ec46f 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -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 ", "Session token from Folo") + .option("--token ", "Session or one-time token from Folo") .option( "--timeout ", "Browser login timeout in seconds (default: 180)", diff --git a/apps/desktop/layer/main/src/lib/cli-login-token.ts b/apps/desktop/layer/main/src/lib/cli-login-token.ts new file mode 100644 index 000000000..aa218106a --- /dev/null +++ b/apps/desktop/layer/main/src/lib/cli-login-token.ts @@ -0,0 +1,7 @@ +export const resolveCliSessionToken = ({ + preferredToken, + cookieToken, +}: { + preferredToken?: string + cookieToken?: string +}) => cookieToken || preferredToken diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts new file mode 100644 index 000000000..189d5a29e --- /dev/null +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index 1c5c90456..81157be55 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -13,6 +13,7 @@ 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" @@ -50,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 ` +export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token ` const runCliCommand = async (args: string[]) => { await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], { @@ -99,14 +99,6 @@ export const getSessionTokenFromCookies = async (): Promise return sessionCookie?.value } -export const resolveCliSessionToken = ({ - preferredToken, - cookieToken, -}: { - preferredToken?: string - cookieToken?: string -}) => preferredToken || cookieToken - const generateOneTimeTokenFromCurrentSession = async (): Promise => { const window = WindowManager.getMainWindow() if (!window) return undefined @@ -140,7 +132,7 @@ const generateOneTimeTokenFromCurrentSession = async (): Promise => { - const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/verify`, { + const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/apply`, { method: "POST", headers: getCliSyncRequestHeaders({ "content-type": "application/json", @@ -152,6 +144,17 @@ const resolveSessionTokenFromOneTimeToken = async ( 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 } diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx index d5a359b30..73e20d84f 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx @@ -7,6 +7,7 @@ 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" @@ -32,6 +33,9 @@ const getOneTimeTokenFromResult = (result: unknown) => { 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 @@ -61,9 +65,8 @@ export const SettingCli = () => { setLoading(true) try { const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate()) - const fallbackToken = getAuthSessionToken() const result = await ipcServices?.cli.installCli( - generatedOneTimeToken ?? fallbackToken ?? undefined, + generatedOneTimeToken ?? getAuthSessionToken() ?? undefined, ) if (result?.success) { toast.success(t("cli.install_success")) @@ -130,24 +133,39 @@ export const SettingCli = () => {
-
- {t("cli.package")} +
+ RUN LATEST WITH NPX +
- {status.packageName} + {LATEST_WITH_NPX_COMMAND}
-
- {t("cli.global_install")} +
+ AGENT PROMPT +
- {status.installCommand} -
- -
-
- {t("cli.desktop_sync")} -
- {status.loginCommand} +

{AGENT_PROMPT}

From 89148dd877f6171a8093f0fe650edc532e2877a8 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 2 Apr 2026 19:13:59 +0800 Subject: [PATCH 11/15] fix(mobile): update auth e2e flows --- apps/mobile/e2e/flows/android/login.yaml | 11 +++++--- apps/mobile/e2e/flows/ios/login.yaml | 9 +++---- apps/mobile/e2e/flows/ios/sign-out.yaml | 23 ++++++++--------- apps/mobile/e2e/flows/shared/login.yaml | 13 ++++++---- apps/mobile/e2e/flows/shared/open-auth.yaml | 6 ++--- apps/mobile/e2e/flows/shared/sign-out.yaml | 28 ++++++++++++--------- 6 files changed, 47 insertions(+), 43 deletions(-) diff --git a/apps/mobile/e2e/flows/android/login.yaml b/apps/mobile/e2e/flows/android/login.yaml index dbdbdca77..1de11f752 100644 --- a/apps/mobile/e2e/flows/android/login.yaml +++ b/apps/mobile/e2e/flows/android/login.yaml @@ -2,8 +2,10 @@ appId: is.follow --- - runFlow: file: ./dismiss-system-dialogs.yaml -- assertVisible: - id: login-screen +- extendedWaitUntil: + visible: + id: login-provider-credential + timeout: 60000 - tapOn: id: auth-toggle-mode - extendedWaitUntil: @@ -29,10 +31,11 @@ appId: is.follow - eraseText - setClipboard: ${E2E_PASSWORD} - pasteText -- pressKey: Enter +- tapOn: + id: login-submit - extendedWaitUntil: notVisible: - id: login-screen + id: login-provider-credential timeout: 30000 - extendedWaitUntil: visible: diff --git a/apps/mobile/e2e/flows/ios/login.yaml b/apps/mobile/e2e/flows/ios/login.yaml index 912a370c4..7ada6befc 100644 --- a/apps/mobile/e2e/flows/ios/login.yaml +++ b/apps/mobile/e2e/flows/ios/login.yaml @@ -1,7 +1,5 @@ appId: is.follow --- -- assertVisible: - id: login-screen - waitForAnimationToEnd: timeout: 15000 - extendedWaitUntil: @@ -48,10 +46,11 @@ appId: is.follow id: login-password-input - eraseText - inputText: ${E2E_PASSWORD} -- pressKey: Enter +- tapOn: + id: login-submit - extendedWaitUntil: notVisible: - id: login-screen + id: login-provider-credential timeout: 30000 - assertNotVisible: - id: login-screen + id: login-provider-credential diff --git a/apps/mobile/e2e/flows/ios/sign-out.yaml b/apps/mobile/e2e/flows/ios/sign-out.yaml index bb5b4686b..302d75078 100644 --- a/apps/mobile/e2e/flows/ios/sign-out.yaml +++ b/apps/mobile/e2e/flows/ios/sign-out.yaml @@ -21,22 +21,19 @@ appId: is.follow id: navigation-back - waitForAnimationToEnd - runFlow: - when: - visible: Settings commands: - tapOn: - text: Settings -- runFlow: - when: - notVisible: Settings - commands: - - tapOn: - point: "89%,86%" + point: "89%,95%" + - waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: settings-general-link + timeout: 15000 - scrollUntilVisible: element: id: settings-sign-out direction: DOWN - timeout: 10000 + timeout: 15000 centerElement: false - tapOn: id: settings-sign-out @@ -44,10 +41,10 @@ appId: is.follow visible: Confirm sign out timeout: 10000 - tapOn: - text: Sign out + text: Sign Out - extendedWaitUntil: visible: - id: login-screen + id: login-provider-credential timeout: 30000 - assertVisible: - id: login-screen + id: login-provider-credential diff --git a/apps/mobile/e2e/flows/shared/login.yaml b/apps/mobile/e2e/flows/shared/login.yaml index 91fea3478..5363b481e 100644 --- a/apps/mobile/e2e/flows/shared/login.yaml +++ b/apps/mobile/e2e/flows/shared/login.yaml @@ -1,7 +1,9 @@ appId: is.follow --- -- assertVisible: - id: login-screen +- extendedWaitUntil: + visible: + id: login-provider-credential + timeout: 60000 - tapOn: id: auth-toggle-mode - extendedWaitUntil: @@ -20,10 +22,11 @@ appId: is.follow id: login-password-input - eraseText - inputText: ${E2E_PASSWORD} -- pressKey: Enter +- tapOn: + id: login-submit - extendedWaitUntil: notVisible: - id: login-screen + id: login-provider-credential timeout: 30000 - assertNotVisible: - id: login-screen + id: login-provider-credential diff --git a/apps/mobile/e2e/flows/shared/open-auth.yaml b/apps/mobile/e2e/flows/shared/open-auth.yaml index faf645af7..c21e7a598 100644 --- a/apps/mobile/e2e/flows/shared/open-auth.yaml +++ b/apps/mobile/e2e/flows/shared/open-auth.yaml @@ -11,6 +11,7 @@ appId: is.follow commands: - tapOn: id: no-login-timeline + - waitForAnimationToEnd - runFlow: when: visible: @@ -18,10 +19,7 @@ appId: is.follow commands: - tapOn: id: home-avatar-trigger -- extendedWaitUntil: - visible: - id: login-screen - timeout: 30000 + - waitForAnimationToEnd - extendedWaitUntil: visible: id: login-provider-credential diff --git a/apps/mobile/e2e/flows/shared/sign-out.yaml b/apps/mobile/e2e/flows/shared/sign-out.yaml index 32d8138dc..7e5f9d828 100644 --- a/apps/mobile/e2e/flows/shared/sign-out.yaml +++ b/apps/mobile/e2e/flows/shared/sign-out.yaml @@ -2,19 +2,23 @@ appId: is.follow --- - tapOn: id: tab-SettingsTabScreen -- scrollUntilVisible: - element: - text: Sign Out - direction: DOWN - timeout: 10000 - centerElement: false -- tapOn: - text: Sign Out -- tapOn: - text: Sign out - extendedWaitUntil: visible: - id: login-screen + id: settings-general-link + timeout: 15000 +- scrollUntilVisible: + element: + id: settings-sign-out + direction: DOWN + timeout: 15000 + centerElement: false +- tapOn: + id: settings-sign-out +- tapOn: + text: Sign Out +- extendedWaitUntil: + visible: + id: login-provider-credential timeout: 30000 - assertVisible: - id: login-screen + id: login-provider-credential From 20cc43cf160f10f1358f20eeb3d8066b7391a0a9 Mon Sep 17 00:00:00 2001 From: wait Date: Thu, 2 Apr 2026 20:57:21 +0800 Subject: [PATCH 12/15] fix: resolve Obsidian EPERM on macOS by using native folder picker (#4242) (#4924) - Replace text input with native dialog.showOpenDialog for vault path selection - Add path validity detection with three-state UI (unselected/valid/invalid) - Fix YAML frontmatter parsing failure with Chinese/special characters - Fix ENOENT error when vault subdirectory doesn't exist - Add AI summary (description) to Obsidian frontmatter export - Add author fallback to feed title when entry author is empty - Increase filename truncation limit from 20 to 80 characters for CJK titles Co-authored-by: Claude Opus 4.6 --- .../layer/main/src/ipc/services/app.ts | 19 ++++++ .../main/src/ipc/services/integration.ts | 15 +++-- .../modules/command/commands/integration.tsx | 4 +- .../settings/tabs/integration/index.tsx | 67 +++++++++++++++++-- locales/settings/en.json | 4 ++ locales/settings/zh-CN.json | 4 ++ 6 files changed, 100 insertions(+), 13 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index 134ed9bb8..2076fa685 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -285,4 +285,23 @@ export class AppService extends IpcService { getCacheSize(_context: IpcContext) { return getCacheSize() } + + @IpcMethod() + async selectDirectory(_context: IpcContext): Promise { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }) + if (result.canceled || result.filePaths.length === 0) return null + return result.filePaths[0]! + } + + @IpcMethod() + async checkPathExists(_context: IpcContext, input: string): Promise { + try { + await fsp.access(input) + return true + } catch { + return false + } + } } diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index 035fb9035..5fe256aad 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -66,24 +66,29 @@ export class IntegrationService extends IpcService { author: string publishedAt: string vaultPath: string + description?: string }, ) { try { - const { url, title, content, author, publishedAt, vaultPath } = input + const { url, title, content, author, publishedAt, vaultPath, description } = input const fileName = `${sanitizeFileName(title || publishedAt) .trim() - .slice(0, 20)}.md` + .slice(0, 80)}.md` const filePath = path.join(vaultPath, fileName) const exists = existsSync(filePath) if (exists) { return { success: false, error: "File already exists" } } + await fsp.mkdir(path.dirname(filePath), { recursive: true }) + + const yamlEscape = (s: string) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + const markdown = `--- -url: ${url} -author: ${author} -publishedAt: ${publishedAt} +url: ${yamlEscape(url)} +author: ${yamlEscape(author)} +publishedAt: ${yamlEscape(publishedAt)}${description ? `\ndescription: ${yamlEscape(description)}` : ""} --- # ${title} diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index b52c894e1..896a1184a 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -11,6 +11,7 @@ import { import { IN_ELECTRON } from "@follow/shared/constants" import { getEntry } from "@follow/store/entry/getter" import type { EntryModel } from "@follow/store/entry/types" +import { getFeedById } from "@follow/store/feed/getter" import { getSummary } from "@follow/store/summary/getters" import { tracker } from "@follow/tracker" import { useMutation, useQuery } from "@tanstack/react-query" @@ -321,9 +322,10 @@ const useRegisterObsidianCommands = () => { url: entry.url || "", title: entry.title || "", content: markdownContent, - author: entry.author || "", + author: entry.author || getFeedById(entry.feedId)?.title || "", publishedAt: entry.publishedAt.toISOString() || "", vaultPath: obsidianVaultPath, + description: getDescription(entry), }) }, }), diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx index 5b495b563..4fec83da8 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx @@ -13,7 +13,8 @@ import { SimpleIconsZotero, } from "@follow/components/ui/platform-icon/icons.js" import { IN_ELECTRON } from "@follow/shared/constants" -import { useCallback, useEffect, useMemo, useState } from "react" +import type { FC } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -22,12 +23,13 @@ import { setIntegrationSetting, useIntegrationSettingValue, } from "~/atoms/settings/integration" +import { ipcServices } from "~/lib/client" import { downloadJsonFile, selectJsonFile } from "~/lib/export" import { getFetchAdapter } from "~/modules/integration/fetch-adapter" import { createSetting } from "../../helper/builder" import { useSetSettingCanSync } from "../../modal/hooks" -import { SettingSectionTitle } from "../../section" +import { SettingItemGroup, SettingSectionTitle } from "../../section" import { CustomIntegrationSection } from "./CustomIntegrationSection" const { defineSettingItem, SettingBuilder } = createSetting( @@ -35,6 +37,61 @@ const { defineSettingItem, SettingBuilder } = createSetting( useIntegrationSettingValue, setIntegrationSetting, ) +const ObsidianVaultPathPicker: FC = () => { + const vaultPath = useIntegrationSettingValue().obsidianVaultPath + const { t } = useTranslation("settings") + const [pathValid, setPathValid] = useState(null) + + useEffect(() => { + if (!vaultPath) { + setPathValid(null) + return + } + ipcServices?.app.checkPathExists(vaultPath).then((exists) => { + setPathValid(exists) + }) + }, [vaultPath]) + + const handleBrowse = async () => { + const selected = await ipcServices?.app.selectDirectory() + if (selected) { + setIntegrationSetting("obsidianVaultPath", selected) + } + } + + const buttonText = !vaultPath + ? t("integration.obsidian.vaultPath.select") + : pathValid === false + ? t("integration.obsidian.vaultPath.reselect") + : t("integration.obsidian.vaultPath.change") + + return ( + +
+ +
+ + {vaultPath && ( + + {vaultPath} + {pathValid === false && ( + + + {t("integration.obsidian.vaultPath.invalid")} + + )} + + )} +
+
+
+ ) +} + export const SettingIntegration = () => { const { t } = useTranslation("settings") const setSync = useSetSettingCanSync() @@ -100,11 +157,7 @@ export const SettingIntegration = () => { label: t("integration.obsidian.enable.label"), description: t("integration.obsidian.enable.description"), }), - defineSettingItem("obsidianVaultPath", { - label: t("integration.obsidian.vaultPath.label"), - vertical: true, - description: t("integration.obsidian.vaultPath.description"), - }), + ObsidianVaultPathPicker, ], }, { diff --git a/locales/settings/en.json b/locales/settings/en.json index 600f8be5f..00a806c98 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -472,8 +472,12 @@ "integration.obsidian.enable.description": "Display 'Save to Obsidian' button when available.", "integration.obsidian.enable.label": "Enable", "integration.obsidian.title": "Obsidian", + "integration.obsidian.vaultPath.change": "Change", "integration.obsidian.vaultPath.description": "The path to your Obsidian vault.", + "integration.obsidian.vaultPath.invalid": "Path not available", "integration.obsidian.vaultPath.label": "Obsidian Vault Path", + "integration.obsidian.vaultPath.reselect": "Reselect", + "integration.obsidian.vaultPath.select": "Select Folder", "integration.outline.collection.description": "The UUID or urlId of the collection where the documents is saved.", "integration.outline.collection.label": "Outline Collection", "integration.outline.enable.description": "Display 'Save to Outline' button when available.", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 4072df4fe..56834dca8 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -472,8 +472,12 @@ "integration.obsidian.enable.description": "显示「保存到 Obsidian」按钮(如果可用)。", "integration.obsidian.enable.label": "启用", "integration.obsidian.title": "Obsidian", + "integration.obsidian.vaultPath.change": "更改", "integration.obsidian.vaultPath.description": "你的 Obsidian 仓库的路径。", + "integration.obsidian.vaultPath.invalid": "路径不可用", "integration.obsidian.vaultPath.label": "Obsidian 仓库路径", + "integration.obsidian.vaultPath.reselect": "重新选择", + "integration.obsidian.vaultPath.select": "选择文件夹", "integration.outline.collection.description": "保存文档的文档集的 UUID 或 urlId。", "integration.outline.collection.label": "Outline 文档集", "integration.outline.enable.description": "显示「保存到 Outline」按钮(如果可用)。", From 8d2a5a98ae680fbf9558144b4c18e8ef796ec1a4 Mon Sep 17 00:00:00 2001 From: Luke D Williams Date: Thu, 2 Apr 2026 08:00:25 -0500 Subject: [PATCH 13/15] fix(mobile): preserve pitch for Android video playback (#4940) --- apps/mobile/src/components/ui/video/VideoPlayer.tsx | 1 + .../src/modules/entry-list/templates/EntryNormalItem.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ui/video/VideoPlayer.tsx b/apps/mobile/src/components/ui/video/VideoPlayer.tsx index 7da7f2f6c..c12fd53e5 100644 --- a/apps/mobile/src/components/ui/video/VideoPlayer.tsx +++ b/apps/mobile/src/components/ui/video/VideoPlayer.tsx @@ -28,6 +28,7 @@ export function VideoPlayer({ const player = useVideoPlayer(source, (player) => { player.loop = true player.muted = true + player.preservesPitch = true // player.play() }) const { status } = useEvent(player, "statusChange", { status: player.status }) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index 64be8b994..0431b2c9d 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -187,7 +187,9 @@ const ThumbnailImage = ({ entryId }: { entryId: string }) => { const audioState = useAudioPlayState(audio?.url) const video = mediaModel?.type === "video" ? mediaModel : null const videoViewRef = useRef(null) - const videoPlayer = useVideoPlayer(video?.url ?? "") + const videoPlayer = useVideoPlayer(video?.url ?? "", (player) => { + player.preservesPitch = true + }) const [showVideoNativeControlsForAndroid, setShowVideoNativeControlsForAndroid] = useState(false) const handlePressPlay = useCallback(() => { if (video) { From ad8813b7f964bfe9fa3a27bbd7862b5d20b966d9 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Apr 2026 12:42:05 +0800 Subject: [PATCH 14/15] docs(desktop): update 1.5.0 changelog --- apps/desktop/changelog/1.5.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/changelog/1.5.0.md b/apps/desktop/changelog/1.5.0.md index 7bd807d91..3a7a888f0 100644 --- a/apps/desktop/changelog/1.5.0.md +++ b/apps/desktop/changelog/1.5.0.md @@ -14,6 +14,8 @@ - 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 From 421d50fca878e9f3c7b12b3be2deb8c69e291fc3 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Apr 2026 13:15:59 +0800 Subject: [PATCH 15/15] fix(desktop): restore integration typecheck --- .../layer/renderer/src/modules/command/commands/integration.tsx | 1 + .../renderer/src/modules/settings/tabs/integration/index.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 896a1184a..500e57aca 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -283,6 +283,7 @@ const useRegisterObsidianCommands = () => { author: string publishedAt: string vaultPath: string + description?: string }) => { return await ipcServices?.integration.saveToObsidian(data) }, diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx index 4fec83da8..9433743e5 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx @@ -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"