feat: add CPX plugin deep links

This commit is contained in:
ezequielnick
2026-07-10 10:55:26 +08:00
parent becad752f6
commit 00b8b569c1
7 changed files with 277 additions and 11 deletions

94
src/main/deeplink.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findDeepLink, handleDeepLink } from './deeplink'
const notificationShow = vi.fn()
const addProfileItem = vi.fn()
const installRemotePlugin = vi.fn()
const loginPlugin = vi.fn()
const safeShowErrorBox = vi.fn()
vi.mock('electron', () => ({
Notification: class {
show(): void {
notificationShow()
}
}
}))
vi.mock('i18next', () => ({ default: { t: (key: string) => key } }))
vi.mock('./config', () => ({
addProfileItem: (...args: unknown[]) => addProfileItem(...args)
}))
vi.mock('./resolve/plugin', () => ({
installRemotePlugin: (...args: unknown[]) => installRemotePlugin(...args),
loginPlugin: (...args: unknown[]) => loginPlugin(...args)
}))
vi.mock('./window', () => ({ mainWindow: null }))
vi.mock('./utils/init', () => ({
safeShowErrorBox: (...args: unknown[]) => safeShowErrorBox(...args)
}))
beforeEach(() => {
notificationShow.mockReset()
addProfileItem.mockReset().mockResolvedValue(undefined)
installRemotePlugin.mockReset().mockResolvedValue({ id: 'plugin-id' })
loginPlugin.mockReset().mockResolvedValue(undefined)
safeShowErrorBox.mockReset()
})
describe('findDeepLink', () => {
it('finds a supported scheme anywhere in the command line', () => {
expect(findDeepLink(['app', '--flag', 'clash://install-config?url=x'])).toBe(
'clash://install-config?url=x'
)
expect(findDeepLink(['app', 'MIHOMO://install-plugin?url=x'])).toBe(
'MIHOMO://install-plugin?url=x'
)
})
it('ignores unrelated arguments', () => {
expect(findDeepLink(['app', '--flag'])).toBeUndefined()
})
})
describe('install-plugin deep link', () => {
it('downloads, installs and starts login', async () => {
const remoteUrl = 'https://provider.example/app.cpx?channel=stable'
await handleDeepLink(`clash://install-plugin?url=${encodeURIComponent(remoteUrl)}`)
expect(installRemotePlugin).toHaveBeenCalledWith(remoteUrl)
expect(loginPlugin).toHaveBeenCalledWith('plugin-id')
expect(notificationShow).toHaveBeenCalledTimes(2)
expect(safeShowErrorBox).not.toHaveBeenCalled()
})
it('reports an install failure and does not start login', async () => {
installRemotePlugin.mockRejectedValue(new Error('bad descriptor'))
await handleDeepLink('mihomo://install-plugin?url=https%3A%2F%2Fprovider.example%2Fapp.cpx')
expect(loginPlugin).not.toHaveBeenCalled()
expect(safeShowErrorBox).toHaveBeenCalledWith(
'plugins.installFailed',
expect.stringContaining('bad descriptor')
)
})
it('keeps the installed plugin when login fails and reports the login error', async () => {
loginPlugin.mockRejectedValue(new Error('PLUGIN_LOGIN_FAILED'))
await handleDeepLink('clash://install-plugin?url=https%3A%2F%2Fprovider.example%2Fapp.cpx')
expect(installRemotePlugin).toHaveBeenCalledOnce()
expect(notificationShow).toHaveBeenCalledOnce()
expect(safeShowErrorBox).toHaveBeenCalledWith(
'plugins.loginFailed',
expect.stringContaining('PLUGIN_LOGIN_FAILED')
)
})
it('requires the url parameter', async () => {
await handleDeepLink('clash://install-plugin')
expect(installRemotePlugin).not.toHaveBeenCalled()
expect(safeShowErrorBox).toHaveBeenCalledWith(
'plugins.installFailed',
expect.stringContaining('profiles.error.urlParamMissing')
)
})
})

View File

