diff --git a/src/lib.rs b/src/lib.rs index 74be7eb..738eb02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,9 @@ pub use parse::*; #[cfg(all(target_os = "android", feature = "android-gui"))] slint::include_modules!(); +#[cfg(all(target_os = "android", feature = "android-gui"))] +use slint::{ComponentHandle, SharedString, Weak}; + use chrono::{SecondsFormat, Utc}; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; @@ -55,11 +58,239 @@ pub const DEFAULT_ABTOP_BIN: &str = "abtop"; #[cfg(all(target_os = "android", feature = "android-gui"))] #[unsafe(no_mangle)] pub fn android_main(app: slint::android::AndroidApp) { + let data_dir = app.internal_data_path(); slint::android::init(app).expect("cannot initialize Android backend"); - AppWindow::new() - .expect("cannot initialize Slint window") - .run() - .expect("cannot run Slint window"); + let window = AppWindow::new().expect("cannot initialize Slint window"); + window.set_is_android(true); + window.set_needs_setup(false); + + let key_path = android_key_path(data_dir); + let saved_key = android_load_api_key(&key_path).unwrap_or_default(); + window.set_api_key_configured(!saved_key.is_empty()); + window.set_setup_status_text("Вставьте VIBEMODE_API_KEY и нажмите Сохранить ключ.".into()); + + let key_state = std::sync::Arc::new(std::sync::Mutex::new(saved_key)); + let path_for_save = key_path.clone(); + let key_for_save = key_state.clone(); + let weak = window.as_weak(); + window.on_save_api_key(move |value| { + if let Some(window) = weak.upgrade() { + let key = value.trim().to_string(); + if key.is_empty() { + window.set_error_text("API key пустой".into()); + return; + } + match android_save_api_key(&path_for_save, &key) { + Ok(()) => { + *key_for_save.lock().unwrap() = key; + window.set_api_key_input("".into()); + window.set_api_key_configured(true); + window.set_error_text("".into()); + window.set_status_text("Ключ сохранён. Нажмите Проверить.".into()); + } + Err(error) => window.set_error_text(error.into()), + } + } + }); + + let key_for_refresh = key_state.clone(); + let weak = window.as_weak(); + window.on_refresh_requested(move || { + android_start_refresh(weak.clone(), key_for_refresh.clone(), false); + }); + + let key_for_demo = key_state.clone(); + let weak = window.as_weak(); + window.on_demo_requested(move || { + android_start_refresh(weak.clone(), key_for_demo.clone(), true); + }); + + android_start_refresh(window.as_weak(), key_state, false); + window.run().expect("cannot run Slint window"); +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_key_path(data_dir: Option) -> PathBuf { + data_dir + .unwrap_or_else(|| PathBuf::from(".")) + .join("vimit-api-key") +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_load_api_key(path: &std::path::Path) -> Result { + match fs::read_to_string(path) { + Ok(value) => Ok(value.trim().to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(format!("cannot read Android API key: {error}")), + } +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_save_api_key(path: &std::path::Path, key: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create Android config dir: {error}"))?; + } + fs::write(path, key).map_err(|error| format!("cannot save Android API key: {error}")) +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_start_refresh( + app: Weak, + key_state: std::sync::Arc>, + demo: bool, +) { + std::thread::spawn(move || { + let result = android_load_dashboard(&key_state, demo); + let _ = app.upgrade_in_event_loop(move |app| android_apply_dashboard(&app, result)); + }); +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_load_dashboard( + key_state: &std::sync::Arc>, + demo: bool, +) -> Result<(Vec, String, String), String> { + if demo { + let windows = summarize_me( + &demo_payload(), + DEFAULT_WARNING_THRESHOLD, + DEFAULT_DANGER_THRESHOLD, + ); + return Ok(( + windows, + "источник: встроенные демо-данные".to_string(), + "demo".to_string(), + )); + } + + let key = key_state.lock().unwrap().clone(); + if key.is_empty() { + let windows = summarize_me( + &demo_payload(), + DEFAULT_WARNING_THRESHOLD, + DEFAULT_DANGER_THRESHOLD, + ); + return Ok(( + windows, + "источник: демо; сохраните VIBEMODE_API_KEY для live-лимитов".to_string(), + "demo".to_string(), + )); + } + + let http = HttpClient::new(USER_AGENT_GUI)?; + let mut router = Router::new( + DEFAULT_API_BASE.to_string(), + api_fallbacks_for(DEFAULT_API_BASE, true), + ); + let (payload, label) = http.fetch_me_with_retry(&key, &mut router, DEFAULT_API_BASE)?; + let windows = summarize_me( + &payload, + DEFAULT_WARNING_THRESHOLD, + DEFAULT_DANGER_THRESHOLD, + ); + Ok(( + windows, + format!("источник: live VibeMode /v1/me ({label})"), + label, + )) +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_apply_dashboard( + app: &AppWindow, + result: Result<(Vec, String, String), String>, +) { + match result { + Ok((windows, source, endpoint)) => { + app.set_error_text("".into()); + app.set_status_text(dashboard_status(&windows).into()); + app.set_source_text(source.into()); + app.set_active_endpoint_label(endpoint.into()); + android_apply_window(app, "5h", windows.iter().find(|window| window.key == "5h")); + android_apply_window( + app, + "24h", + windows.iter().find(|window| window.key == "24h"), + ); + android_apply_window(app, "7d", windows.iter().find(|window| window.key == "7d")); + android_apply_window( + app, + "30d", + windows.iter().find(|window| window.key == "30d"), + ); + } + Err(error) => { + let msg = if error.contains("HTTP 401") { + "Проверьте VIBEMODE_API_KEY" + } else { + "Не удалось загрузить VibeMode" + }; + app.set_error_text(msg.into()); + app.set_status_text(msg.into()); + } + } +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_apply_window(app: &AppWindow, key: &str, window: Option<&WindowState>) { + let Some(window) = window else { return }; + let level: SharedString = window.level.clone().into(); + let reset: SharedString = window.reset.clone().into(); + let credits: SharedString = metric_text("кредиты", window.credits.as_ref()).into(); + let requests: SharedString = metric_text("запросы", window.requests.as_ref()).into(); + let percent_text: SharedString = format_percent(window.percent).into(); + let percent = window.percent as f32; + let peak = + peak_percent(window.credits.as_ref(), window.requests.as_ref()).unwrap_or(0.0) as f32; + + match key { + "5h" => { + app.set_five_level(level); + app.set_five_reset(reset); + app.set_five_credits(credits); + app.set_five_requests(requests); + app.set_five_percent_text(percent_text); + app.set_five_percent(percent); + app.set_five_credit_percent(peak); + app.set_five_request_percent(peak); + } + "24h" => { + app.set_day_level(level); + app.set_day_reset(reset); + app.set_day_credits(credits); + app.set_day_requests(requests); + app.set_day_percent_text(percent_text); + app.set_day_percent(percent); + app.set_day_credit_percent(peak); + app.set_day_request_percent(peak); + } + "7d" => { + app.set_week_level(level); + app.set_week_reset(reset.clone()); + app.set_week_credits(credits); + app.set_week_requests(requests); + app.set_week_percent_text(percent_text); + app.set_week_percent(percent); + app.set_week_credit_percent(peak); + app.set_week_request_percent(peak); + if let Some(metric) = window.credits.as_ref() { + app.set_donut_remaining(short_number(metric.remaining).into()); + app.set_donut_limit(short_number(metric.limit).into()); + } + } + "30d" => { + app.set_month_level(level); + app.set_month_reset(reset); + app.set_month_credits(credits); + app.set_month_requests(requests); + app.set_month_percent_text(percent_text); + app.set_month_percent(percent); + app.set_month_credit_percent(peak); + app.set_month_request_percent(peak); + } + _ => {} + } } pub const WINDOWS: [(&str, &str, &str, &str); 4] = [ diff --git a/ui/app.slint b/ui/app.slint index 630a96a..71a048f 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,4 +1,4 @@ -import { Button, ComboBox, CheckBox } from "std-widgets.slint"; +import { Button, ComboBox, CheckBox, LineEdit } from "std-widgets.slint"; import { Theme } from "theme.slint"; component MeterBar inherits Rectangle { @@ -391,16 +391,17 @@ component PulseOrganism inherits Rectangle { export component AppWindow inherits Window { title: "vimit-gui"; in-out property is-overlay: false; + in-out property is-android: false; in-out property is-compact: false; in-out property overlay-pinned: true; in-out property overlay-unit: 0; in-out property overlay-reset-seconds: -1; in property token-rate-raw: 0.0; - preferred-width: root.is-overlay ? (root.is-compact ? 260px : 340px) : 1120px; - preferred-height: root.is-overlay ? (root.is-compact ? 52px : 380px) : 720px; - min-width: root.is-overlay ? (root.is-compact ? 220px : 300px) : 900px; - min-height: root.is-overlay ? (root.is-compact ? 48px : 340px) : 660px; + preferred-width: root.is-overlay ? (root.is-compact ? 260px : 340px) : root.is-android ? 420px : 1120px; + preferred-height: root.is-overlay ? (root.is-compact ? 52px : 380px) : root.is-android ? 760px : 720px; + min-width: root.is-overlay ? (root.is-compact ? 220px : 300px) : root.is-android ? 320px : 900px; + min-height: root.is-overlay ? (root.is-compact ? 48px : 340px) : root.is-android ? 520px : 660px; no-frame: root.is-overlay; always-on-top: root.is-overlay && root.overlay-pinned; background: root.is-overlay ? transparent : Theme.bg-primary; @@ -414,6 +415,7 @@ export component AppWindow inherits Window { callback update-now(); callback auto-update-changed(bool); callback auto-api-failover-changed(bool); + callback save-api-key(string); callback close-overlay(); callback launch-mini-overlay(); callback overlay-compact-changed(bool); @@ -456,6 +458,8 @@ export component AppWindow inherits Window { in property new-version-label: ""; in-out property auto-update-check: true; in-out property auto-api-failover: true; + in-out property api-key-input: ""; + in-out property api-key-configured: false; in property status-text: "Запуск..."; in property source-text: "источник: загрузка"; @@ -636,7 +640,7 @@ export component AppWindow inherits Window { font-weight: 600; } - HorizontalLayout { + if (!root.is-android) : HorizontalLayout { spacing: 10px; height: 34px; @@ -663,15 +667,93 @@ export component AppWindow inherits Window { Rectangle { } } + + if (root.is-android) : VerticalLayout { + spacing: 8px; + + HorizontalLayout { + spacing: 8px; + Button { + text: "Обновить"; + clicked => { root.refresh-requested(); } + } + + Button { + text: "Демо"; + clicked => { root.demo-requested(); } + } + } + + HorizontalLayout { + spacing: 8px; + Button { + text: "Мини-окно"; + clicked => { root.launch-mini-overlay(); } + } + + Button { + text: root.settings-open ? "Скрыть настройки" : "Настройки"; + clicked => { root.settings-open = !root.settings-open; } + } + } + } } Rectangle { } } } + if (root.is-android) : Rectangle { + min-height: root.api-key-configured ? 112px : 146px; + background: Theme.bg-card; + border-radius: 10px; + border-color: Theme.border; + border-width: 1px; + + VerticalLayout { + padding: 14px; + spacing: 8px; + + Text { + text: root.api-key-configured ? "API key сохранён локально" : "Вставьте VIBEMODE API key"; + color: root.api-key-configured ? #7fb69c : Theme.text-primary; + font-size: 16px; + font-weight: 800; + wrap: word-wrap; + } + + LineEdit { + text <=> root.api-key-input; + placeholder-text: "VIBEMODE_API_KEY"; + input-type: InputType.password; + } + + HorizontalLayout { + spacing: 8px; + + Button { + text: "Сохранить ключ"; + clicked => { root.save-api-key(root.api-key-input); } + } + + Button { + text: "Проверить"; + clicked => { root.refresh-requested(); } + } + } + + if (!root.api-key-configured) : Text { + text: "Ключ хранится только в локальном файле приложения Android и не выводится в лог."; + color: Theme.text-muted; + font-size: 11px; + wrap: word-wrap; + } + } + } + Rectangle { visible: root.settings-open; - height: root.settings-open ? 270px : 0px; + height: root.settings-open ? (root.is-android ? 390px : 270px) : 0px; background: Theme.bg-card; border-radius: 8px; border-color: Theme.border; @@ -1009,8 +1091,8 @@ export component AppWindow inherits Window { } DonutChart { - row: 0; - col: 1; + row: root.is-android ? 1 : 0; + col: root.is-android ? 0 : 1; remaining: root.donut-remaining; total: root.donut-limit; reset: root.week-reset; @@ -1020,7 +1102,7 @@ export component AppWindow inherits Window { } LimitCard { - row: 1; + row: root.is-android ? 2 : 1; col: 0; name: "7d"; level: root.week-level; @@ -1038,8 +1120,8 @@ export component AppWindow inherits Window { } LimitCard { - row: 1; - col: 1; + row: root.is-android ? 3 : 1; + col: root.is-android ? 0 : 1; name: "30d"; level: root.month-level; reset: root.month-reset;