diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 2f6b4e0f1..337db21c8 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,6 +2,8 @@ ## Shiny new things +- Add custom integration configurations to adapt to more apps + ## Improvements - Redesign integration settings page for better user experience and support integration settings export and import. diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index e969aad10..7551d3260 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -4,6 +4,7 @@ import fsp from "node:fs/promises" import path from "pathe" import { store } from "~/lib/store" +import { logger } from "~/logger" import type { IpcContext } from "../base" import { IpcMethod, IpcService } from "../base" @@ -44,6 +45,14 @@ interface AddMagnetInput { urls: string[] } +interface CustomFetchInput { + url: string + method: string + headers: Record + body?: string + timeout?: number +} + export class IntegrationService extends IpcService { constructor() { super("integration") @@ -195,4 +204,147 @@ ${content} // eslint-disable-next-line no-console console.log(`Added magnet links to qBittorrent: ${urls.join(", ")}`) } + + @IpcMethod() + async customFetch(context: IpcContext, input: CustomFetchInput) { + const requestId = Math.random().toString(36).slice(2, 8) + const { url, method, headers, body, timeout = 10_000 } = input + + // Log request start + logger.info(`[CustomFetch:${requestId}] Starting request`, { + url: url.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) => + // Mask potential sensitive query parameters + key.toLowerCase().includes("token") || + key.toLowerCase().includes("key") || + key.toLowerCase().includes("password") + ? `${prefix}${key}=***` + : `${prefix}${key}=${value}`, + ), + method, + timeout, + hasBody: !!body, + bodyLength: body?.length || 0, + headerCount: Object.keys(headers || {}).length, + }) + + // Log request headers (mask sensitive headers) + const safeHeaders = { ...headers } + Object.keys(safeHeaders).forEach((key) => { + if ( + key.toLowerCase().includes("authorization") || + key.toLowerCase().includes("token") || + key.toLowerCase().includes("key") + ) { + safeHeaders[key] = "***" + } + }) + logger.debug(`[CustomFetch:${requestId}] Request headers`, { headers: safeHeaders }) + + // Log request body (truncated for large bodies) + if (body) { + const truncatedBody = + body.length > 500 + ? `${body.slice(0, 500)}... [truncated, total: ${body.length} chars]` + : body + logger.debug(`[CustomFetch:${requestId}] Request body`, { body: truncatedBody }) + } + + const startTime = Date.now() + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => { + logger.warn(`[CustomFetch:${requestId}] Request timeout triggered after ${timeout}ms`) + controller.abort() + }, timeout) + + logger.debug(`[CustomFetch:${requestId}] Sending request...`) + + const response = await fetch(url, { + method, + headers, + body: body && ["POST", "PUT", "PATCH"].includes(method.toUpperCase()) ? body : undefined, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + const duration = Date.now() - startTime + + // Log response info + logger.info(`[CustomFetch:${requestId}] Request completed`, { + status: response.status, + statusText: response.statusText, + ok: response.ok, + duration: `${duration}ms`, + }) + + // Convert response headers to plain object + const responseHeaders: Record = {} + response.headers.forEach((value, key) => { + responseHeaders[key] = value + }) + + logger.debug(`[CustomFetch:${requestId}] Response headers`, { + headers: responseHeaders, + contentType: responseHeaders["content-type"], + contentLength: responseHeaders["content-length"], + }) + + // Get response text + const text = await response.text() + const responseSize = text.length + + logger.debug(`[CustomFetch:${requestId}] Response body received`, { + size: `${responseSize} chars`, + preview: text.length > 200 ? `${text.slice(0, 200)}...` : text, + }) + + // Try to parse as JSON, fallback to text + let data: any + try { + data = JSON.parse(text) + logger.debug(`[CustomFetch:${requestId}] Response successfully parsed as JSON`) + } catch { + data = text + logger.debug(`[CustomFetch:${requestId}] Response kept as text (not valid JSON)`) + } + + const result = { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data, + text, + } + + logger.info(`[CustomFetch:${requestId}] Request successful`, { + finalStatus: result.ok ? "success" : "http_error", + responseSize: `${responseSize} chars`, + totalDuration: `${Date.now() - startTime}ms`, + }) + + return result + } catch (error) { + const duration = Date.now() - startTime + + if (error instanceof Error && error.name === "AbortError") { + logger.error(`[CustomFetch:${requestId}] Request timeout`, { + duration: `${duration}ms`, + timeout: `${timeout}ms`, + url: url.split("?")[0], // Remove query params for privacy + }) + throw new Error(`Request timeout after ${timeout}ms`) + } + + logger.error(`[CustomFetch:${requestId}] Request failed`, { + error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : "Unknown", + duration: `${duration}ms`, + url: url.split("?")[0], // Remove query params for privacy + }) + + throw error + } + } } diff --git a/apps/desktop/layer/renderer/src/atoms/settings/integration.ts b/apps/desktop/layer/renderer/src/atoms/settings/integration.ts index 0e590b37b..6998c4d17 100644 --- a/apps/desktop/layer/renderer/src/atoms/settings/integration.ts +++ b/apps/desktop/layer/renderer/src/atoms/settings/integration.ts @@ -1,9 +1,17 @@ import { createSettingAtom } from "@follow/atoms/helper/setting.js" +import { IN_ELECTRON } from "@follow/shared/constants" import { defaultIntegrationSettings } from "@follow/shared/settings/defaults" import type { IntegrationSettings } from "@follow/shared/settings/interface" export const createDefaultSettings = (): IntegrationSettings => { - const defaultSettings = defaultIntegrationSettings + const defaultSettings = { ...defaultIntegrationSettings } + + // Only include useBrowserFetch setting in Electron environment + if (!IN_ELECTRON) { + // Remove useBrowserFetch setting for non-Electron environments + const { useBrowserFetch, ...settingsWithoutBrowserFetch } = defaultSettings + return settingsWithoutBrowserFetch as IntegrationSettings + } // Check if we have stored settings that might need migration const storedSettings = (() => { diff --git a/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts index 767634024..58e35e6e1 100644 --- a/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts +++ b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts @@ -2,14 +2,14 @@ import type { CustomIntegration, FetchTemplate } from "@follow/shared/settings/i import type { EntryModel } from "@follow/store/entry/types" import { getSummary } from "@follow/store/summary/getters" import { tracker } from "@follow/tracker" -import type { FetchError } from "ofetch" -import { ofetch } from "ofetch" import { toast } from "sonner" import { getActionLanguage } from "~/atoms/settings/general" import { getIntegrationSettings } from "~/atoms/settings/integration" import { parseHtml } from "~/lib/parse-html" +import { getFetchAdapter } from "./fetch-adapter" + /** * Placeholder values that can be used in custom integration templates */ @@ -214,31 +214,33 @@ export class CustomIntegrationManager { context, ) - // Prepare request options - const requestOptions: Parameters[1] = { - method: method as any, - headers, + // Prepare request options for fetch adapter + const finalHeaders = { ...headers } + + // Set content-type if not already set and we have a body + if ( + body && + ["POST", "PUT", "PATCH"].includes(method) && + !Object.keys(headers).some((key) => key.toLowerCase() === "content-type") + ) { + finalHeaders["Content-Type"] = "application/json" } - // Add body for methods that support it - if (body && ["POST", "PUT", "PATCH"].includes(method)) { - requestOptions.body = body + // Execute the HTTP request using fetch adapter + const response = await getFetchAdapter().fetch(url, { + method: method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + headers: finalHeaders, + body: body && ["POST", "PUT", "PATCH"].includes(method) ? body : undefined, + }) - // Set content-type if not already set - if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) { - requestOptions.headers = { - ...headers, - "Content-Type": "application/json", - } - } + // Check if request was successful + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}: ${response.statusText}`) } - // Execute the HTTP request - await ofetch(url, requestOptions) - return { success: true } } catch (error) { - const errorMessage = (error as FetchError)?.message || "Unknown error" + const errorMessage = (error as Error)?.message || "Unknown error" return { success: false, error: errorMessage } } } diff --git a/apps/desktop/layer/renderer/src/modules/integration/fetch-adapter.ts b/apps/desktop/layer/renderer/src/modules/integration/fetch-adapter.ts new file mode 100644 index 000000000..01ebd23f5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/integration/fetch-adapter.ts @@ -0,0 +1,179 @@ +import { IN_ELECTRON } from "@follow/shared/constants" +import type { FetchError } from "ofetch" +import { ofetch } from "ofetch" + +import { getIntegrationSettings } from "~/atoms/settings/integration" +import { ipcServices } from "~/lib/client" + +/** + * HTTP request options for fetch adapters + */ +export interface FetchRequestOptions { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + headers?: Record + body?: string + timeout?: number +} + +/** + * HTTP response from fetch adapters + */ +export interface FetchResponse { + ok: boolean + status: number + statusText: string + headers: Record + data?: any + text?: string +} + +/** + * Abstract base class for HTTP fetch adapters + */ +export abstract class BaseFetchAdapter { + abstract fetch(url: string, options?: FetchRequestOptions): Promise +} + +/** + * Browser fetch adapter using native fetch or ofetch + */ +export class BrowserFetchAdapter extends BaseFetchAdapter { + async fetch(url: string, options?: FetchRequestOptions): Promise { + const finalOptions = options || { method: "GET" } + try { + const requestOptions: Parameters[1] = { + method: finalOptions.method, + headers: finalOptions.headers || {}, + timeout: finalOptions.timeout || 30000, + } + + // Add body for methods that support it + if (finalOptions.body && ["POST", "PUT", "PATCH"].includes(finalOptions.method)) { + requestOptions.body = finalOptions.body + } + + const response = await ofetch.raw(url, requestOptions) + + // Convert Headers object to plain object + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers, + data: response._data, + text: typeof response._data === "string" ? response._data : JSON.stringify(response._data), + } + } catch (error) { + const fetchError = error as FetchError + throw new Error(`Browser fetch failed: ${fetchError.message || "Unknown error"}`) + } + } +} + +/** + * Electron fetch adapter using IPC services + */ +export class ElectronFetchAdapter extends BaseFetchAdapter { + async fetch(url: string, options?: FetchRequestOptions): Promise { + const finalOptions = options || { method: "GET" } + try { + // Check if IPC services are available + if (!ipcServices?.integration?.customFetch) { + throw new Error("Electron IPC services not available") + } + + const response = await ipcServices.integration.customFetch({ + url, + method: finalOptions.method, + headers: finalOptions.headers || {}, + body: finalOptions.body, + timeout: finalOptions.timeout || 30000, + }) + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText || "OK", + headers: response.headers || {}, + data: response.data, + text: response.text, + } + } catch (error) { + throw new Error(`Electron fetch failed: ${(error as Error).message || "Unknown error"}`) + } + } +} + +/** + * Fetch adapter factory and configuration + */ +export class FetchAdapterManager { + private static instance: FetchAdapterManager + private adapter: BaseFetchAdapter + private preferElectron: boolean + + private constructor() { + // Initialize preference based on settings + // Default to browser fetch if useBrowserFetch is true, electron otherwise + if (IN_ELECTRON) { + const settings = getIntegrationSettings() + this.preferElectron = !settings.useBrowserFetch + } else { + this.preferElectron = false // Always use browser fetch in non-Electron environment + } + + this.adapter = this.createAdapter() + } + + static getInstance(): FetchAdapterManager { + if (!FetchAdapterManager.instance) { + FetchAdapterManager.instance = new FetchAdapterManager() + } + return FetchAdapterManager.instance + } + + /** + * @description Electron only + */ + preferElectronFetch() { + this.preferElectron = true + this.adapter = this.createAdapter() + } + /** + * @description Electron only + */ + preferClientFetch() { + this.preferElectron = false + this.adapter = this.createAdapter() + } + + /** + * Create appropriate adapter based on environment and preferences + */ + private createAdapter(): BaseFetchAdapter { + // If in Electron environment and Electron is preferred and available + if (IN_ELECTRON && this.preferElectron && ipcServices?.integration?.customFetch) { + return new ElectronFetchAdapter() + } + + // Fallback to browser adapter + return new BrowserFetchAdapter() + } + + /** + * Execute HTTP request using the current adapter + */ + async fetch(url: string, options?: FetchRequestOptions): Promise { + return this.adapter.fetch(url, options) + } +} + +/** + * Convenience function to get the fetch adapter manager instance + */ +export const getFetchAdapter = () => FetchAdapterManager.getInstance() diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts index e71df395e..67a8505e2 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/builder.ts @@ -11,10 +11,11 @@ export const createDefineSettingItem = label: string description?: string | JSX.Element onChange?: (value: T[K]) => void + onAfterChange?: (value: T[K]) => void hide?: boolean } & Omit, "onChange" | "description" | "label" | "hide" | "key">, ): any => { - const { label, description, onChange, hide, ...rest } = options + const { label, description, onChange, hide, onAfterChange, ...rest } = options if (hide) return null return { @@ -22,8 +23,12 @@ export const createDefineSettingItem = label, description, onChange: (value: any) => { - if (onChange) return onChange(value as any) - setSetting(key, value as any) + try { + if (onChange) return onChange(value as any) + setSetting(key, value as any) + } finally { + onAfterChange?.(value as any) + } }, disabled: hide, ...rest, diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationSection.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationSection.tsx index c662881ea..3e6864158 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationSection.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationSection.tsx @@ -203,7 +203,7 @@ const CustomIntegrationsSection = ({ {integrations.length === 0 ? (
- +

