feat(integration): support electron side to send request
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
0089027586
commit
8802303cf2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ export const defaultIntegrationSettings: IntegrationSettings = {
|
|||
// custom actions
|
||||
enableCustomIntegration: false,
|
||||
customIntegration: [],
|
||||
|
||||
// fetch preferences (Electron only)
|
||||
useBrowserFetch: true,
|
||||
}
|
||||
|
||||
export const defaultAISettings: AISettings = {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ export interface IntegrationSettings {
|
|||
// custom actions
|
||||
enableCustomIntegration: boolean
|
||||
customIntegration: CustomIntegration[]
|
||||
|
||||
// fetch preferences (Electron only)
|
||||
useBrowserFetch: boolean
|
||||
}
|
||||
|
||||
export interface FetchTemplate {
|
||||
|
|
|
|||
Loading…
Reference in New Issue