Skip to content

Commit 9f1831e

Browse files
authored
Merge pull request #1097 from iOfficeAI/task/newapi-prefill-apikey
feat: add aionui:// deep link protocol for new-api provider pre-fill
2 parents 17f996f + e0ed86b commit 9f1831e

12 files changed

Lines changed: 323 additions & 8 deletions

File tree

electron-builder.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ productName: AionUi
33
executableName: AionUi
44
copyright: Copyright © 2024 AionUi
55

6+
protocols:
7+
- name: AionUi Protocol
8+
schemes:
9+
- aionui
10+
611
asar:
712
smartUnpack: true
813

@@ -167,6 +172,7 @@ linux:
167172
Comment: ${description}
168173
Icon: aionui
169174
Categories: Office;Utility;
175+
MimeType: x-scheme-handler/aionui;
170176

171177
npmRebuild: false
172178
buildDependenciesFromSource: false

src/common/ipcBridge.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ export const document = {
310310
convert: bridge.buildProvider<import('./types/conversion').DocumentConversionResponse, import('./types/conversion').DocumentConversionRequest>('document.convert'),
311311
};
312312

313+
// Deep link protocol handling / 深度链接协议处理
314+
export const deepLink = {
315+
/** Emitted when app is opened via aionui:// protocol URL */
316+
received: bridge.buildEmitter<{
317+
action: string; // e.g. 'add-provider'
318+
params: Record<string, string>; // parsed query params
319+
}>('deep-link.received'),
320+
};
321+
313322
// 窗口控制相关接口 / Window controls API
314323
export const windowControls = {
315324
minimize: bridge.buildProvider<void, void>('window-controls:minimize'),

src/index.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,95 @@ import { applyZoomToWindow } from './process/utils/zoom';
2323
// @ts-expect-error - electron-squirrel-startup doesn't have types
2424
import electronSquirrelStartup from 'electron-squirrel-startup';
2525

26+
// ============ Deep Link Protocol ============
27+
// Register aionui:// protocol scheme for external app integration (e.g., New API token quick-add)
28+
const PROTOCOL_SCHEME = 'aionui';
29+
30+
/**
31+
* Parse an aionui:// URL into action and params.
32+
* Supports two formats:
33+
* 1. aionui://add-provider?baseUrl=xxx&apiKey=xxx
34+
* 2. aionui://provider/add?v=1&data=<base64 JSON> (one-api / new-api style)
35+
*/
36+
const parseDeepLinkUrl = (url: string): { action: string; params: Record<string, string> } | null => {
37+
try {
38+
const parsed = new URL(url);
39+
if (parsed.protocol !== `${PROTOCOL_SCHEME}:`) return null;
40+
41+
// Build action from hostname + pathname, e.g. "provider/add" or "add-provider"
42+
const hostname = parsed.hostname || '';
43+
const pathname = parsed.pathname.replace(/^\/+/, '');
44+
const action = pathname ? `${hostname}/${pathname}` : hostname;
45+
46+
const params: Record<string, string> = {};
47+
parsed.searchParams.forEach((value, key) => {
48+
params[key] = value;
49+
});
50+
51+
// If data param exists, decode base64 JSON and merge into params
52+
if (params.data) {
53+
try {
54+
const json = JSON.parse(Buffer.from(params.data, 'base64').toString('utf-8'));
55+
if (json && typeof json === 'object') {
56+
Object.assign(params, json);
57+
}
58+
} catch {
59+
// Ignore decode errors
60+
}
61+
// Remove raw base64 blob so it isn't forwarded to the renderer
62+
delete params.data;
63+
}
64+
65+
return { action, params };
66+
} catch {
67+
return null;
68+
}
69+
};
70+
71+
/** Pending deep-link URL received before the window was ready */
72+
let pendingDeepLinkUrl: string | null = process.argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`)) || null;
73+
74+
/**
75+
* Send the deep-link payload to the renderer via IPC bridge.
76+
* If the window isn't ready yet, queue it.
77+
*/
78+
const handleDeepLinkUrl = (url: string) => {
79+
const parsed = parseDeepLinkUrl(url);
80+
if (!parsed) return;
81+
82+
if (!mainWindow || mainWindow.isDestroyed()) {
83+
// Window not ready yet – last-write-wins: only the most recent deep link is kept,
84+
// which is intentional since the user can only act on one at a time.
85+
pendingDeepLinkUrl = url;
86+
return;
87+
}
88+
89+
ipcBridge.deepLink.received.emit(parsed);
90+
};
91+
92+
// ============ Single Instance Lock ============
93+
// Acquire lock early so the second instance quits before doing unnecessary work.
94+
// When a second instance starts (e.g. from protocol URL), it sends its data
95+
// to the first instance via second-instance event, then quits.
96+
const deepLinkFromArgv = process.argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`));
97+
const gotTheLock = app.requestSingleInstanceLock({ deepLinkUrl: deepLinkFromArgv });
98+
if (!gotTheLock) {
99+
app.quit();
100+
} else {
101+
app.on('second-instance', (_event, argv, _workingDirectory, additionalData) => {
102+
// Prefer additionalData (reliable on all platforms), fallback to argv scan
103+
const deepLinkUrl = (additionalData as { deepLinkUrl?: string })?.deepLinkUrl || argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`));
104+
if (deepLinkUrl) {
105+
handleDeepLinkUrl(deepLinkUrl);
106+
}
107+
// Focus existing window
108+
if (mainWindow) {
109+
if (mainWindow.isMinimized()) mainWindow.restore();
110+
mainWindow.focus();
111+
}
112+
});
113+
}
114+
26115
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
27116
// 修复 macOS 和 Linux 下 GUI 应用的 PATH 环境变量,使其与命令行一致
28117
if (process.platform === 'darwin' || process.platform === 'linux') {
@@ -197,6 +286,8 @@ const createWindow = (): void => {
197286
mainWindow = new BrowserWindow({
198287
width: windowWidth,
199288
height: windowHeight,
289+
show: false, // Hide until CSS is loaded to prevent FOUC
290+
backgroundColor: '#ffffff',
200291
autoHideMenuBar: true,
201292
// Set icon for Windows/Linux in development mode
202293
...(devIcon && process.platform !== 'darwin' ? { icon: devIcon } : {}),
@@ -213,6 +304,18 @@ const createWindow = (): void => {
213304
},
214305
});
215306

307+
// Show window after page and CSS are fully loaded to prevent FOUC
308+
const showWindow = () => {
309+
if (!mainWindow.isDestroyed() && !mainWindow.isVisible()) {
310+
mainWindow.show();
311+
}
312+
};
313+
mainWindow.webContents.once('did-finish-load', () => {
314+
setTimeout(showWindow, 200);
315+
});
316+
// Fallback: show window after 3s even if did-finish-load doesn't fire
317+
setTimeout(showWindow, 3000);
318+
216319
initMainAdapterWithWindow(mainWindow);
217320
setupApplicationMenu();
218321
void applyZoomToWindow(mainWindow);
@@ -317,6 +420,16 @@ const handleAppReady = async (): Promise<void> => {
317420
await startWebServer(resolvedPort, allowRemote);
318421
} else {
319422
createWindow();
423+
424+
// Flush pending deep-link URL (received before window was ready)
425+
if (pendingDeepLinkUrl) {
426+
const url = pendingDeepLinkUrl;
427+
pendingDeepLinkUrl = null;
428+
// Wait for renderer to be ready before sending
429+
mainWindow.webContents.once('did-finish-load', () => {
430+
handleDeepLinkUrl(url);
431+
});
432+
}
320433
}
321434

322435
// 启动时初始化ACP检测器 (skip in --resetpass mode)
@@ -339,6 +452,27 @@ const handleAppReady = async (): Promise<void> => {
339452
});
340453
};
341454

455+
// ============ Protocol Registration ============
456+
// Register aionui:// as the default protocol client
457+
if (process.defaultApp) {
458+
// Dev mode: need to pass execPath explicitly
459+
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, [path.resolve(process.argv[1])]);
460+
} else {
461+
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME);
462+
}
463+
464+
// macOS: handle aionui:// URLs via the open-url event
465+
app.on('open-url', (event, url) => {
466+
event.preventDefault();
467+
handleDeepLinkUrl(url);
468+
// Focus existing window so user sees the result
469+
if (mainWindow && !mainWindow.isDestroyed()) {
470+
if (mainWindow.isMinimized()) mainWindow.restore();
471+
mainWindow.show();
472+
mainWindow.focus();
473+
}
474+
});
475+
342476
// Ensure we don't miss the ready event when running in CLI/WebUI mode
343477
void app
344478
.whenReady()

src/renderer/components/SettingsModal/contents/ModelModalContent.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ipcBridge } from '@/common';
88
import type { IProvider } from '@/common/storage';
99
import { Button, Divider, Message, Popconfirm, Collapse, Tag } from '@arco-design/web-react';
1010
import { DeleteFour, Info, Minus, Plus, Write } from '@icon-park/react';
11-
import React, { useState } from 'react';
11+
import React, { useEffect, useState } from 'react';
1212
import { useTranslation } from 'react-i18next';
1313
import useSWR from 'swr';
1414
import AddModelModal from '@/renderer/pages/settings/components/AddModelModal';
@@ -17,6 +17,7 @@ import { isNewApiPlatform, NEW_API_PROTOCOL_OPTIONS } from '@/renderer/config/mo
1717
import EditModeModal from '@/renderer/pages/settings/components/EditModeModal';
1818
import AionScrollArea from '@/renderer/components/base/AionScrollArea';
1919
import { useSettingsViewMode } from '../settingsViewContext';
20+
import { consumePendingDeepLink } from '@/renderer/hooks/useDeepLink';
2021

2122
/**
2223
* 获取协议显示标签颜色
@@ -111,6 +112,14 @@ const ModelModalContent: React.FC = () => {
111112
},
112113
});
113114

115+
// Consume pending deep-link data on mount (set by useDeepLink hook before navigation)
116+
useEffect(() => {
117+
const pending = consumePendingDeepLink();
118+
if (pending) {
119+
addPlatformModalCtrl.open({ deepLinkData: pending });
120+
}
121+
}, [addPlatformModalCtrl]);
122+
114123
const [addModelModalCtrl, addModelModalContext] = AddModelModal.useModal({
115124
onSubmit(platform) {
116125
updatePlatform(platform, () => {

src/renderer/config/modelPlatforms.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ export const NEW_API_PROTOCOL_OPTIONS = [
118118
{ label: 'Anthropic', value: 'anthropic' },
119119
];
120120

121+
/**
122+
* 根据模型名称自动推断 New API 协议类型
123+
* Auto-detect New API protocol type based on model name
124+
*/
125+
export const detectNewApiProtocol = (modelName: string): string => {
126+
const name = modelName.toLowerCase();
127+
if (name.startsWith('claude') || name.startsWith('anthropic')) return 'anthropic';
128+
if (name.startsWith('gemini') || name.startsWith('models/gemini')) return 'gemini';
129+
// Default to openai (covers gpt, deepseek, qwen, o1, o3, etc.)
130+
return 'openai';
131+
};
132+
121133
// ============ 工具函数 / Utility Functions ============
122134

123135
/**

src/renderer/hooks/useColorScheme.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from 'react';
1212
export type ColorScheme = 'default';
1313

1414
const DEFAULT_COLOR_SCHEME: ColorScheme = 'default';
15+
const COLOR_SCHEME_CACHE_KEY = '__aionui_colorScheme';
1516

1617
/**
1718
* Initialize color scheme immediately when module loads
@@ -22,6 +23,11 @@ const initColorScheme = async () => {
2223
const scheme = (await ConfigStorage.get('colorScheme')) as ColorScheme;
2324
const initialScheme = scheme || DEFAULT_COLOR_SCHEME;
2425
document.documentElement.setAttribute('data-color-scheme', initialScheme);
26+
try {
27+
localStorage.setItem(COLOR_SCHEME_CACHE_KEY, initialScheme);
28+
} catch (_e) {
29+
/* noop */
30+
}
2531
return initialScheme;
2632
} catch (error) {
2733
console.error('Failed to load initial color scheme:', error);
@@ -49,6 +55,11 @@ const useColorScheme = (): [ColorScheme, (scheme: ColorScheme) => Promise<void>]
4955
*/
5056
const applyColorScheme = useCallback((newScheme: ColorScheme) => {
5157
document.documentElement.setAttribute('data-color-scheme', newScheme);
58+
try {
59+
localStorage.setItem(COLOR_SCHEME_CACHE_KEY, newScheme);
60+
} catch (_e) {
61+
/* noop */
62+
}
5263
}, []);
5364

5465
/**

src/renderer/hooks/useDeepLink.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @license
3+
* Copyright 2025 AionUi (aionui.com)
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useCallback, useEffect } from 'react';
8+
import { useNavigate } from 'react-router-dom';
9+
import { ipcBridge } from '@/common';
10+
11+
/**
12+
* Deep link event payload from main process
13+
*/
14+
export type DeepLinkPayload = {
15+
action: string;
16+
params: Record<string, string>;
17+
};
18+
19+
export type DeepLinkAddProviderDetail = {
20+
baseUrl?: string;
21+
apiKey?: string;
22+
name?: string;
23+
platform?: string;
24+
};
25+
26+
/** Pending deep link data for the add-provider action. Read-once: consumed by ModelModalContent on mount. */
27+
let pendingDeepLinkData: DeepLinkAddProviderDetail | null = null;
28+
29+
/**
30+
* Consume (read and clear) pending deep link data.
31+
* Returns the data if present, or null. Subsequent calls return null until new data arrives.
32+
*/
33+
export const consumePendingDeepLink = (): DeepLinkAddProviderDetail | null => {
34+
const data = pendingDeepLinkData;
35+
pendingDeepLinkData = null;
36+
return data;
37+
};
38+
39+
/**
40+
* Hook to listen for aionui:// deep link events from main process.
41+
* Routes 'add-provider' action to the model settings page.
42+
* The pre-fill data is stored in a module-level variable and consumed
43+
* by ModelModalContent on mount via consumePendingDeepLink().
44+
*/
45+
export const useDeepLink = () => {
46+
const navigate = useNavigate();
47+
48+
const handler = useCallback(
49+
(payload: DeepLinkPayload) => {
50+
// Support both formats: "add-provider" and "provider/add" (one-api style)
51+
if (payload.action === 'add-provider' || payload.action === 'provider/add') {
52+
pendingDeepLinkData = {
53+
baseUrl: payload.params.baseUrl || payload.params.base_url,
54+
apiKey: payload.params.apiKey || payload.params.api_key || payload.params.key,
55+
name: payload.params.name,
56+
platform: payload.params.platform,
57+
};
58+
59+
// Navigate to model settings page; ModelModalContent will pick up the pending data
60+
void navigate('/settings/model');
61+
}
62+
},
63+
[navigate]
64+
);
65+
66+
useEffect(() => {
67+
return ipcBridge.deepLink.received.on(handler);
68+
}, [handler]);
69+
};

src/renderer/hooks/useTheme.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react';
55
export type Theme = 'light' | 'dark';
66

77
const DEFAULT_THEME: Theme = 'light';
8+
const THEME_CACHE_KEY = '__aionui_theme';
89

910
// Initialize theme immediately when module loads
1011
const initTheme = async () => {
@@ -13,6 +14,11 @@ const initTheme = async () => {
1314
const initialTheme = theme || DEFAULT_THEME;
1415
document.documentElement.setAttribute('data-theme', initialTheme);
1516
document.body.setAttribute('arco-theme', initialTheme);
17+
try {
18+
localStorage.setItem(THEME_CACHE_KEY, initialTheme);
19+
} catch (_e) {
20+
/* noop */
21+
}
1622
return initialTheme;
1723
} catch (error) {
1824
console.error('Failed to load initial theme:', error);
@@ -35,6 +41,11 @@ const useTheme = (): [Theme, (theme: Theme) => Promise<void>] => {
3541
const applyTheme = useCallback((newTheme: Theme) => {
3642
document.documentElement.setAttribute('data-theme', newTheme);
3743
document.body.setAttribute('arco-theme', newTheme);
44+
try {
45+
localStorage.setItem(THEME_CACHE_KEY, newTheme);
46+
} catch (_e) {
47+
/* noop */
48+
}
3849
}, []);
3950

4051
// Set theme with persistence

0 commit comments

Comments
 (0)