From 739333aa8027ad88a1f7312e59abad575e9b55ff Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 6 Mar 2026 23:47:04 +0400 Subject: [PATCH] docs: expand AGPL-3.0 license section in README (#1143) * fix(desktop): settings UI redesign, IPC security hardening, release profile Settings window: - Add titlebar drag region (macOS traffic light clearance) - Move Export/Import from Overview to Debug & Logs section - Category cards grid changed to 3-column layout Security (IPC trust boundary): - Add require_trusted_window() to get_desktop_runtime_info, open_url, open_live_channels_window_command, open_youtube_login - Validate base_url in open_live_channels_window_command (localhost-only http) Performance: - Add [profile.release] with fat LTO, codegen-units=1, strip, panic=abort - Reuse reqwest::Client via app state with connection pooling - Debounce window resize handler (150ms) in EventHandlerManager * docs: expand AGPL-3.0 license section in README Add plain-language explanation of AGPL-3.0 rights and obligations including attribution requirements, copyleft conditions, network use clause (SaaS must share source), and a use-case reference table. --- README.md | 37 ++++++++++++++- settings.html | 1 + src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 7 +++ src-tauri/src/main.rs | 55 ++++++++++++++++----- src/app/event-handlers.ts | 4 +- src/settings-main.ts | 87 ++++++++++++++++++---------------- src/styles/settings-window.css | 50 ++++++++++++------- 8 files changed, 166 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index a808b8824..f23129dbd 100644 --- a/README.md +++ b/README.md @@ -499,7 +499,40 @@ If you find World Monitor useful: ## License -GNU Affero General Public License v3.0 (AGPL-3.0) — see [LICENSE](LICENSE) for details. +This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)** — see [LICENSE](LICENSE) for the full text. + +### What This Means + +**You are free to:** + +- **Use** — run World Monitor for any purpose, including commercial use +- **Study** — read, audit, and learn from the source code +- **Modify** — adapt, extend, and build upon the code +- **Distribute** — share copies with anyone + +**Under these conditions:** + +- **Source code disclosure** — if you distribute or modify this software, you **must** make the complete source code available under the same AGPL-3.0 license +- **Network use is distribution** — if you run a modified version as a network service (SaaS, web app, API), you **must** provide the source code to all users who interact with it over the network. This is the key difference from GPL-3.0 — you cannot run a modified version behind a server without sharing the source +- **Same license (copyleft)** — any derivative work must be released under AGPL-3.0. You cannot re-license under a proprietary or more permissive license +- **Attribution** — you must retain all copyright notices, give appropriate credit to the original author, and clearly indicate any changes you made +- **State changes** — modified files must carry prominent notices stating that you changed them, with the date of the change +- **No additional restrictions** — you may not impose any further restrictions on the rights granted by this license (e.g., no DRM, no additional terms) + +**In plain terms:** + +| Use Case | Allowed? | Condition | +|----------|----------|-----------| +| Personal / internal use | Yes | No conditions | +| Self-hosted deployment | Yes | No conditions if unmodified | +| Forking & modifying | Yes | Must share source under AGPL-3.0 | +| Commercial use | Yes | Must share source under AGPL-3.0 | +| Running as a SaaS/web service | Yes | Must share source under AGPL-3.0 | +| Bundling into a proprietary product | No | AGPL-3.0 copyleft prevents this | + +**No warranty** — the software is provided "as is" without warranty of any kind. + +Copyright (C) 2024-2026 Elie Habib. All rights reserved under AGPL-3.0. --- @@ -521,7 +554,7 @@ GNU Affero General Public License v3.0 (AGPL-3.0) — see [LICENSE](LICENSE) for We thank the following researchers for responsibly disclosing security issues: -- **Cody Richard** — Disclosed three security findings covering IPC command exposure via DevTools in production builds, renderer-to-sidecar trust boundary analysis, and the global fetch patch credential injection architecture (2025) +- **Cody Richard** — Disclosed three security findings covering IPC command exposure via DevTools in production builds, renderer-to-sidecar trust boundary analysis, and the global fetch patch credential injection architecture (2026) If you discover a vulnerability, please see our [Security Policy](./SECURITY.md) for responsible disclosure guidelines. diff --git a/settings.html b/settings.html index 06b48aa4b..0909d497e 100644 --- a/settings.html +++ b/settings.html @@ -9,6 +9,7 @@
+
World Monitor Settings diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 991ad067a..c0c556c1a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5422,7 +5422,7 @@ dependencies = [ [[package]] name = "world-monitor" -version = "2.5.23" +version = "2.5.25" dependencies = [ "getrandom 0.2.17", "keyring", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42b64944f..102b7aae4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,13 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "linux- reqwest = { version = "0.12", default-features = false, features = ["native-tls", "json"] } getrandom = "0.2" +[profile.release] +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true +opt-level = "s" + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7bd0c83f4..f4fd4e9b4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -55,11 +55,26 @@ const SUPPORTED_SECRET_KEYS: [&str; 25] = [ "ICAO_API_KEY", ]; -#[derive(Default)] struct LocalApiState { child: Mutex>, token: Mutex>, port: Mutex>, + http_client: reqwest::Client, +} + +impl Default for LocalApiState { + fn default() -> Self { + Self { + child: Mutex::new(None), + token: Mutex::new(None), + port: Mutex::new(None), + http_client: reqwest::Client::builder() + .use_native_tls() + .pool_max_idle_per_host(2) + .build() + .unwrap_or_default(), + } + } } /// In-memory cache for keychain secrets. Populated once at startup to avoid @@ -234,13 +249,14 @@ fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>) } #[tauri::command] -fn get_desktop_runtime_info(state: tauri::State<'_, LocalApiState>) -> DesktopRuntimeInfo { +fn get_desktop_runtime_info(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { + require_trusted_window(webview.label())?; let port = state.port.lock().ok().and_then(|g| *g); - DesktopRuntimeInfo { + Ok(DesktopRuntimeInfo { os: env::consts::OS.to_string(), arch: env::consts::ARCH.to_string(), local_api_port: port, - } + }) } #[tauri::command] @@ -499,7 +515,8 @@ fn open_path_in_shell(path: &Path) -> Result<(), String> { } #[tauri::command] -fn open_url(url: String) -> Result<(), String> { +fn open_url(webview: Webview, url: String) -> Result<(), String> { + require_trusted_window(webview.label())?; let parsed = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?; match parsed.scheme() { @@ -555,9 +572,24 @@ fn close_settings_window(app: AppHandle) -> Result<(), String> { #[tauri::command] async fn open_live_channels_window_command( + webview: Webview, app: AppHandle, base_url: Option, ) -> Result<(), String> { + require_trusted_window(webview.label())?; + if let Some(ref url) = base_url { + if !url.is_empty() { + let parsed = Url::parse(url).map_err(|_| "Invalid base URL".to_string())?; + match parsed.scheme() { + "http" => match parsed.host_str() { + Some("localhost") | Some("127.0.0.1") => {} + _ => return Err("base_url http only allowed for localhost".to_string()), + }, + "https" => {} + _ => return Err("base_url must be http(s)".to_string()), + } + } + } open_live_channels_window(&app, base_url) } @@ -574,7 +606,7 @@ fn close_live_channels_window(app: AppHandle) -> Result<(), String> { /// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking). /// Called from frontend when browser CORS and sidecar Node.js TLS both fail. #[tauri::command] -async fn fetch_polymarket(webview: Webview, path: String, params: String) -> Result { +async fn fetch_polymarket(webview: Webview, state: tauri::State<'_, LocalApiState>, path: String, params: String) -> Result { require_trusted_window(webview.label())?; let allowed = ["events", "markets", "tags"]; let segment = path.trim_start_matches('/'); @@ -582,11 +614,7 @@ async fn fetch_polymarket(webview: Webview, path: String, params: String) -> Res return Err("Invalid Polymarket path".into()); } let url = format!("https://gamma-api.polymarket.com/{}?{}", segment, params); - let client = reqwest::Client::builder() - .use_native_tls() - .build() - .map_err(|e| format!("HTTP client error: {e}"))?; - let resp = client + let resp = state.http_client .get(&url) .header("Accept", "application/json") .timeout(std::time::Duration::from_secs(10)) @@ -612,6 +640,7 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> { let _settings_window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) .title("World Monitor Settings") + .title_bar_style(tauri::TitleBarStyle::Overlay) .inner_size(980.0, 600.0) .min_inner_size(820.0, 480.0) .resizable(true) @@ -649,6 +678,7 @@ fn open_live_channels_window(app: &AppHandle, base_url: Option) -> Resul let _live_channels_window = WebviewWindowBuilder::new(app, "live-channels", url) .title("Channel management - World Monitor") + .title_bar_style(tauri::TitleBarStyle::Overlay) .inner_size(680.0, 760.0) .min_inner_size(520.0, 600.0) .resizable(true) @@ -690,7 +720,8 @@ fn open_youtube_login_window(app: &AppHandle) -> Result<(), String> { } #[tauri::command] -async fn open_youtube_login(app: AppHandle) -> Result<(), String> { +async fn open_youtube_login(webview: Webview, app: AppHandle) -> Result<(), String> { + require_trusted_window(webview.label())?; open_youtube_login_window(&app) } diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts index b1c920a3f..edfc4afba 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -313,10 +313,10 @@ export class EventHandlerManager implements AppModule { trackMapViewChange(regionSelect.value); }); - this.boundResizeHandler = () => { + this.boundResizeHandler = debounce(() => { this.ctx.map?.setIsResizing(false); this.ctx.map?.render(); - }; + }, 150); window.addEventListener('resize', this.boundResizeHandler); this.setupMapResize(); diff --git a/src/settings-main.ts b/src/settings-main.ts index 25140bb10..4cffcd0a4 100644 --- a/src/settings-main.ts +++ b/src/settings-main.ts @@ -216,15 +216,6 @@ function renderOverview(area: HTMLElement): void {
${catCards}
-
- - - -
@@ -268,39 +259,6 @@ function renderOverview(area: HTMLElement): void { } function initOverviewListeners(area: HTMLElement): void { - area.querySelector('#exportSettingsBtn')?.addEventListener('click', () => { - exportSettings(); - }); - - const importInput = area.querySelector('#importSettingsInput'); - area.querySelector('#importSettingsBtn')?.addEventListener('click', () => { - importInput?.click(); - }); - - importInput?.addEventListener('change', async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - try { - const result: ImportResult = await importSettings(file); - setActionStatus(t('components.settings.importSuccess', { count: String(result.keysImported) }), 'ok'); - } catch (err: unknown) { - if (err instanceof DOMException) { - if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { - setActionStatus(t('components.settings.importFailed') + ': storage limit reached', 'error'); - } else if (err.name === 'SecurityError') { - setActionStatus(t('components.settings.importFailed') + ': storage blocked', 'error'); - } else { - setActionStatus(`${t('components.settings.importFailed')}: ${err.message || err.name}`, 'error'); - } - } else if (err instanceof Error && err.message) { - setActionStatus(`${t('components.settings.importFailed')}: ${err.message}`, 'error'); - } else { - setActionStatus(t('components.settings.importFailed'), 'error'); - } - } - importInput.value = ''; - }); - area.querySelector('[data-wm-toggle]')?.addEventListener('click', () => { const input = area.querySelector('[data-wm-key-input]'); if (input) input.type = input.type === 'password' ? 'text' : 'password'; @@ -672,6 +630,18 @@ function renderDebug(area: HTMLElement): void {
+
+

Data Management

+
+ + + +
+

Diagnostics

@@ -700,6 +670,39 @@ function renderDebug(area: HTMLElement): void { void invokeDesktopAction('open_sidecar_log_file', t('modals.settingsWindow.openApiLog')); }); + area.querySelector('#exportSettingsBtn')?.addEventListener('click', () => { + exportSettings(); + }); + + const importInput = area.querySelector('#importSettingsInput'); + area.querySelector('#importSettingsBtn')?.addEventListener('click', () => { + importInput?.click(); + }); + + importInput?.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const result: ImportResult = await importSettings(file); + setActionStatus(t('components.settings.importSuccess', { count: String(result.keysImported) }), 'ok'); + } catch (err: unknown) { + if (err instanceof DOMException) { + if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { + setActionStatus(t('components.settings.importFailed') + ': storage limit reached', 'error'); + } else if (err.name === 'SecurityError') { + setActionStatus(t('components.settings.importFailed') + ': storage blocked', 'error'); + } else { + setActionStatus(`${t('components.settings.importFailed')}: ${err.message || err.name}`, 'error'); + } + } else if (err instanceof Error && err.message) { + setActionStatus(`${t('components.settings.importFailed')}: ${err.message}`, 'error'); + } else { + setActionStatus(t('components.settings.importFailed'), 'error'); + } + } + importInput.value = ''; + }); + initDiagnostics(); } diff --git a/src/styles/settings-window.css b/src/styles/settings-window.css index 7b5b3e1ba..f22851bdb 100644 --- a/src/styles/settings-window.css +++ b/src/styles/settings-window.css @@ -27,6 +27,14 @@ -moz-osx-font-smoothing: grayscale; } +/* ── Desktop titlebar (macOS traffic lights clearance) ── */ +.settings-titlebar { + height: 28px; + background: var(--settings-bg); + flex-shrink: 0; + -webkit-app-region: drag; +} + /* ── Header ── */ .settings-header { display: flex; @@ -256,7 +264,7 @@ .settings-ov-cats { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(3, 1fr); gap: 8px; flex: 1; } @@ -296,12 +304,6 @@ color: var(--settings-text-secondary); } -.settings-ov-actions { - display: flex; - gap: 12px; - margin-top: 16px; -} - .settings-ov-license { max-width: 560px; } @@ -685,6 +687,28 @@ } /* ── Debug section ── */ +.debug-data-section { + border: 1px solid var(--settings-border); + background: var(--settings-surface); + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 16px; +} + +.debug-data-section h3 { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; + color: var(--settings-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.debug-data-actions { + display: flex; + gap: 10px; +} + .debug-actions { display: flex; gap: 10px; @@ -1080,7 +1104,7 @@ tr.diag-err td { color: var(--settings-red); } } .settings-ov-cats { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, 1fr); } .settings-overview { @@ -1095,13 +1119,3 @@ tr.diag-err td { color: var(--settings-red); } } } -/* Data management in overview */ -.settings-ov-actions { - display: flex; - gap: 10px; - margin-top: 16px; -} - -.settings-ov-actions .settings-btn { - flex: 1; -}