{t("integration.custom_integrations.list.empty.title")}

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 12e7deaa8..d09acde9c 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 @@ -12,6 +12,7 @@ import { SimpleIconsReadwise, SimpleIconsZotero, } from "@follow/components/ui/platform-icon/icons.js" +import { IN_ELECTRON } from "@follow/shared/constants" import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -22,6 +23,7 @@ import { useIntegrationSettingValue, } from "~/atoms/settings/integration" import { downloadJsonFile, selectJsonFile } from "~/lib/export" +import { getFetchAdapter } from "~/modules/integration/fetch-adapter" import { createSetting } from "../../helper/builder" import { useSetSettingCanSync } from "../../modal/hooks" @@ -376,6 +378,22 @@ export const SettingIntegration = () => { defineSettingItem("saveSummaryAsDescription", { label: t("integration.save_ai_summary_as_description.label"), }), + // Only show browser fetch setting in Electron environment + ...(IN_ELECTRON + ? [ + defineSettingItem("useBrowserFetch", { + label: t("integration.use_browser_fetch.label"), + description: t("integration.use_browser_fetch.description"), + onAfterChange: (value) => { + if (value) { + getFetchAdapter().preferClientFetch() + } else { + getFetchAdapter().preferElectronFetch() + } + }, + }), + ] + : []), ]} />
diff --git a/locales/settings/en.json b/locales/settings/en.json index bc0298cb2..ca9d2f4f5 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -448,6 +448,8 @@ "integration.status.enabled": "Enabled", "integration.tip": "Tip: Your sensitive data is stored locally and is not uploaded to the server.", "integration.title": "Integration", + "integration.use_browser_fetch.description": "Use browser fetch API for custom integrations instead of Electron's native fetch. Enable for better web compatibility, disable for enhanced security.", + "integration.use_browser_fetch.label": "Use Browser Fetch", "integration.zotero.enable.description": "Show 'Save to Zotero' button if avilable.", "integration.zotero.enable.label": "Enable", "integration.zotero.title": "Zotero", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 450b438b7..d742a20bd 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -366,6 +366,8 @@ "integration.sidebar_title": "第三方接入", "integration.tip": "提示:敏感数据仅在本地存储,不会被收集或上传到云端。", "integration.title": "第三方接入", + "integration.use_browser_fetch.description": "为自定义集成使用浏览器 fetch API 而不是 Electron 原生 fetch。启用以获得更好的网络兼容性,禁用以提高安全性。", + "integration.use_browser_fetch.label": "使用浏览器发送请求", "integration.zotero.enable.description": "显示「保存到 Zotero」按钮(如果可用)", "integration.zotero.enable.label": "启用", "integration.zotero.title": "Zotero", diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts index b0976a700..1899ee429 100644 --- a/packages/internal/shared/src/settings/defaults.ts +++ b/packages/internal/shared/src/settings/defaults.ts @@ -148,6 +148,9 @@ export const defaultIntegrationSettings: IntegrationSettings = { // custom actions enableCustomIntegration: false, customIntegration: [], + + // fetch preferences (Electron only) + useBrowserFetch: true, } export const defaultAISettings: AISettings = { diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts index d506c7c8f..5f75e25b4 100644 --- a/packages/internal/shared/src/settings/interface.ts +++ b/packages/internal/shared/src/settings/interface.ts @@ -143,6 +143,9 @@ export interface IntegrationSettings { // custom actions enableCustomIntegration: boolean customIntegration: CustomIntegration[] + + // fetch preferences (Electron only) + useBrowserFetch: boolean } export interface FetchTemplate {