mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
37
README.md
37
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.
|
||||
|
||||
|
||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -5422,7 +5422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "world-monitor"
|
||||
version = "2.5.23"
|
||||
version = "2.5.25"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"keyring",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user