feat(oauth): add browser selection and refreshable login links

- detect installed browsers and remember the selected option
- support generating OAuth links without opening a browser
- refresh login links after cancelling the previous OAuth session
- update multilingual labels and responsive styles
- bump app version to 0.2.21 and core version to 7.2.131
This commit is contained in:
lzt404 committed 2026-08-14 01:20:02 +08:00
1 parent 6ac2d8d4af
commit 2888042645
11 files changed
+387 -16

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.2.128
7.2.131
+1 -1
View File
@@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.19"
version = "0.2.21"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.19"
version = "0.2.21"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+4
View File
@@ -10,6 +10,7 @@ mod configuration_watcher;
mod core_config;
mod core_runtime;
mod management_api;
mod oauth_browser;
mod provider_health;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod tray;
@@ -21,6 +22,7 @@ use management_api::{
management_authorization, management_endpoint, management_http_client, read_management_text,
read_management_value,
};
use oauth_browser::*;
#[cfg(test)]
use provider_health::{provider_health_content_type_is_streaming, provider_health_stream_has_text};
@@ -1921,6 +1923,8 @@ fn main() {
management_api::start_oauth_login,
management_api::get_oauth_status,
management_api::submit_oauth_callback,
list_oauth_browsers,
open_oauth_url,
open_external_url,
check_app_update,
get_app_update_task,
+3 -2
View File
@@ -2,7 +2,7 @@
use super::windows_explorer_executable;
use super::{
auth_dir_path_for_core, configure_background_command, core_install_dir,
is_hashed_management_secret_key, open_external_url_inner, path_to_string, truncate_for_error,
is_hashed_management_secret_key, open_oauth_url_inner, path_to_string, truncate_for_error,
GuiConfigFile, GuiConfigState,
};
use serde::{Deserialize, Serialize};
@@ -162,6 +162,7 @@ pub(crate) async fn start_oauth_login(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
provider: String,
browser: Option<String>,
) -> Result<OAuthStartResult, String> {
let config = gui_config_state.snapshot()?;
let provider_key = normalize_management_oauth_provider(&provider)?;
@@ -197,7 +198,7 @@ pub(crate) async fn start_oauth_login(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let (opened, open_error) = match open_external_url_inner(&app, &url) {
let (opened, open_error) = match open_oauth_url_inner(&app, &url, browser.as_deref()) {
Ok(()) => (true, None),
Err(error) => (false, Some(error)),
};
+206
View File
@@ -0,0 +1,206 @@
use super::*;
const DEFAULT_BROWSER: &str = "default";
const NO_AUTO_OPEN: &str = "none";
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OAuthBrowserOption {
id: &'static str,
label: &'static str,
}
struct BrowserSpec {
id: &'static str,
label: &'static str,
executable: PathBuf,
}
#[tauri::command]
pub(crate) fn list_oauth_browsers() -> Vec<OAuthBrowserOption> {
let mut options = vec![OAuthBrowserOption {
id: DEFAULT_BROWSER,
label: "System Default",
}];
options.extend(
browser_specs()
.into_iter()
.map(|browser| OAuthBrowserOption {
id: browser.id,
label: browser.label,
}),
);
options
}
#[tauri::command]
pub(crate) fn open_oauth_url(
app: tauri::AppHandle,
url: String,
browser: Option<String>,
) -> Result<(), String> {
open_oauth_url_inner(&app, &url, browser.as_deref())
}
pub(crate) fn open_oauth_url_inner(
app: &tauri::AppHandle,
url: &str,
browser: Option<&str>,
) -> Result<(), String> {
let url = validate_http_url(url)?;
let browser = browser
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_BROWSER);
if browser == NO_AUTO_OPEN {
return Ok(());
}
if browser == DEFAULT_BROWSER {
return open_external_url_inner(app, url);
}
let selected = browser_specs()
.into_iter()
.find(|candidate| candidate.id == browser)
.ok_or_else(|| format!("The selected browser is not installed: {browser}"))?;
app.opener()
.open_url(
url,
Some(selected.executable.to_string_lossy().into_owned()),
)
.map_err(|error| format!("Failed to open {}: {error}", selected.label))
}
fn validate_http_url(url: &str) -> Result<&str, String> {
let url = url.trim();
if url.starts_with("http://") || url.starts_with("https://") {
Ok(url)
} else {
Err("Only http/https links can be opened".to_string())
}
}
#[cfg(target_os = "windows")]
fn browser_specs() -> Vec<BrowserSpec> {
let find = |id, label, roots: &[&str], relative: &str| {
roots
.iter()
.filter_map(env::var_os)
.map(|root| PathBuf::from(root).join(relative))
.find(|path| path.is_file())
.map(|executable| BrowserSpec {
id,
label,
executable,
})
};
[
find(
"edge",
"Microsoft Edge",
&["LOCALAPPDATA", "ProgramFiles(x86)", "ProgramFiles"],
"Microsoft/Edge/Application/msedge.exe",
),
find(
"chrome",
"Google Chrome",
&["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"],
"Google/Chrome/Application/chrome.exe",
),
find(
"brave",
"Brave",
&["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"],
"BraveSoftware/Brave-Browser/Application/brave.exe",
),
find(
"firefox",
"Mozilla Firefox",
&["ProgramFiles", "ProgramFiles(x86)"],
"Mozilla Firefox/firefox.exe",
),
]
.into_iter()
.flatten()
.collect()
}
#[cfg(target_os = "macos")]
fn browser_specs() -> Vec<BrowserSpec> {
let roots = [
PathBuf::from("/Applications"),
env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default()
.join("Applications"),
];
let find = |id, label, bundle: &str| {
roots
.iter()
.map(|root| root.join(bundle))
.find(|path| path.is_dir())
.map(|executable| BrowserSpec {
id,
label,
executable,
})
};
[
find("safari", "Safari", "Safari.app"),
find("chrome", "Google Chrome", "Google Chrome.app"),
find("firefox", "Mozilla Firefox", "Firefox.app"),
find("brave", "Brave", "Brave Browser.app"),
find("edge", "Microsoft Edge", "Microsoft Edge.app"),
]
.into_iter()
.flatten()
.collect()
}
#[cfg(target_os = "linux")]
fn browser_specs() -> Vec<BrowserSpec> {
let find = |id, label, names: &[&str]| {
env::var_os("PATH")
.into_iter()
.flat_map(|path| env::split_paths(&path).collect::<Vec<_>>())
.flat_map(|root| names.iter().map(move |name| root.join(name)))
.find(|path| path.is_file())
.map(|executable| BrowserSpec {
id,
label,
executable,
})
};
[
find(
"chrome",
"Google Chrome",
&["google-chrome", "google-chrome-stable"],
),
find("firefox", "Mozilla Firefox", &["firefox"]),
find("brave", "Brave", &["brave-browser", "brave"]),
find(
"edge",
"Microsoft Edge",
&["microsoft-edge", "microsoft-edge-stable"],
),
]
.into_iter()
.flatten()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oauth_browser_urls_only_allow_http_and_https() {
assert_eq!(
validate_http_url(" https://example.com ").unwrap(),
"https://example.com"
);
assert!(validate_http_url("file:///tmp/token").is_err());
assert!(validate_http_url("javascript:alert(1)").is_err());
}
}
+8 -2
View File
@@ -83,7 +83,7 @@ export const jaOverrides = {
'oauth.loginAnother': '別のアカウントでログイン',
'oauth.loggingIn': 'ログイン中…',
'oauth.startLogin': 'ログインを開始',
'oauth.hint': 'ログインを開始するとブラウザーが開き、認証を行います。自動で開かない場合はリンクを手動で開いてください',
'oauth.hint': 'ログインを開始すると上で選択したブラウザーが開きます。「自動で開かない」を選ぶとリンクのみ生成します',
'oauth.authorizationLink': '認証リンク',
'oauth.copyLink': 'リンクをコピー',
'oauth.openLink': 'リンクを開く',
@@ -92,7 +92,13 @@ export const jaOverrides = {
'oauth.submitCallback': 'コールバックを送信',
'oauth.callbackSubmitted': 'コールバックを送信しました。認証完了を待っています',
'oauth.callbackFailed': 'コールバックの送信に失敗しました{detail}',
'oauth.openBrowser': 'ブラウザーを開く',
'oauth.refreshLink': 'リンクを更新',
'oauth.refreshingLink': '更新中…',
'oauth.browser.label': 'ブラウザーを選択',
'oauth.browser.detecting': 'ブラウザーを検出中…',
'oauth.browser.systemDefault': 'システムのデフォルトブラウザー',
'oauth.browser.noAutoOpen': '自動で開かない',
'oauth.browser.remembered': '利用可能なブラウザーを自動選択し、選択内容を記憶します',
'oauth.status.completed': '完了',
'oauth.status.failed': '失敗',
'oauth.status.waiting': 'コールバック待ち',
+8 -2
View File
@@ -83,7 +83,7 @@ export const en: Record<MessageKey, string> = {
'oauth.loginAnother': 'Sign In Another Account',
'oauth.loggingIn': 'Signing in…',
'oauth.startLogin': 'Start Sign-In',
'oauth.hint': 'Starting sign-in opens your browser for authorization. If it does not open, use the link below.',
'oauth.hint': 'Starting sign-in uses the browser selected above. Choose “Do Not Open Automatically” to generate the link only.',
'oauth.authorizationLink': 'Authorization link',
'oauth.copyLink': 'Copy Link',
'oauth.openLink': 'Open Link',
@@ -92,7 +92,13 @@ export const en: Record<MessageKey, string> = {
'oauth.submitCallback': 'Submit Callback',
'oauth.callbackSubmitted': 'Callback submitted. Waiting for authentication.',
'oauth.callbackFailed': 'Callback submission failed{detail}',
'oauth.openBrowser': 'Open Browser',
'oauth.refreshLink': 'Refresh Link',
'oauth.refreshingLink': 'Refreshing…',
'oauth.browser.label': 'Choose Browser',
'oauth.browser.detecting': 'Detecting browsers…',
'oauth.browser.systemDefault': 'System Default Browser',
'oauth.browser.noAutoOpen': 'Do Not Open Automatically',
'oauth.browser.remembered': 'An available browser is selected automatically and your choice is remembered',
'oauth.status.completed': 'Completed',
'oauth.status.failed': 'Failed',
'oauth.status.waiting': 'Waiting for Callback',
+8 -2
View File
@@ -81,7 +81,7 @@ export const zhCN = {
'oauth.loginAnother': '登录另一个账号',
'oauth.loggingIn': '登录中…',
'oauth.startLogin': '开始登录',
'oauth.hint': '点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接',
'oauth.hint': '点击开始登录后,将按上方选择打开浏览器;选择“不自动打开”时仅生成登录链接',
'oauth.authorizationLink': '授权链接',
'oauth.copyLink': '复制链接',
'oauth.openLink': '打开链接',
@@ -90,7 +90,13 @@ export const zhCN = {
'oauth.submitCallback': '提交回调',
'oauth.callbackSubmitted': '回调已提交,等待认证完成',
'oauth.callbackFailed': '回调提交失败{detail}',
'oauth.openBrowser': '打开浏览器',
'oauth.refreshLink': '刷新链接',
'oauth.refreshingLink': '刷新中…',
'oauth.browser.label': '选择浏览器',
'oauth.browser.detecting': '正在检测浏览器…',
'oauth.browser.systemDefault': '系统默认浏览器',
'oauth.browser.noAutoOpen': '不自动打开',
'oauth.browser.remembered': '已自动选择可用浏览器,并会记住你的选择',
'oauth.status.completed': '已完成',
'oauth.status.failed': '失败',
'oauth.status.waiting': '等待回调',
+108 -5
View File
@@ -6,6 +6,7 @@ import {
ExternalLink,
LoaderCircle,
LogIn,
RefreshCw,
} from 'lucide-react';
import antigravityIcon from '../assets/icons/antigravity.svg';
import claudeIcon from '../assets/icons/claude.svg';
@@ -40,6 +41,7 @@ type OAuthProviderState = {
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
refreshing?: boolean;
};
type OAuthStartResult = {
@@ -49,6 +51,11 @@ type OAuthStartResult = {
openError?: string | null;
};
type OAuthBrowserOption = {
id: string;
label: string;
};
type OAuthStatusResult = {
status: string;
error?: string | null;
@@ -70,8 +77,29 @@ const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
]);
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
const OAUTH_POLL_INTERVAL_MS = 3000;
const OAUTH_BROWSER_STORAGE_KEY = 'easy-cli-proxy-api.oauth-browser.v3';
const NO_AUTO_OPEN_BROWSER_ID = 'none';
const oauthLoginSuccessCache = createOAuthLoginSuccessCache<OAuthProviderId>();
const resolveOAuthBrowserSelection = (
available: OAuthBrowserOption[],
preferred: string,
): string => {
if (preferred === NO_AUTO_OPEN_BROWSER_ID) return preferred;
if (available.some((browser) => browser.id === preferred)) return preferred;
return available.find((browser) => browser.id !== 'default')?.id
?? available.find((browser) => browser.id === 'default')?.id
?? NO_AUTO_OPEN_BROWSER_ID;
};
const loadOAuthBrowserPreference = (): string => {
try {
return window.localStorage.getItem(OAUTH_BROWSER_STORAGE_KEY)?.trim() || '';
} catch {
return '';
}
};
const cachedOAuthProviderStates = (): Partial<Record<OAuthProviderId, OAuthProviderState>> => (
oauthLoginSuccessCache.snapshot().reduce<Partial<Record<OAuthProviderId, OAuthProviderState>>>(
(states, provider) => ({ ...states, [provider]: { status: 'success' } }),
@@ -133,11 +161,43 @@ export function OAuthLoginPage() {
message: string;
tone: 'success' | 'error' | 'info';
} | null>(null);
const [browsers, setBrowsers] = useState<OAuthBrowserOption[]>([]);
const [browsersLoading, setBrowsersLoading] = useState(true);
const [selectedBrowser, setSelectedBrowser] = useState(loadOAuthBrowserPreference);
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
const noticeTimerRef = useRef<number | null>(null);
useEffect(() => {
let active = true;
void invoke<OAuthBrowserOption[]>('list_oauth_browsers')
.then((available) => {
if (!active) return;
setBrowsers(available);
setSelectedBrowser((current) => resolveOAuthBrowserSelection(available, current));
})
.catch((error) => {
if (!active) return;
console.warn('Failed to detect installed browsers', error);
const fallback = [{ id: 'default', label: 'System Default' }];
setBrowsers(fallback);
setSelectedBrowser((current) => resolveOAuthBrowserSelection(fallback, current));
})
.finally(() => {
if (active) setBrowsersLoading(false);
});
return () => { active = false; };
}, []);
useEffect(() => {
try {
window.localStorage.setItem(OAUTH_BROWSER_STORAGE_KEY, selectedBrowser);
} catch {
// Keep the in-memory selection when persistent storage is unavailable.
}
}, [selectedBrowser]);
const showNotice = useCallback((message: string, tone: 'success' | 'error' | 'info') => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
setNotice({ message, tone });
@@ -287,7 +347,10 @@ export function OAuthLoginPage() {
try {
await captureCredentialSnapshot(provider);
const result = await invoke<OAuthStartResult>('start_oauth_login', { provider });
const result = await invoke<OAuthStartResult>('start_oauth_login', {
provider,
browser: selectedBrowser,
});
if (!result.state) {
updateProviderState(provider, {
url: result.url,
@@ -327,10 +390,31 @@ export function OAuthLoginPage() {
}
};
const refreshLoginLink = async (provider: OAuthProviderId) => {
const currentState = states[provider]?.state;
clearPollingTimer(provider);
updateProviderState(provider, { refreshing: true });
try {
if (currentState) {
await managementApi.delete('/oauth-session', { query: { state: currentState } });
}
} catch (error) {
console.warn('Failed to cancel the previous OAuth session before refreshing', error);
}
try {
await startLogin(provider);
} finally {
updateProviderState(provider, { refreshing: false });
}
};
const openAuthUrl = async (url?: string) => {
if (!url) return;
try {
await invoke('open_external_url', { url });
await invoke('open_oauth_url', {
url,
browser: selectedBrowser === NO_AUTO_OPEN_BROWSER_ID ? 'default' : selectedBrowser,
});
} catch (error) {
showNotice(String(error), 'error');
}
@@ -394,6 +478,24 @@ export function OAuthLoginPage() {
<section className="page management-page">
<header className="management-header">
<div><span>OAuth</span><h1>{t('oauth.title')}</h1></div>
<label className="oauth-browser-picker">
<span>{t('oauth.browser.label')}</span>
<select
value={selectedBrowser}
onChange={(event) => setSelectedBrowser(event.currentTarget.value)}
aria-label={t('oauth.browser.label')}
disabled={browsersLoading}
>
{browsersLoading ? <option value="">{t('oauth.browser.detecting')}</option> : null}
{browsers.map((browser) => (
<option value={browser.id} key={browser.id}>
{browser.id === 'default' ? t('oauth.browser.systemDefault') : browser.label}
</option>
))}
<option value={NO_AUTO_OPEN_BROWSER_ID}>{t('oauth.browser.noAutoOpen')}</option>
</select>
<small>{t('oauth.browser.remembered')}</small>
</label>
</header>
{notice ? (
@@ -476,13 +578,14 @@ export function OAuthLoginPage() {
</div>
<div className="button-row management-card-actions">
<button type="button" className="primary-button" disabled={Boolean(state.polling)} onClick={() => void startLogin(provider.id)}>
<button type="button" className="primary-button" disabled={Boolean(state.polling) || browsersLoading} onClick={() => void startLogin(provider.id)}>
{state.polling ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <LogIn size={16} aria-hidden="true" />}
{loginLabel}
</button>
{state.url ? (
<button type="button" className="secondary-button" onClick={() => void openAuthUrl(state.url)}>
<ExternalLink size={16} aria-hidden="true" />{t('oauth.openBrowser')}
<button type="button" className="secondary-button" disabled={browsersLoading || state.refreshing} onClick={() => void refreshLoginLink(provider.id)}>
<RefreshCw size={16} className={state.refreshing ? 'spin' : undefined} aria-hidden="true" />
{state.refreshing ? t('oauth.refreshingLink') : t('oauth.refreshLink')}
</button>
) : null}
</div>
+39
View File
@@ -2713,6 +2713,34 @@ textarea:focus-visible {
gap: 14px;
}
.oauth-browser-picker {
display: grid;
grid-template-columns: auto minmax(180px, 240px);
align-items: center;
justify-content: end;
gap: 5px 10px;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
font-weight: 700;
}
.oauth-browser-picker select {
width: 100%;
height: 38px;
padding: 0 34px 0 11px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 6px;
background: var(--theme-ffffff);
color: var(--theme-2d2a26);
}
.oauth-browser-picker small {
grid-column: 1 / -1;
font-size: var(--font-size-caption);
font-weight: 400;
text-align: right;
}
.oauth-card {
display: grid;
grid-template-rows: auto auto auto;
@@ -7995,6 +8023,17 @@ textarea:focus-visible {
min-height: 0;
}
.oauth-browser-picker {
width: 100%;
grid-template-columns: 1fr;
justify-content: stretch;
}
.oauth-browser-picker small {
grid-column: 1;
text-align: left;
}
.oauth-card-body,
.oauth-callback-row {
grid-template-columns: 1fr;