mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: add CPX plugin deep links
This commit is contained in:
94
src/main/deeplink.test.ts
Normal file
94
src/main/deeplink.test.ts
Normal 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')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
1
src/main/resolve/plugin/constants.ts
Normal file
1
src/main/resolve/plugin/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MAX_PLUGIN_FILE_BYTES = 1024 * 1024
|
||||
@@ -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()
|
||||
|
||||
69
src/main/resolve/plugin/remote.test.ts
Normal file
69
src/main/resolve/plugin/remote.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
42
src/main/resolve/plugin/remote.ts
Normal file
42
src/main/resolve/plugin/remote.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user