@@ -1,11 +1,19 @@
import { Notification } from 'electron'
import i18next from 'i18next'
import { addProfileItem } from './config'
import { installRemotePlugin, loginPlugin } from './resolve/plugin'
import { mainWindow } from './window'
import { safeShowErrorBox } from './utils/init'
export function findDeepLink(args: string[]): string | undefined {
return args.find((arg) => {
const lower = arg.toLowerCase()
return lower.startsWith('clash://') || lower.startsWith('mihomo://')
})
}
export async function handleDeepLink(url: string): Promise<void> {
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
if (!findDeepLink([url])) return
const urlObj = new URL(url)
switch (urlObj.host) {
@@ -28,5 +36,27 @@ export async function handleDeepLink(url: string): Promise<void> {
}
break
}
case 'install-plugin': {
let plugin: IPluginItem
try {
const pluginUrl = urlObj.searchParams.get('url')
if (!pluginUrl) {
throw new Error(i18next.t('profiles.error.urlParamMissing'))
}
plugin = await installRemotePlugin(pluginUrl)
new Notification({ title: i18next.t('plugins.installed') }).show()
} catch (e) {
safeShowErrorBox('plugins.installFailed', `${e}`)
break
}
try {
await loginPlugin(plugin.id)
new Notification({ title: i18next.t('plugins.loginSuccess') }).show()
} catch (e) {
safeShowErrorBox('plugins.loginFailed', `${e}`)
}
break
}
}
}

View File

