Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />

<!-- Primary Meta Tags -->
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 51 additions & 3 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::net::TcpListener;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
Expand All @@ -18,7 +19,7 @@ use serde_json::{Map, Value};
use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, RunEvent, Webview, WebviewUrl, WebviewWindowBuilder, WindowEvent};

const LOCAL_API_PORT: &str = "46123";
const DEFAULT_LOCAL_API_PORT: u16 = 46123;
const KEYRING_SERVICE: &str = "world-monitor";
const LOCAL_API_LOG_FILE: &str = "local-api.log";
const DESKTOP_LOG_FILE: &str = "desktop.log";
Expand Down Expand Up @@ -56,6 +57,7 @@ const SUPPORTED_SECRET_KEYS: [&str; 22] = [
struct LocalApiState {
child: Mutex<Option<Child>>,
token: Mutex<Option<String>>,
port: Mutex<Option<u16>>,
}

/// In-memory cache for keychain secrets. Populated once at startup to avoid
Expand Down Expand Up @@ -179,6 +181,7 @@ impl PersistentCache {
struct DesktopRuntimeInfo {
os: String,
arch: String,
local_api_port: Option<u16>,
}

fn save_vault(cache: &HashMap<String, String>) -> Result<(), String> {
Expand Down Expand Up @@ -219,13 +222,23 @@ fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>)
}

#[tauri::command]
fn get_desktop_runtime_info() -> DesktopRuntimeInfo {
fn get_desktop_runtime_info(state: tauri::State<'_, LocalApiState>) -> DesktopRuntimeInfo {
let port = state.port.lock().ok().and_then(|g| *g);
DesktopRuntimeInfo {
os: env::consts::OS.to_string(),
arch: env::consts::ARCH.to_string(),
local_api_port: port,
}
}

#[tauri::command]
fn get_local_api_port(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result<u16, String> {
require_trusted_window(webview.label())?;
state.port.lock()
.map_err(|_| "Failed to lock port state".to_string())?
.ok_or_else(|| "Port not yet assigned".to_string())
}

#[tauri::command]
fn list_supported_secret_keys() -> Vec<String> {
SUPPORTED_SECRET_KEYS
Expand Down Expand Up @@ -867,6 +880,18 @@ fn resolve_node_binary(app: &AppHandle) -> Option<PathBuf> {
common_locations.into_iter().find(|path| path.is_file())
}

fn probe_available_port(preferred: u16) -> u16 {
if let Ok(listener) = TcpListener::bind(("127.0.0.1", preferred)) {
drop(listener);
return preferred;
}
let listener = TcpListener::bind(("127.0.0.1", 0))
.expect("no ephemeral port available");
let port = listener.local_addr().unwrap().port();
drop(listener);
port
}

fn start_local_api(app: &AppHandle) -> Result<(), String> {
let state = app.state::<LocalApiState>();
let mut slot = state
Expand All @@ -877,6 +902,11 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
return Ok(());
}

// Clear port state for fresh start
if let Ok(mut port_slot) = state.port.lock() {
*port_slot = None;
}

let (script, resource_root) = local_api_paths(app);
if !script.exists() {
return Err(format!(
Expand All @@ -888,6 +918,9 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
"Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string()
})?;

// Probe for available port — fall back to OS-assigned if default is busy
let actual_port = probe_available_port(DEFAULT_LOCAL_API_PORT);

let log_path = sidecar_log_path(app)?;
let log_file = OpenOptions::new()
.create(true)
Expand All @@ -913,6 +946,11 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
"INFO",
&format!("resolved node binary={}", node_binary.display()),
);
append_desktop_log(
app,
"INFO",
&format!("local API sidecar using port {actual_port}"),
);

// Generate a unique token for local API auth (prevents other local processes from accessing sidecar)
let mut token_slot = state
Expand All @@ -939,7 +977,7 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
&format!("node args: script={script_for_node} resource_dir={resource_for_node}"),
);
cmd.arg(&script_for_node)
.env("LOCAL_API_PORT", LOCAL_API_PORT)
.env("LOCAL_API_PORT", actual_port.to_string())
.env("LOCAL_API_RESOURCE_DIR", &resource_for_node)
.env("LOCAL_API_MODE", "tauri-sidecar")
.env("LOCAL_API_TOKEN", &local_api_token)
Expand Down Expand Up @@ -980,6 +1018,12 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
&format!("local API sidecar started pid={}", child.id()),
);
*slot = Some(child);

// Store the actual port after successful spawn
if let Ok(mut port_slot) = state.port.lock() {
*port_slot = Some(actual_port);
}

Ok(())
}

Expand All @@ -991,6 +1035,9 @@ fn stop_local_api(app: &AppHandle) {
append_desktop_log(app, "INFO", "local API sidecar stopped");
}
}
if let Ok(mut port_slot) = state.port.lock() {
*port_slot = None;
}
}
}

