feat(integration): support electron side to send request

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-07-29 00:51:21 +08:00
parent 0089027586
commit 8802303cf2
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 401 additions and 25 deletions

View File

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

View File

@ -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<string, string>
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<string, string> = {}
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
}
}
}

View File

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

View File

@ -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<typeof ofetch>[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 }
}
}

View File

@ -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<string, string>
body?: string
timeout?: number
}
/**
* HTTP response from fetch adapters
*/
export interface FetchResponse {
ok: boolean
status: number
statusText: string
headers: Record<string, string>
data?: any
text?: string
}
/**
* Abstract base class for HTTP fetch adapters
*/
export abstract class BaseFetchAdapter {
abstract fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse>
}
/**
* Browser fetch adapter using native fetch or ofetch
*/
export class BrowserFetchAdapter extends BaseFetchAdapter {
async fetch(url: string, options?: FetchRequestOptions): Promise<FetchResponse> {
const finalOptions = options || { method: "GET" }
try {
const requestOptions: Parameters<typeof ofetch>[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<string, string> = {}
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<FetchResponse> {
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<FetchResponse> {
return this.adapter.fetch(url, options)
}
}
/**
* Convenience function to get the fetch adapter manager instance
*/
export const getFetchAdapter = () => FetchAdapterManager.getInstance()

View File

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

View File

@ -203,7 +203,7 @@ const CustomIntegrationsSection = ({
{integrations.length === 0 ? (
<div className="border-fill-secondary flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
<i className="i-mgc-magic-2-cute-re text-text-tertiary mb-3 text-2xl" />
<i className="i-mgc-webhook-cute-re text-text-tertiary mb-3 text-2xl" />
<p className="text-text-tertiary mb-2 text-sm font-medium">
{t("integration.custom_integrations.list.empty.title")}
</p>

View File

@ -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()
}
},
}),
]
: []),
]}
/>
</div>

View File

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

View File

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

View File

@ -148,6 +148,9 @@ export const defaultIntegrationSettings: IntegrationSettings = {
// custom actions
enableCustomIntegration: false,
customIntegration: [],
// fetch preferences (Electron only)
useBrowserFetch: true,
}
export const defaultAISettings: AISettings = {

View File

@ -143,6 +143,9 @@ export interface IntegrationSettings {
// custom actions
enableCustomIntegration: boolean
customIntegration: CustomIntegration[]
// fetch preferences (Electron only)
useBrowserFetch: boolean
}
export interface FetchTemplate {