@@ -29,7 +29,7 @@ import {
triggerMainWindow,
closeMainWindow
} from './window'
import { handleDeepLink } from './deeplink'
import { findDeepLink, handleDeepLink } from './deeplink'
import {
fixUserDataPermissions,
setupPlatformSpecifics,
@@ -119,17 +119,30 @@ async function initHardwareAcceleration(): Promise<void> {
initHardwareAcceleration()
setupAppLifecycle()
app.on('second-instance', async (_event, commandline) => {
showMainWindow()
const url = commandline.pop()
if (url) {
await handleDeepLink(url)
let deepLinksReady = false
let pendingDeepLinks: string[] = []
let deepLinkChain = Promise.resolve()
function dispatchDeepLink(url: string): void {
if (!deepLinksReady) {
if (!pendingDeepLinks.includes(url)) pendingDeepLinks.push(url)
return
}
deepLinkChain = deepLinkChain
.then(async () => {
showMainWindow()
await handleDeepLink(url)
})
.catch((e) => safeShowErrorBox('common.error.default', `${e}`))
}
app.on('second-instance', (_event, commandline) => {
const url = findDeepLink(commandline)
if (url) dispatchDeepLink(url)
})
app.on('open-url', async (_event, url) => {
showMainWindow()
await handleDeepLink(url)
app.on('open-url', (_event, url) => {
dispatchDeepLink(url)
})
const initPromise = (async () => {
@@ -249,6 +262,18 @@ app.whenReady().then(async () => {
await createWindowPromise
// macOS delivers cold-start links through open-url; Windows/Linux put them in argv.
if (process.platform !== 'darwin') {
const initialDeepLink = findDeepLink(process.argv)
if (initialDeepLink && !pendingDeepLinks.includes(initialDeepLink)) {
pendingDeepLinks.unshift(initialDeepLink)
}
}
deepLinksReady = true
const queuedDeepLinks = pendingDeepLinks
pendingDeepLinks = []
queuedDeepLinks.forEach(dispatchDeepLink)
void startSubStoreServices().catch((e) =>
mainLogger.warn('Failed to start sub-store services', e)
)

View File

@@ -0,0 +1 @@
export const MAX_PLUGIN_FILE_BYTES = 1024 * 1024

View File

@@ -15,9 +15,10 @@ import { generateDevice } from './device'
import { enroll, fetchConfig, revoke, GatewayError, type GatewayTarget } from './gateway'
import { writeVault, readVault, removeVault } from './vault'
import { computeBackoff } from './backoff'
import { MAX_PLUGIN_FILE_BYTES } from './constants'
import { fetchRemotePlugin } from './remote'
const DEFAULT_PLUGIN_INTERVAL_MIN = 1440 // 24h
const MAX_PLUGIN_FILE_BYTES = 1024 * 1024
function notifyRenderer(): void {
mainWindow?.webContents.send('pluginConfigUpdated')
@@ -79,6 +80,10 @@ export async function installPlugin(fileBytesB64: string): Promise<IPluginItem>
return record
}
export async function installRemotePlugin(url: string): Promise<IPluginItem> {
return installPlugin(await fetchRemotePlugin(url))
}
// 写订阅 profile + 回填 profileId + 置 active + 清失败状态(首次登录与复用设备登录共用)
async function finishLogin(id: string, record: IPluginItem, content: string): Promise<void> {
const profileId = record.profileId ?? randomUUID()

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_PLUGIN_FILE_BYTES } from './constants'
import { fetchRemotePlugin } from './remote'
const getAppConfig = vi.fn()
const getControledMihomoConfig = vi.fn()
const requestOnce = vi.fn()
vi.mock('../../config/app', () => ({
getAppConfig: (...args: unknown[]) => getAppConfig(...args)
}))
vi.mock('../../config/controledMihomo', () => ({
getControledMihomoConfig: (...args: unknown[]) => getControledMihomoConfig(...args)
}))
vi.mock('./http-client', () => ({
requestOnce: (...args: unknown[]) => requestOnce(...args)
}))
beforeEach(() => {
getAppConfig.mockReset().mockResolvedValue({ subscriptionTimeout: 1234 })
getControledMihomoConfig.mockReset().mockResolvedValue({ 'mixed-port': 17890 })
requestOnce.mockReset().mockResolvedValue({ status: 200, headers: {}, body: '{"magic":"CPXF"}' })
})
describe('fetchRemotePlugin', () => {
it('downloads an https descriptor with the guarded plugin client', async () => {
const result = await fetchRemotePlugin('https://provider.example/app.cpx?channel=stable')
expect(Buffer.from(result, 'base64').toString('utf-8')).toBe('{"magic":"CPXF"}')
expect(requestOnce).toHaveBeenCalledWith(
'https://provider.example/app.cpx?channel=stable',
expect.objectContaining({
method: 'GET',
timeout: 1234,
maxBytes: MAX_PLUGIN_FILE_BYTES,
lookup: expect.any(Function),
proxy: undefined
})
)
})
it('uses the configured local proxy when enabled', async () => {
getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true })
await fetchRemotePlugin('https://provider.example/app.cpx')
expect(requestOnce.mock.calls[0][1].proxy).toEqual({ host: '127.0.0.1', port: 17890 })
})
it.each([
'http://provider.example/app.cpx',
'https://user:password@provider.example/app.cpx',
'https://localhost/app.cpx',
'https://127.0.0.1/app.cpx',
'https://provider.example/app.cpx#fragment'
])('rejects an unsafe download URL: %s', async (url) => {
await expect(fetchRemotePlugin(url)).rejects.toThrow()
expect(requestOnce).not.toHaveBeenCalled()
})
it('rejects non-success responses', async () => {
requestOnce.mockResolvedValue({ status: 404, headers: {}, body: 'not found' })
await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toThrow(/404/)
})
it('propagates the network size guard', async () => {
requestOnce.mockRejectedValue(new Error('Response too large'))
await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toThrow(
/too large/i
)
})
})

View File

@@ -0,0 +1,42 @@
import { getAppConfig } from '../../config/app'
import { MAX_PLUGIN_FILE_BYTES } from './constants'
import { requestOnce } from './http-client'
import { createGuardedLookup, isForbiddenHost } from './net-guard'
function parseDownloadUrl(url: string): URL {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new Error('Invalid plugin URL')
}
if (parsed.protocol !== 'https:') throw new Error('Plugin URL must use https')
if (parsed.username || parsed.password) throw new Error('Plugin URL must not contain userinfo')
if (parsed.hash) throw new Error('Plugin URL must not contain a fragment')
if (isForbiddenHost(parsed.hostname)) throw new Error('Plugin URL must use a public host')
return parsed
}
export async function fetchRemotePlugin(url: string): Promise<string> {
const parsed = parseDownloadUrl(url)
const { subscriptionTimeout = 30000, pluginUseProxy } = await getAppConfig()
let proxy: { host: string; port: number } | undefined
if (pluginUseProxy) {
const { getControledMihomoConfig } = await import('../../config/controledMihomo')
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
proxy = { host: '127.0.0.1', port }
}
const response = await requestOnce(parsed.toString(), {
method: 'GET',
headers: { Accept: 'application/json, application/octet-stream' },
timeout: subscriptionTimeout,
maxBytes: MAX_PLUGIN_FILE_BYTES,
lookup: createGuardedLookup(),
proxy
})
if (response.status < 200 || response.status >= 300) {
throw new Error(`Plugin download failed (status ${response.status})`)
}
return Buffer.from(response.body, 'utf-8').toString('base64')
}