Expand Down Expand Up @@ -1087,6 +1134,7 @@ fn main() {
set_secret,
delete_secret,
get_local_api_token,
get_local_api_port,
get_desktop_runtime_info,
read_cache_entry,
write_cache_entry,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
}
},
"bundle": {
Expand Down
5 changes: 3 additions & 2 deletions src/components/LiveNewsPanel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Panel } from './Panel';
import { fetchLiveVideoId } from '@/services/live-news';
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl } from '@/services/runtime';
import { t } from '../services/i18n';
import { loadFromStorage, saveToStorage } from '@/utils';
import { STORAGE_KEYS, SITE_VARIANT } from '@/config';
Expand Down Expand Up @@ -316,7 +316,8 @@ export class LiveNewsPanel extends Panel {
this.boundMessageHandler = (e: MessageEvent) => {
if (e.source !== this.desktopEmbedIframe?.contentWindow) return;
const expected = this.embedOrigin;
if (e.origin !== expected && e.origin !== 'http://127.0.0.1:46123') return;
const localOrigin = getApiBaseUrl();
if (e.origin !== expected && (!localOrigin || e.origin !== localOrigin)) return;
const msg = e.data;
if (!msg || typeof msg !== 'object' || !msg.type) return;
if (msg.type === 'yt-ready') {
Expand Down
4 changes: 2 additions & 2 deletions src/components/ServiceStatusPanel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import { Panel } from './Panel';
import { t } from '@/services/i18n';
import { isDesktopRuntime } from '@/services/runtime';
import { getLocalApiPort, isDesktopRuntime } from '@/services/runtime';
import {
getDesktopReadinessChecks,
getKeyBackedAvailabilitySummary,
Expand Down Expand Up @@ -131,7 +131,7 @@ export class ServiceStatusPanel extends Panel {
);
}

const port = this.localBackend.port ?? 46123;
const port = this.localBackend.port ?? getLocalApiPort();
const remote = this.localBackend.remoteBase ?? 'https://worldmonitor.app';

return h('div', { className: 'service-status-backend' },
Expand Down
14 changes: 9 additions & 5 deletions src/services/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isDesktopRuntime } from './runtime';
import { getApiBaseUrl, isDesktopRuntime } from './runtime';
import { invokeTauri } from './tauri-bridge';

export type RuntimeSecretKey =
Expand Down Expand Up @@ -63,8 +63,12 @@ export interface RuntimeConfig {
}

const TOGGLES_STORAGE_KEY = 'worldmonitor-runtime-feature-toggles';
const SIDECAR_ENV_UPDATE_URL = 'http://127.0.0.1:46123/api/local-env-update';
const SIDECAR_SECRET_VALIDATE_URL = 'http://127.0.0.1:46123/api/local-validate-secret';
function getSidecarEnvUpdateUrl(): string {
return `${getApiBaseUrl()}/api/local-env-update`;
}
function getSidecarSecretValidateUrl(): string {
return `${getApiBaseUrl()}/api/local-validate-secret`;
}

const defaultToggles: Record<RuntimeFeatureId, boolean> = {
aiGroq: true,
Expand Down Expand Up @@ -406,7 +410,7 @@ async function pushSecretToSidecar(key: string, value: string): Promise<void> {
headers.set('Authorization', `Bearer ${token}`);
}

const response = await fetch(SIDECAR_ENV_UPDATE_URL, {
const response = await fetch(getSidecarEnvUpdateUrl(), {
method: 'POST',
headers,
body: JSON.stringify({ key, value: value || null }),
Expand Down Expand Up @@ -445,7 +449,7 @@ export async function verifySecretWithApi(
}

try {
const response = await callSidecarWithAuth(SIDECAR_SECRET_VALIDATE_URL, {
const response = await callSidecarWithAuth(getSidecarSecretValidateUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value: value.trim(), context }),
Expand Down
40 changes: 36 additions & 4 deletions src/services/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,37 @@ const DEFAULT_REMOTE_HOSTS: Record<string, string> = {
world: 'https://worldmonitor.app',
};

const DEFAULT_LOCAL_API_BASE = 'http://127.0.0.1:46123';
const DEFAULT_LOCAL_API_PORT = 46123;
const FORCE_DESKTOP_RUNTIME = import.meta.env.VITE_DESKTOP_RUNTIME === '1';

let _resolvedPort: number | null = null;
let _portPromise: Promise<number> | null = null;

export async function resolveLocalApiPort(): Promise<number> {
if (_resolvedPort !== null) return _resolvedPort;
if (_portPromise) return _portPromise;
_portPromise = (async () => {
try {
const { tryInvokeTauri } = await import('@/services/tauri-bridge');
const port = await tryInvokeTauri<number>('get_local_api_port');
if (port && port > 0) {
_resolvedPort = port;
return port;
}
} catch {
// IPC failed — allow retry on next call
} finally {
_portPromise = null;
}
return DEFAULT_LOCAL_API_PORT;
})();
return _portPromise;
}

export function getLocalApiPort(): number {
return _resolvedPort ?? DEFAULT_LOCAL_API_PORT;
}

function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, '');
}
Expand Down Expand Up @@ -72,7 +100,7 @@ export function getApiBaseUrl(): string {
return normalizeBaseUrl(configuredBaseUrl);
}

return DEFAULT_LOCAL_API_BASE;
return `http://127.0.0.1:${getLocalApiPort()}`;
}

export function getRemoteApiBaseUrl(): string {
Expand Down Expand Up @@ -213,7 +241,6 @@ export function installRuntimeFetchPatch(): void {
}

const nativeFetch = window.fetch.bind(window);
const localBase = getApiBaseUrl();
let localApiToken: string | null = null;
let tokenFetchedAt = 0;

Expand All @@ -229,6 +256,11 @@ export function installRuntimeFetchPatch(): void {
return nativeFetch(input, init);
}

// Resolve dynamic sidecar port on first API call
if (_resolvedPort === null) {
try { await resolveLocalApiPort(); } catch { /* use default */ }
}

const tokenExpired = localApiToken && (Date.now() - tokenFetchedAt > TOKEN_TTL_MS);
if (!localApiToken || tokenExpired) {
try {
Expand All @@ -247,7 +279,7 @@ export function installRuntimeFetchPatch(): void {
}
const localInit = { ...init, headers };

const localUrl = `${localBase}${target}`;
const localUrl = `${getApiBaseUrl()}${target}`;
if (debug) console.log(`[fetch] intercept → ${target}`);
let allowCloudFallback = !isLocalOnlyApiTarget(target);

Expand Down
18 changes: 13 additions & 5 deletions src/settings-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './styles/settings-window.css';
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
import { WorldMonitorTab } from '@/components/WorldMonitorTab';
import { RUNTIME_FEATURES, loadDesktopSecrets } from '@/services/runtime-config';
import { getApiBaseUrl, resolveLocalApiPort } from '@/services/runtime';
import { tryInvokeTauri } from '@/services/tauri-bridge';
import { escapeHtml } from '@/utils/sanitize';
import { initI18n, t } from '@/services/i18n';
Expand Down Expand Up @@ -78,6 +79,11 @@ async function initSettingsWindow(): Promise<void> {
await initI18n();
applyStoredTheme();

// Prime sidecar port before any diagnostics or sidecar calls.
// This sets _resolvedPort in runtime.ts so getApiBaseUrl() returns the
// correct port for all callers (including runtime-config.ts).
try { await resolveLocalApiPort(); } catch { /* use default */ }

requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
Expand Down Expand Up @@ -167,7 +173,9 @@ async function initSettingsWindow(): Promise<void> {
initTabs();
}

const SIDECAR_BASE = 'http://127.0.0.1:46123';
function getSidecarBase(): string {
return getApiBaseUrl() || 'http://127.0.0.1:46123';
}

function initDiagnostics(): void {
const verboseToggle = document.getElementById('verboseApiLog') as HTMLInputElement | null;
Expand All @@ -188,15 +196,15 @@ function initDiagnostics(): void {
async function syncVerboseState(): Promise<void> {
if (!verboseToggle) return;
try {
const res = await fetch(`${SIDECAR_BASE}/api/local-debug-toggle`);
const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`);
const data = await res.json();
verboseToggle.checked = data.verboseMode;
} catch { /* sidecar not running */ }
}

verboseToggle?.addEventListener('change', async () => {
try {
const res = await fetch(`${SIDECAR_BASE}/api/local-debug-toggle`, { method: 'POST' });
const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`, { method: 'POST' });
const data = await res.json();
if (verboseToggle) verboseToggle.checked = data.verboseMode;
setActionStatus(data.verboseMode ? t('modals.settingsWindow.verboseOn') : t('modals.settingsWindow.verboseOff'), 'ok');
Expand All @@ -210,7 +218,7 @@ function initDiagnostics(): void {
async function refreshTrafficLog(): Promise<void> {
if (!trafficLogEl) return;
try {
const res = await fetch(`${SIDECAR_BASE}/api/local-traffic-log`);
const res = await fetch(`${getSidecarBase()}/api/local-traffic-log`);
const data = await res.json();
const entries: Array<{ timestamp: string; method: string; path: string; status: number; durationMs: number }> = data.entries || [];
if (trafficCount) trafficCount.textContent = `(${entries.length})`;
Expand All @@ -236,7 +244,7 @@ function initDiagnostics(): void {

clearBtn?.addEventListener('click', async () => {
try {
await fetch(`${SIDECAR_BASE}/api/local-traffic-log`, { method: 'DELETE' });
await fetch(`${getSidecarBase()}/api/local-traffic-log`, { method: 'DELETE' });
} catch { /* ignore */ }
if (trafficLogEl) trafficLogEl.innerHTML = `<p class="diag-empty">${t('modals.settingsWindow.logCleared')}</p>`;
if (trafficCount) trafficCount.textContent = '(0)';
Expand Down