22 files changed
+522
-30
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
7.2.128
|
||||
7.2.131
|
||||
Generated
+1
-1
@@ -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,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"
|
||||
|
||||
@@ -178,6 +178,7 @@ pub(crate) fn software_settings(
|
||||
Ok(SoftwareSettings {
|
||||
close_behavior: config.close_behavior,
|
||||
autostart_enabled: app_autostart_enabled(app)?,
|
||||
start_core_on_launch: config.start_core_on_launch,
|
||||
silent_start_enabled: config.silent_start,
|
||||
})
|
||||
}
|
||||
@@ -206,13 +207,16 @@ pub(crate) fn save_software_settings(
|
||||
}
|
||||
|
||||
let config = if previous_config.close_behavior == settings.close_behavior
|
||||
&& previous_config.start_core_on_launch == settings.start_core_on_launch
|
||||
&& previous_config.silent_start == settings.silent_start_enabled
|
||||
{
|
||||
previous_config
|
||||
} else {
|
||||
match gui_config_state
|
||||
.set_software_preferences(settings.close_behavior, settings.silent_start_enabled)
|
||||
{
|
||||
match gui_config_state.set_software_preferences(
|
||||
settings.close_behavior,
|
||||
settings.start_core_on_launch,
|
||||
settings.silent_start_enabled,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = autostart_changed
|
||||
|
||||
@@ -847,6 +847,9 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
if presence.close_behavior.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
if presence.start_core_on_launch.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
if presence.silent_start.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
@@ -1178,6 +1181,7 @@ pub(crate) fn write_gui_config_to_path(
|
||||
("allow-lan", value(config.allow_lan)),
|
||||
("host", value(config.host.as_str())),
|
||||
("run-on-startup", value(config.run_on_startup)),
|
||||
("start-core-on-launch", value(config.start_core_on_launch)),
|
||||
("silent-start", value(config.silent_start)),
|
||||
("close-behavior", value(config.close_behavior.as_str())),
|
||||
("auth-dir", value(config.auth_dir.as_str())),
|
||||
|
||||
@@ -802,6 +802,7 @@ pub(crate) fn current_core_status(
|
||||
let install_dir = core_install_dir()?;
|
||||
let binary_path = find_core_binary(&install_dir);
|
||||
let installed = binary_path.is_some();
|
||||
let starting = process_state.is_some_and(CoreProcessState::is_starting);
|
||||
let managed_pid = process_state.and_then(|state| state.managed_pid());
|
||||
let management_port_open = management_port.map(is_management_port_open);
|
||||
let process_id = match managed_pid {
|
||||
@@ -814,7 +815,9 @@ pub(crate) fn current_core_status(
|
||||
let running = process_id.is_some() && management_port_open.unwrap_or(true);
|
||||
let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version);
|
||||
|
||||
let message = if !installed {
|
||||
let message = if starting {
|
||||
"CPA 内核正在启动".to_string()
|
||||
} else if !installed {
|
||||
"未安装 CPA 内核,请先安装最新版".to_string()
|
||||
} else if running {
|
||||
"CPA 内核正在运行".to_string()
|
||||
@@ -825,6 +828,7 @@ pub(crate) fn current_core_status(
|
||||
Ok(CoreStatus {
|
||||
installed,
|
||||
running,
|
||||
starting,
|
||||
managed: managed_pid.is_some(),
|
||||
process_id,
|
||||
current_version,
|
||||
@@ -1820,6 +1824,10 @@ pub(crate) fn should_start_hidden(config: &GuiConfigFile) -> bool {
|
||||
config.silent_start && cfg!(any(target_os = "windows", target_os = "macos"))
|
||||
}
|
||||
|
||||
pub(crate) fn should_start_core_on_launch(config: &GuiConfigFile) -> bool {
|
||||
config.start_core_on_launch
|
||||
}
|
||||
|
||||
pub(crate) fn configure_initial_main_window(
|
||||
app_handle: &tauri::AppHandle,
|
||||
start_hidden: bool,
|
||||
|
||||
+43
-3
@@ -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};
|
||||
|
||||
@@ -37,7 +39,6 @@ use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Arc;
|
||||
@@ -274,6 +275,7 @@ struct AppUpdateInner {
|
||||
#[derive(Default)]
|
||||
struct CoreProcessState {
|
||||
child: Mutex<Option<Child>>,
|
||||
starting: AtomicBool,
|
||||
#[cfg(windows)]
|
||||
job: Mutex<Option<isize>>,
|
||||
}
|
||||
@@ -368,6 +370,7 @@ struct CorePlatform {
|
||||
struct CoreStatus {
|
||||
installed: bool,
|
||||
running: bool,
|
||||
starting: bool,
|
||||
managed: bool,
|
||||
process_id: Option<u32>,
|
||||
current_version: Option<String>,
|
||||
@@ -550,6 +553,7 @@ struct GuiConfigFile {
|
||||
allow_lan: bool,
|
||||
host: String,
|
||||
run_on_startup: bool,
|
||||
start_core_on_launch: bool,
|
||||
silent_start: bool,
|
||||
close_behavior: WindowsCloseBehavior,
|
||||
window_width: Option<u32>,
|
||||
@@ -660,6 +664,7 @@ impl Default for GuiConfigFile {
|
||||
allow_lan: false,
|
||||
host: "127.0.0.1".to_string(),
|
||||
run_on_startup: false,
|
||||
start_core_on_launch: true,
|
||||
silent_start: false,
|
||||
close_behavior: WindowsCloseBehavior::Ask,
|
||||
window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH),
|
||||
@@ -696,6 +701,7 @@ struct GuiConfigPresence {
|
||||
api_access_remarks: Option<Vec<GuiApiAccessRemark>>,
|
||||
management_secret_key: Option<String>,
|
||||
close_behavior: Option<WindowsCloseBehavior>,
|
||||
start_core_on_launch: Option<bool>,
|
||||
silent_start: Option<bool>,
|
||||
usage_statistics_enabled: Option<bool>,
|
||||
plugins_enabled: Option<bool>,
|
||||
@@ -723,6 +729,7 @@ struct GuiSettings {
|
||||
struct SoftwareSettings {
|
||||
close_behavior: WindowsCloseBehavior,
|
||||
autostart_enabled: bool,
|
||||
start_core_on_launch: bool,
|
||||
silent_start_enabled: bool,
|
||||
}
|
||||
|
||||
@@ -731,6 +738,7 @@ struct SoftwareSettings {
|
||||
struct SoftwareSettingsInput {
|
||||
close_behavior: WindowsCloseBehavior,
|
||||
autostart_enabled: bool,
|
||||
start_core_on_launch: bool,
|
||||
silent_start_enabled: bool,
|
||||
}
|
||||
|
||||
@@ -1334,6 +1342,23 @@ impl AppUpdateState {
|
||||
}
|
||||
|
||||
impl CoreProcessState {
|
||||
fn new(starting: bool) -> Self {
|
||||
Self {
|
||||
child: Mutex::new(None),
|
||||
starting: AtomicBool::new(starting),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_starting(&self) -> bool {
|
||||
self.starting.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn set_starting(&self, starting: bool) {
|
||||
self.starting.store(starting, Ordering::Release);
|
||||
}
|
||||
|
||||
fn managed_pid(&self) -> Option<u32> {
|
||||
let Ok(mut child) = self.child.lock() else {
|
||||
return None;
|
||||
@@ -1503,10 +1528,12 @@ impl GuiConfigState {
|
||||
fn set_software_preferences(
|
||||
&self,
|
||||
close_behavior: WindowsCloseBehavior,
|
||||
start_core_on_launch: bool,
|
||||
silent_start: bool,
|
||||
) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.close_behavior = close_behavior;
|
||||
config.start_core_on_launch = start_core_on_launch;
|
||||
config.silent_start = silent_start;
|
||||
Ok(())
|
||||
})
|
||||
@@ -1718,7 +1745,7 @@ fn main() {
|
||||
)
|
||||
.manage(CoreDownloadState::default())
|
||||
.manage(AppUpdateState::default())
|
||||
.manage(CoreProcessState::default())
|
||||
.manage(CoreProcessState::new(gui_config.start_core_on_launch))
|
||||
.manage(usage::UsageCollectorState::default())
|
||||
.manage(GuiConfigState::new(gui_config))
|
||||
.manage(MainWindowSizeState::new(initial_window_size))
|
||||
@@ -1836,21 +1863,32 @@ fn main() {
|
||||
let gui_config_state = core_app.state::<GuiConfigState>();
|
||||
let process_state = core_app.state::<CoreProcessState>();
|
||||
let Ok(config) = gui_config_state.snapshot() else {
|
||||
process_state.set_starting(false);
|
||||
return;
|
||||
};
|
||||
|
||||
if config.start_core_on_launch {
|
||||
if let Ok(status) =
|
||||
current_core_status(Some(process_state.inner()), Some(config.port))
|
||||
{
|
||||
emit_core_status(&core_app, &status);
|
||||
}
|
||||
}
|
||||
|
||||
match auto_install_bundled_core_if_missing(&core_app) {
|
||||
Ok(true) => eprintln!("未检测到 CPA 内核,已自动安装内置离线版本"),
|
||||
Ok(false) => {}
|
||||
Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"),
|
||||
}
|
||||
|
||||
if config.run_on_startup {
|
||||
if should_start_core_on_launch(&config) {
|
||||
if let Err(error) = start_core_process_inner(process_state.inner(), &config) {
|
||||
eprintln!("自动启动 CPA 内核失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
process_state.set_starting(false);
|
||||
|
||||
if let Ok(status) =
|
||||
current_core_status(Some(process_state.inner()), Some(config.port))
|
||||
{
|
||||
@@ -1921,6 +1959,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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn gui_config_defaults_are_stable() {
|
||||
assert!(content.contains("port = 8317"));
|
||||
assert!(content.contains("allow-lan = false"));
|
||||
assert!(content.contains("run-on-startup = false"));
|
||||
assert!(content.contains("start-core-on-launch = true"));
|
||||
assert!(content.contains("silent-start = false"));
|
||||
assert!(content.contains("close-behavior = \"ask\""));
|
||||
assert!(content.contains("window-width = 1531"));
|
||||
@@ -119,6 +120,7 @@ fn gui_window_size_round_trips_through_portable_config() {
|
||||
#[test]
|
||||
fn silent_start_defaults_off_and_requires_tray_support() {
|
||||
let legacy = toml::from_str::<GuiConfigFile>("port = 8317\n").unwrap();
|
||||
assert!(legacy.start_core_on_launch);
|
||||
assert!(!legacy.silent_start);
|
||||
assert!(!should_start_hidden(&legacy));
|
||||
|
||||
@@ -132,6 +134,18 @@ fn silent_start_defaults_off_and_requires_tray_support() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_start_on_launch_defaults_on_and_can_be_disabled() {
|
||||
let legacy = toml::from_str::<GuiConfigFile>("port = 8317\n").unwrap();
|
||||
assert!(should_start_core_on_launch(&legacy));
|
||||
|
||||
let disabled = GuiConfigFile {
|
||||
start_core_on_launch: false,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(!should_start_core_on_launch(&disabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_window_size_is_clamped_and_requires_both_dimensions() {
|
||||
let mut config = GuiConfigFile {
|
||||
|
||||
@@ -767,6 +767,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
|
||||
allow_lan: true,
|
||||
host: "0.0.0.0".to_string(),
|
||||
run_on_startup: false,
|
||||
start_core_on_launch: true,
|
||||
silent_start: false,
|
||||
close_behavior: WindowsCloseBehavior::Ask,
|
||||
window_width: None,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn core_process_starting_state_tracks_automatic_launch() {
|
||||
let state = CoreProcessState::new(true);
|
||||
assert!(state.is_starting());
|
||||
state.set_starting(false);
|
||||
assert!(!state.is_starting());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_preserves_only_regular_bundled_assets() {
|
||||
let root = agent_test_home("bundled-assets");
|
||||
|
||||
@@ -6,6 +6,7 @@ fn windows_tray_presentation_tracks_core_state_and_busy_actions() {
|
||||
let mut status = CoreStatus {
|
||||
installed: false,
|
||||
running: false,
|
||||
starting: false,
|
||||
managed: false,
|
||||
process_id: None,
|
||||
current_version: None,
|
||||
|
||||
@@ -3024,6 +3024,7 @@ mod tests {
|
||||
allow_lan: false,
|
||||
host: "127.0.0.1".to_string(),
|
||||
run_on_startup: false,
|
||||
start_core_on_launch: true,
|
||||
silent_start: false,
|
||||
close_behavior: crate::WindowsCloseBehavior::Ask,
|
||||
window_width: None,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event';
|
||||
export type CoreStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
starting: boolean;
|
||||
managed: boolean;
|
||||
processId: number | null;
|
||||
currentVersion: string | null;
|
||||
|
||||
+10
-2
@@ -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': 'コールバック待ち',
|
||||
@@ -359,6 +365,8 @@ export const jaOverrides = {
|
||||
'config.software.title': 'ソフトウェア設定',
|
||||
'config.software.autostart': 'システム起動時に開始',
|
||||
'config.software.autostartDescription': 'サインイン後に EasyCLIProxyAPI を自動的に起動します',
|
||||
'config.software.startCoreOnLaunch': '起動時にコアを開始',
|
||||
'config.software.startCoreOnLaunchDescription': 'EasyCLIProxyAPI の起動時に CPA コアを自動的に開始します',
|
||||
'config.software.silentStart': 'サイレント起動',
|
||||
'config.software.silentStartDescription': 'メインウィンドウを表示せず、システムトレイで直接起動します',
|
||||
'config.software.closeBehavior': '終了動作',
|
||||
|
||||
+10
-2
@@ -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',
|
||||
@@ -294,6 +300,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.software.title': 'Software Settings',
|
||||
'config.software.autostart': 'Launch at startup',
|
||||
'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in',
|
||||
'config.software.startCoreOnLaunch': 'Start Core on Launch',
|
||||
'config.software.startCoreOnLaunchDescription': 'Automatically start the CPA core whenever EasyCLIProxyAPI launches',
|
||||
'config.software.silentStart': 'Silent Launch',
|
||||
'config.software.silentStartDescription': 'Start without showing the main window and run directly in the system tray',
|
||||
'config.software.closeBehavior': 'Close behavior',
|
||||
|
||||
@@ -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': '等待回调',
|
||||
@@ -292,6 +298,8 @@ export const zhCN = {
|
||||
'config.software.title': '软件设置',
|
||||
'config.software.autostart': '开机自启',
|
||||
'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI',
|
||||
'config.software.startCoreOnLaunch': '启动时启动内核',
|
||||
'config.software.startCoreOnLaunchDescription': '每次启动 EasyCLIProxyAPI 时自动启动 CPA 内核',
|
||||
'config.software.silentStart': '静默启动',
|
||||
'config.software.silentStartDescription': '启动应用时不显示主窗口,直接在系统托盘运行',
|
||||
'config.software.closeBehavior': '关闭行为',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Power,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -75,6 +76,7 @@ type NetworkDraftDirty = Record<NetworkDraftField, boolean>;
|
||||
type SoftwareSettings = {
|
||||
closeBehavior: CloseBehavior;
|
||||
autostartEnabled: boolean;
|
||||
startCoreOnLaunch: boolean;
|
||||
silentStartEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -103,6 +105,7 @@ export function ConfigPanelPage() {
|
||||
const [softwareSettingsLoading, setSoftwareSettingsLoading] = useState(true);
|
||||
const [softwareCloseBehaviorDraft, setSoftwareCloseBehaviorDraft] = useState<CloseBehavior>('ask');
|
||||
const [softwareAutostartDraft, setSoftwareAutostartDraft] = useState(false);
|
||||
const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true);
|
||||
const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false);
|
||||
const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -237,6 +240,7 @@ export function ConfigPanelPage() {
|
||||
setSoftwareSettings(result);
|
||||
setSoftwareCloseBehaviorDraft(result.closeBehavior);
|
||||
setSoftwareAutostartDraft(result.autostartEnabled);
|
||||
setSoftwareStartCoreDraft(result.startCoreOnLaunch);
|
||||
setSoftwareSilentStartDraft(result.silentStartEnabled);
|
||||
} catch (error) {
|
||||
setSoftwareSettings(null);
|
||||
@@ -526,6 +530,7 @@ export function ConfigPanelPage() {
|
||||
if (
|
||||
softwareCloseBehaviorDraft === softwareSettings.closeBehavior
|
||||
&& softwareAutostartDraft === softwareSettings.autostartEnabled
|
||||
&& softwareStartCoreDraft === softwareSettings.startCoreOnLaunch
|
||||
&& softwareSilentStartDraft === softwareSettings.silentStartEnabled
|
||||
) return;
|
||||
|
||||
@@ -535,18 +540,21 @@ export function ConfigPanelPage() {
|
||||
settings: {
|
||||
closeBehavior: softwareCloseBehaviorDraft,
|
||||
autostartEnabled: softwareAutostartDraft,
|
||||
startCoreOnLaunch: softwareStartCoreDraft,
|
||||
silentStartEnabled: softwareSilentStartDraft,
|
||||
},
|
||||
});
|
||||
setSoftwareSettings(result);
|
||||
setSoftwareCloseBehaviorDraft(result.closeBehavior);
|
||||
setSoftwareAutostartDraft(result.autostartEnabled);
|
||||
setSoftwareStartCoreDraft(result.startCoreOnLaunch);
|
||||
setSoftwareSilentStartDraft(result.silentStartEnabled);
|
||||
setSoftwareSavedStatusVisible(true);
|
||||
showNotice(t('config.notice.softwareUpdated'), 'success');
|
||||
} catch (error) {
|
||||
setSoftwareCloseBehaviorDraft(softwareSettings.closeBehavior);
|
||||
setSoftwareAutostartDraft(softwareSettings.autostartEnabled);
|
||||
setSoftwareStartCoreDraft(softwareSettings.startCoreOnLaunch);
|
||||
setSoftwareSilentStartDraft(softwareSettings.silentStartEnabled);
|
||||
setSoftwareSavedStatusVisible(false);
|
||||
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
|
||||
@@ -585,8 +593,11 @@ export function ConfigPanelPage() {
|
||||
&& softwareAutostartDraft !== softwareSettings.autostartEnabled;
|
||||
const softwareSilentStartDirty = softwareSettings !== null
|
||||
&& softwareSilentStartDraft !== softwareSettings.silentStartEnabled;
|
||||
const softwareStartCoreDirty = softwareSettings !== null
|
||||
&& softwareStartCoreDraft !== softwareSettings.startCoreOnLaunch;
|
||||
const softwareSettingsDirty = softwareCloseBehaviorDirty
|
||||
|| softwareAutostartDirty
|
||||
|| softwareStartCoreDirty
|
||||
|| softwareSilentStartDirty;
|
||||
const softwareStatusLabel = softwareSettingsLoading
|
||||
? t('common.loading')
|
||||
@@ -1272,6 +1283,31 @@ export function ConfigPanelPage() {
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="config-software-setting-row">
|
||||
<div className="config-software-setting-copy">
|
||||
<span className="config-software-setting-icon" aria-hidden="true">
|
||||
<Power size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('config.software.startCoreOnLaunch')}</strong>
|
||||
<small>{t('config.software.startCoreOnLaunchDescription')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.software.startCoreOnLaunch')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={t('config.software.startCoreOnLaunch')}
|
||||
checked={softwareStartCoreDraft}
|
||||
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null}
|
||||
onChange={(event) => {
|
||||
setSoftwareSavedStatusVisible(false);
|
||||
setSoftwareStartCoreDraft(event.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="config-software-setting-row">
|
||||
<div className="config-software-setting-copy">
|
||||
<span className="config-software-setting-icon" aria-hidden="true">
|
||||
|
||||
@@ -584,9 +584,10 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
const currentVersion = coreStatus?.currentVersion ?? '';
|
||||
const coreInstalled = Boolean(coreStatus?.installed);
|
||||
const coreRunning = Boolean(coreStatus?.running);
|
||||
const busy = checkingLatest || installing || processBusy;
|
||||
const coreProcessBusy = processBusy || Boolean(coreStatus?.starting);
|
||||
const busy = checkingLatest || installing || coreProcessBusy;
|
||||
const installDisabled = busy || Boolean(coreStatus?.running);
|
||||
const offlineInstallDisabled = installing || processBusy || coreRunning;
|
||||
const offlineInstallDisabled = installing || coreProcessBusy || coreRunning;
|
||||
const computedPercent =
|
||||
progress?.percent ??
|
||||
(progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null);
|
||||
@@ -702,7 +703,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<h2>{t('kernel.control.title')}</h2>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
|
||||
{processBusy ? t('common.processing') : statusLabel}
|
||||
{coreProcessBusy ? t('common.processing') : statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -736,7 +737,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<button
|
||||
type="button"
|
||||
className={coreRunning ? 'danger-button' : 'primary-button'}
|
||||
disabled={!coreInstalled || installing || processBusy}
|
||||
disabled={!coreInstalled || installing || coreProcessBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand(
|
||||
coreRunning ? 'stop_core_process' : 'start_core_process',
|
||||
@@ -744,12 +745,12 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{processBusy ? t('common.processing') : coreRunning ? t('kernel.action.stop') : t('kernel.action.start')}
|
||||
{coreProcessBusy ? t('common.processing') : coreRunning ? t('kernel.action.stop') : t('kernel.action.start')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy}
|
||||
disabled={!coreInstalled || !coreRunning || installing || coreProcessBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
|
||||
}
|
||||
@@ -759,7 +760,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={processBusy}
|
||||
disabled={coreProcessBusy}
|
||||
onClick={() => void refreshStatus()}
|
||||
>
|
||||
{t('kernel.control.refresh')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user