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.
This commit is contained in:
Elie Habib
2026-03-06 23:47:04 +04:00
committed by GitHub
parent 327cd1bd18
commit 739333aa80
8 changed files with 166 additions and 77 deletions

View File

@@ -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.

View File

@@ -9,6 +9,7 @@
</head>
<body style="background:#1a1c1e;color:#e8eaed;margin:0">
<div class="settings-shell">
<div class="settings-titlebar"></div>
<div class="settings-header">
<svg class="settings-header-icon" viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
<span class="settings-header-title">World Monitor Settings</span>

2
src-tauri/Cargo.lock generated
View File

@@ -5422,7 +5422,7 @@ dependencies = [
[[package]]
name = "world-monitor"
version = "2.5.23"
version = "2.5.25"
dependencies = [
"getrandom 0.2.17",
"keyring",

View File

@@ -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"]

View File

@@ -55,11 +55,26 @@ const SUPPORTED_SECRET_KEYS: [&str; 25] = [
"ICAO_API_KEY",
];
#[derive(Default)]
struct LocalApiState {
child: Mutex<Option<Child>>,
token: Mutex<Option<String>>,
port: Mutex<Option<u16>>,
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<DesktopRuntimeInfo, String> {
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<String>,
) -> 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<String, String> {
async fn fetch_polymarket(webview: Webview, state: tauri::State<'_, LocalApiState>, path: String, params: String) -> Result<String, String> {
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<String>) -> 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)
}

View File

@@ -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();

View File

@@ -216,15 +216,6 @@ function renderOverview(area: HTMLElement): void {
</div>
</div>
<div class="settings-ov-cats">${catCards}</div>
<div class="settings-ov-actions">
<button type="button" class="settings-btn settings-btn-secondary" id="exportSettingsBtn">
${t('components.settings.exportSettings')}
</button>
<button type="button" class="settings-btn settings-btn-secondary" id="importSettingsBtn">
${t('components.settings.importSettings')}
</button>
<input type="file" id="importSettingsInput" accept=".json" style="display: none;" />
</div>
</div>
<div class="settings-ov-license">
@@ -268,39 +259,6 @@ function renderOverview(area: HTMLElement): void {
}
function initOverviewListeners(area: HTMLElement): void {
area.querySelector('#exportSettingsBtn')?.addEventListener('click', () => {
exportSettings();
});
const importInput = area.querySelector<HTMLInputElement>('#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<HTMLInputElement>('[data-wm-key-input]');
if (input) input.type = input.type === 'password' ? 'text' : 'password';
@@ -672,6 +630,18 @@ function renderDebug(area: HTMLElement): void {
<button id="openLogsBtn" type="button">Open Logs Folder</button>
<button id="openSidecarLogBtn" type="button">Open API Log</button>
</div>
<section class="debug-data-section">
<h3>Data Management</h3>
<div class="debug-data-actions">
<button type="button" class="settings-btn settings-btn-secondary" id="exportSettingsBtn">
${t('components.settings.exportSettings')}
</button>
<button type="button" class="settings-btn settings-btn-secondary" id="importSettingsBtn">
${t('components.settings.importSettings')}
</button>
<input type="file" id="importSettingsInput" accept=".json" style="display: none;" />
</div>
</section>
<section class="settings-diagnostics" id="diagnosticsSection">
<header class="diag-header">
<h2>Diagnostics</h2>
@@ -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<HTMLInputElement>('#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();
}

View File

@@ -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;
}