Fix Tauri desktop runtime reliability and settings UX

This commit is contained in:
Elie Habib
2026-02-13 21:46:08 +04:00
parent ac370e9a87
commit ad4e52caee
81 changed files with 1890 additions and 143 deletions

5
.gitignore vendored
View File

@@ -9,3 +9,8 @@ dist/
.claude/
.cursor/
.env.vercel-backup
.agent/
.factory/
.windsurf/
skills/
test-results/

View File

@@ -2,7 +2,12 @@ const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/.*-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor.*\.vercel\.app$/,
/^http:\/\/localhost(:\d+)?$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/tauri\.localhost(:\d+)?$/,
/^https:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^tauri:\/\/localhost$/,
/^asset:\/\/localhost$/,
];
function isAllowedOrigin(origin) {

40
api/_cors.test.mjs Normal file
View File

@@ -0,0 +1,40 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
function makeRequest(origin) {
const headers = new Headers();
if (origin !== null) {
headers.set('origin', origin);
}
return new Request('https://worldmonitor.app/api/test', { headers });
}
test('allows desktop Tauri origins', () => {
const origins = [
'https://tauri.localhost',
'https://abc123.tauri.localhost',
'tauri://localhost',
'asset://localhost',
'http://127.0.0.1:46123',
];
for (const origin of origins) {
const req = makeRequest(origin);
assert.equal(isDisallowedOrigin(req), false, `origin should be allowed: ${origin}`);
const cors = getCorsHeaders(req);
assert.equal(cors['Access-Control-Allow-Origin'], origin);
}
});
test('rejects unrelated external origins', () => {
const req = makeRequest('https://evil.example.com');
assert.equal(isDisallowedOrigin(req), true);
const cors = getCorsHeaders(req);
assert.equal(cors['Access-Control-Allow-Origin'], 'https://worldmonitor.app');
});
test('requests without origin remain allowed', () => {
const req = makeRequest(null);
assert.equal(isDisallowedOrigin(req), false);
});

View File

@@ -78,6 +78,22 @@ function parseChartData(chart, ticker, issuer) {
}
}
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
summary: {
etfCount: 0,
totalVolume: 0,
totalEstFlow: 0,
netDirection: 'UNAVAILABLE',
inflowCount: 0,
outflowCount: 0,
},
etfs: [],
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
@@ -136,9 +152,11 @@ export default async function handler(req) {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=1800` },
});
} catch (err) {
const fallback = cachedResponse || { error: 'Failed to fetch ETF data', timestamp: new Date().toISOString() };
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: cachedResponse ? 200 : 500,
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, s-maxage=60' },
});
}

View File

@@ -67,6 +67,34 @@ function extractAlignedPriceVolume(chart) {
}
}
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
verdict: 'UNKNOWN',
bullishCount: 0,
totalCount: 0,
signals: {
liquidity: { status: 'UNKNOWN', value: null, sparkline: [] },
flowStructure: { status: 'UNKNOWN', btcReturn5: null, qqqReturn5: null },
macroRegime: { status: 'UNKNOWN', qqqRoc20: null, xlpRoc20: null },
technicalTrend: {
status: 'UNKNOWN',
btcPrice: null,
sma50: null,
sma200: null,
vwap30d: null,
mayerMultiple: null,
sparkline: [],
},
hashRate: { status: 'UNKNOWN', change30d: null },
miningCost: { status: 'UNKNOWN' },
fearGreed: { status: 'UNKNOWN', value: null, history: [] },
},
meta: { qqqSparkline: [] },
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
@@ -245,9 +273,11 @@ export default async function handler(req) {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=600` },
});
} catch (err) {
const fallback = cachedResponse || { error: 'Failed to compute signals', timestamp: new Date().toISOString() };
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: cachedResponse ? 200 : 500,
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, s-maxage=60' },
});
}

View File

@@ -8,6 +8,21 @@ let cacheTimestamp = 0;
const DEFAULT_COINS = 'tether,usd-coin,dai,first-digital-usd,ethena-usde';
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
summary: {
totalMarketCap: 0,
totalVolume24h: 0,
coinCount: 0,
depeggedCount: 0,
healthStatus: 'UNAVAILABLE',
},
stablecoins: [],
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
@@ -104,9 +119,11 @@ export default async function handler(req) {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=300` },
});
} catch (err) {
const fallback = cachedResponse || { error: 'Failed to fetch stablecoin data', timestamp: new Date().toISOString() };
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: cachedResponse ? 200 : 500,
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, s-maxage=60' },
});
}

66
api/youtube/embed.js Normal file
View File

@@ -0,0 +1,66 @@
export const config = { runtime: 'edge' };
function parseFlag(value, fallback = '1') {
if (value === '0' || value === '1') return value;
return fallback;
}
function sanitizeVideoId(value) {
if (typeof value !== 'string') return null;
return /^[A-Za-z0-9_-]{11}$/.test(value) ? value : null;
}
export default async function handler(request) {
const url = new URL(request.url);
const videoId = sanitizeVideoId(url.searchParams.get('videoId'));
if (!videoId) {
return new Response('Missing or invalid videoId', {
status: 400,
headers: { 'content-type': 'text/plain; charset=utf-8' },
});
}
const autoplay = parseFlag(url.searchParams.get('autoplay'), '1');
const mute = parseFlag(url.searchParams.get('mute'), '1');
const embedSrc = new URL(`https://www.youtube.com/embed/${videoId}`);
embedSrc.searchParams.set('autoplay', autoplay);
embedSrc.searchParams.set('mute', mute);
embedSrc.searchParams.set('playsinline', '1');
embedSrc.searchParams.set('rel', '0');
embedSrc.searchParams.set('controls', '1');
embedSrc.searchParams.set('enablejsapi', '1');
embedSrc.searchParams.set('origin', 'https://worldmonitor.app');
embedSrc.searchParams.set('widget_referrer', 'https://worldmonitor.app');
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<style>
html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}
iframe{display:block;border:0;width:100%;height:100%}
</style>
</head>
<body>
<iframe
src="${embedSrc.toString()}"
title="YouTube live"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}

View File

@@ -0,0 +1,28 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import handler from './embed.js';
function makeRequest(query = '') {
return new Request(`https://worldmonitor.app/api/youtube/embed${query}`);
}
test('rejects missing or invalid video ids', async () => {
const missing = await handler(makeRequest());
assert.equal(missing.status, 400);
const invalid = await handler(makeRequest('?videoId=bad'));
assert.equal(invalid.status, 400);
});
test('returns embeddable html for valid video id', async () => {
const response = await handler(makeRequest('?videoId=iEpJwprxDdk&autoplay=0&mute=1'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type')?.includes('text/html'), true);
const html = await response.text();
assert.equal(html.includes('https://www.youtube.com/embed/iEpJwprxDdk'), true);
assert.equal(html.includes('autoplay=0'), true);
assert.equal(html.includes('mute=1'), true);
assert.equal(html.includes('origin=https%3A%2F%2Fworldmonitor.app'), true);
assert.equal(html.includes('widget_referrer=https%3A%2F%2Fworldmonitor.app'), true);
});

154
e2e/runtime-fetch.spec.ts Normal file
View File

@@ -0,0 +1,154 @@
import { expect, test } from '@playwright/test';
test.describe('desktop runtime routing guardrails', () => {
test('detectDesktopRuntime covers packaged tauri hosts', async ({ page }) => {
await page.goto('/runtime-harness.html');
const result = await page.evaluate(async () => {
const runtime = await import('/src/services/runtime.ts');
return {
tauriHost: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0',
locationProtocol: 'https:',
locationHost: 'tauri.localhost',
locationOrigin: 'https://tauri.localhost',
}),
tauriScheme: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0',
locationProtocol: 'tauri:',
locationHost: '',
locationOrigin: 'tauri://localhost',
}),
tauriUa: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0 Tauri/2.0',
locationProtocol: 'https:',
locationHost: 'example.com',
locationOrigin: 'https://example.com',
}),
tauriGlobal: runtime.detectDesktopRuntime({
hasTauriGlobals: true,
userAgent: 'Mozilla/5.0',
locationProtocol: 'https:',
locationHost: 'example.com',
locationOrigin: 'https://example.com',
}),
secureLocalhost: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0',
locationProtocol: 'https:',
locationHost: 'localhost',
locationOrigin: 'https://localhost',
}),
insecureLocalhost: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0',
locationProtocol: 'http:',
locationHost: 'localhost:5173',
locationOrigin: 'http://localhost:5173',
}),
webHost: runtime.detectDesktopRuntime({
hasTauriGlobals: false,
userAgent: 'Mozilla/5.0',
locationProtocol: 'https:',
locationHost: 'worldmonitor.app',
locationOrigin: 'https://worldmonitor.app',
}),
};
});
expect(result.tauriHost).toBe(true);
expect(result.tauriScheme).toBe(true);
expect(result.tauriUa).toBe(true);
expect(result.tauriGlobal).toBe(true);
expect(result.secureLocalhost).toBe(true);
expect(result.insecureLocalhost).toBe(false);
expect(result.webHost).toBe(false);
});
test('runtime fetch patch falls back to cloud for local failures', async ({ page }) => {
await page.goto('/runtime-harness.html');
const result = await page.evaluate(async () => {
const runtime = await import('/src/services/runtime.ts');
const globalWindow = window as unknown as Record<string, unknown>;
const originalFetch = window.fetch.bind(window);
const calls: string[] = [];
const responseJson = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
window.fetch = (async (input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
calls.push(url);
if (url.includes('127.0.0.1:46123/api/fred-data')) {
return responseJson({ error: 'missing local api key' }, 500);
}
if (url.includes('worldmonitor.app/api/fred-data')) {
return responseJson({ observations: [{ value: '321.5' }] }, 200);
}
if (url.includes('127.0.0.1:46123/api/stablecoin-markets')) {
throw new Error('ECONNREFUSED');
}
if (url.includes('worldmonitor.app/api/stablecoin-markets')) {
return responseJson({ stablecoins: [{ symbol: 'USDT' }] }, 200);
}
return responseJson({ ok: true }, 200);
}) as typeof window.fetch;
const previousTauri = globalWindow.__TAURI__;
globalWindow.__TAURI__ = { core: { invoke: () => Promise.resolve(null) } };
delete globalWindow.__wmFetchPatched;
try {
runtime.installRuntimeFetchPatch();
const fredResponse = await window.fetch('/api/fred-data?series_id=CPIAUCSL');
const fredBody = await fredResponse.json() as { observations?: Array<{ value: string }> };
const stableResponse = await window.fetch('/api/stablecoin-markets');
const stableBody = await stableResponse.json() as { stablecoins?: Array<{ symbol: string }> };
return {
fredStatus: fredResponse.status,
fredValue: fredBody.observations?.[0]?.value ?? null,
stableStatus: stableResponse.status,
stableSymbol: stableBody.stablecoins?.[0]?.symbol ?? null,
calls,
};
} finally {
window.fetch = originalFetch;
delete globalWindow.__wmFetchPatched;
if (previousTauri === undefined) {
delete globalWindow.__TAURI__;
} else {
globalWindow.__TAURI__ = previousTauri;
}
}
});
expect(result.fredStatus).toBe(200);
expect(result.fredValue).toBe('321.5');
expect(result.stableStatus).toBe(200);
expect(result.stableSymbol).toBe('USDT');
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/api/fred-data'))).toBe(true);
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/fred-data'))).toBe(true);
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/api/stablecoin-markets'))).toBe(true);
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/stablecoin-markets'))).toBe(true);
});
});

View File

@@ -3,7 +3,8 @@
<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 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:;" />
<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'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.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://www.youtube.com https://www.youtube-nocookie.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<!-- Primary Meta Tags -->
<title>World Monitor - Global Situation with AI Insights</title>

View File

@@ -13,16 +13,18 @@
"preview": "vite preview",
"test:e2e:full": "VITE_VARIANT=full playwright test",
"test:e2e:tech": "VITE_VARIANT=tech playwright test",
"test:e2e": "npm run test:e2e:full && npm run test:e2e:tech",
"test:e2e:runtime": "VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts",
"test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech",
"test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs",
"test:e2e:visual:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"",
"test:e2e:visual:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"",
"test:e2e:visual": "npm run test:e2e:visual:full && npm run test:e2e:visual:tech",
"test:e2e:visual:update:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots",
"test:e2e:visual:update:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots",
"test:e2e:visual:update": "npm run test:e2e:visual:update:full && npm run test:e2e:visual:update:tech",
"desktop:dev": "tauri dev",
"desktop:build:full": "VITE_VARIANT=full tauri build",
"desktop:build:tech": "VITE_VARIANT=tech tauri build --config src-tauri/tauri.tech.conf.json",
"desktop:dev": "VITE_DESKTOP_RUNTIME=1 tauri dev",
"desktop:build:full": "VITE_VARIANT=full VITE_DESKTOP_RUNTIME=1 tauri build",
"desktop:build:tech": "VITE_VARIANT=tech VITE_DESKTOP_RUNTIME=1 tauri build --config src-tauri/tauri.tech.conf.json",
"desktop:package:macos:full": "node scripts/desktop-package.mjs --os macos --variant full",
"desktop:package:macos:tech": "node scripts/desktop-package.mjs --os macos --variant tech",
"desktop:package:windows:full": "node scripts/desktop-package.mjs --os windows --variant full",

11
runtime-harness.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Runtime Harness</title>
</head>
<body>
<div id="runtime-harness">runtime harness</div>
</body>
</html>

View File

@@ -37,7 +37,11 @@ if (!validVariants.has(variant)) {
}
const bundles = os === 'macos' ? 'app,dmg' : 'nsis,msi';
const env = { ...process.env, VITE_VARIANT: variant };
const env = {
...process.env,
VITE_VARIANT: variant,
VITE_DESKTOP_RUNTIME: '1',
};
const cliArgs = ['build', '--bundles', bundles];
const tauriBin = path.join('node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri');

26
settings.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>World Monitor Settings</title>
</head>
<body>
<div class="settings-shell">
<header class="settings-header">
<div>
<h1>Settings</h1>
<p>Desktop runtime configuration and local API keys.</p>
</div>
<div class="settings-actions">
<button id="openLogsBtn" type="button">Open Logs Folder</button>
<button id="openSidecarLogBtn" type="button">Open API Log</button>
</div>
</header>
<p id="settingsActionStatus" class="settings-action-status" aria-live="polite"></p>
<main id="settingsApp" class="settings-content"></main>
</div>
<script type="module" src="/src/settings-main.ts"></script>
</body>
</html>

View File

@@ -1,2 +1,4 @@
/target
/.cargo/config.local.toml
/Cargo.lock
/gen

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -5,12 +5,6 @@ import { readdir } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const port = Number(process.env.LOCAL_API_PORT || 46123);
const remoteBase = (process.env.LOCAL_API_REMOTE_BASE || 'https://worldmonitor.app').replace(/\/$/, '');
const resourceDir = process.env.LOCAL_API_RESOURCE_DIR || process.cwd();
const apiDir = path.join(resourceDir, 'api');
const mode = process.env.LOCAL_API_MODE || 'desktop-sidecar';
function json(data, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(data), {
status,
@@ -85,6 +79,8 @@ function matchRoute(routePath, pathname) {
}
async function buildRouteTable(root) {
if (!existsSync(root)) return [];
const files = [];
async function walk(dir) {
@@ -116,10 +112,15 @@ async function readBody(req) {
return chunks.length ? Buffer.concat(chunks) : undefined;
}
function toHeaders(nodeHeaders) {
function toHeaders(nodeHeaders, options = {}) {
const stripOrigin = options.stripOrigin === true;
const headers = new Headers();
Object.entries(nodeHeaders).forEach(([key, value]) => {
if (key.toLowerCase() === 'host') return;
const lowerKey = key.toLowerCase();
if (lowerKey === 'host') return;
if (stripOrigin && (lowerKey === 'origin' || lowerKey === 'referer' || lowerKey.startsWith('sec-fetch-'))) {
return;
}
if (Array.isArray(value)) {
value.forEach(v => headers.append(key, v));
} else if (typeof value === 'string') {
@@ -129,25 +130,13 @@ function toHeaders(nodeHeaders) {
return headers;
}
async function handleServiceStatus() {
return json({
success: true,
timestamp: new Date().toISOString(),
summary: { operational: 2, degraded: 0, outage: 0, unknown: 0 },
services: [
{ id: 'local-api', name: 'Local Desktop API', category: 'dev', status: 'operational', description: `Running on 127.0.0.1:${port}` },
{ id: 'cloud-pass-through', name: 'Cloud pass-through', category: 'cloud', status: 'operational', description: `Fallback target ${remoteBase}` },
],
local: { enabled: true, mode, port, remoteBase },
});
}
async function proxyToCloud(requestUrl, req) {
async function proxyToCloud(requestUrl, req, remoteBase) {
const target = `${remoteBase}${requestUrl.pathname}${requestUrl.search}`;
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req);
return fetch(target, {
method: req.method,
headers: toHeaders(req.headers),
// Strip browser-origin headers for server-to-server parity.
headers: toHeaders(req.headers, { stripOrigin: true }),
body,
});
}
@@ -176,69 +165,184 @@ async function importHandler(modulePath) {
return mod;
}
async function dispatch(requestUrl, req, routes) {
function resolveConfig(options = {}) {
const port = Number(options.port ?? process.env.LOCAL_API_PORT ?? 46123);
const remoteBase = String(options.remoteBase ?? process.env.LOCAL_API_REMOTE_BASE ?? 'https://worldmonitor.app').replace(/\/$/, '');
const resourceDir = String(options.resourceDir ?? process.env.LOCAL_API_RESOURCE_DIR ?? process.cwd());
const apiDir = options.apiDir
? String(options.apiDir)
: [
path.join(resourceDir, 'api'),
path.join(resourceDir, '_up_', 'api'),
].find((candidate) => existsSync(candidate)) ?? path.join(resourceDir, 'api');
const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar');
const logger = options.logger ?? console;
return {
port,
remoteBase,
resourceDir,
apiDir,
mode,
logger,
};
}
function isMainModule() {
if (!process.argv[1]) return false;
return pathToFileURL(process.argv[1]).href === import.meta.url;
}
async function handleLocalServiceStatus(context) {
return json({
success: true,
timestamp: new Date().toISOString(),
summary: { operational: 2, degraded: 0, outage: 0, unknown: 0 },
services: [
{ id: 'local-api', name: 'Local Desktop API', category: 'dev', status: 'operational', description: `Running on 127.0.0.1:${context.port}` },
{ id: 'cloud-pass-through', name: 'Cloud pass-through', category: 'cloud', status: 'operational', description: `Fallback target ${context.remoteBase}` },
],
local: { enabled: true, mode: context.mode, port: context.port, remoteBase: context.remoteBase },
});
}
async function tryCloudFallback(requestUrl, req, context, reason) {
if (reason) {
context.logger.warn('[local-api] local route fallback to cloud', requestUrl.pathname, reason);
}
try {
return await proxyToCloud(requestUrl, req, context.remoteBase);
} catch (error) {
context.logger.error('[local-api] cloud fallback failed', requestUrl.pathname, error);
return null;
}
}
async function dispatch(requestUrl, req, routes, context) {
if (requestUrl.pathname === '/api/service-status') {
return handleServiceStatus();
return handleLocalServiceStatus(context);
}
if (requestUrl.pathname === '/api/local-status') {
return json({ success: true, mode, port, apiDir, remoteBase, routes: routes.length });
return json({
success: true,
mode: context.mode,
port: context.port,
apiDir: context.apiDir,
remoteBase: context.remoteBase,
routes: routes.length,
});
}
const modulePath = pickModule(requestUrl.pathname, routes);
if (!modulePath || !existsSync(modulePath)) {
return proxyToCloud(requestUrl, req);
const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler missing');
if (cloudResponse) return cloudResponse;
return json({ error: 'Local handler missing and cloud fallback unavailable' }, 502);
}
try {
const mod = await importHandler(modulePath);
if (typeof mod.default !== 'function') {
const cloudResponse = await tryCloudFallback(requestUrl, req, context, `invalid handler module ${path.basename(modulePath)}`);
if (cloudResponse) return cloudResponse;
return json({ error: `Invalid handler module: ${path.basename(modulePath)}` }, 500);
}
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req);
const request = new Request(requestUrl.toString(), {
method: req.method,
headers: toHeaders(req.headers),
// Local handler execution does not need browser-origin metadata.
headers: toHeaders(req.headers, { stripOrigin: true }),
body,
});
const response = await mod.default(request);
if (!(response instanceof Response)) {
const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler returned non-Response');
if (cloudResponse) return cloudResponse;
return json({ error: `Handler returned invalid response for ${requestUrl.pathname}` }, 500);
}
// Local handlers can return 4xx/5xx when desktop keys are missing.
// Prefer cloud parity response when available.
if (!response.ok) {
const cloudResponse = await tryCloudFallback(requestUrl, req, context, `local status ${response.status}`);
if (cloudResponse) return cloudResponse;
}
return response;
} catch (error) {
console.error('[local-api] local handler failed, trying cloud fallback', requestUrl.pathname, error);
try {
return await proxyToCloud(requestUrl, req);
} catch {
return json({ error: 'Local handler failed and cloud fallback unavailable' }, 502);
}
const cloudResponse = await tryCloudFallback(requestUrl, req, context, error);
if (cloudResponse) return cloudResponse;
return json({ error: 'Local handler failed and cloud fallback unavailable' }, 502);
}
}
const routes = await buildRouteTable(apiDir);
export async function createLocalApiServer(options = {}) {
const context = resolveConfig(options);
const routes = await buildRouteTable(context.apiDir);
createServer(async (req, res) => {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
const server = createServer(async (req, res) => {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${context.port}`);
if (!requestUrl.pathname.startsWith('/api/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
return;
}
if (!requestUrl.pathname.startsWith('/api/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
return;
}
try {
const response = await dispatch(requestUrl, req, routes, context);
const body = Buffer.from(await response.arrayBuffer());
const headers = Object.fromEntries(response.headers.entries());
res.writeHead(response.status, headers);
res.end(body);
} catch (error) {
context.logger.error('[local-api] fatal', error);
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
return {
context,
routes,
server,
async start() {
await new Promise((resolve, reject) => {
const onListening = () => {
server.off('error', onError);
resolve();
};
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
server.once('listening', onListening);
server.once('error', onError);
server.listen(context.port, '127.0.0.1');
});
const address = server.address();
const boundPort = typeof address === 'object' && address?.port ? address.port : context.port;
context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length})`);
return { port: boundPort };
},
async close() {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
if (isMainModule()) {
try {
const response = await dispatch(requestUrl, req, routes);
const body = Buffer.from(await response.arrayBuffer());
const headers = Object.fromEntries(response.headers.entries());
res.writeHead(response.status, headers);
res.end(body);
const app = await createLocalApiServer();
await app.start();
} catch (error) {
console.error('[local-api] fatal', error);
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
console.error('[local-api] startup failed', error);
process.exit(1);
}
}).listen(port, '127.0.0.1', () => {
console.log(`[local-api] listening on http://127.0.0.1:${port} (apiDir=${apiDir}, routes=${routes.length})`);
});
}

View File

@@ -0,0 +1,299 @@
import { strict as assert } from 'node:assert';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createLocalApiServer } from './local-api-server.mjs';
async function listen(server, host = '127.0.0.1', port = 0) {
await new Promise((resolve, reject) => {
const onListening = () => {
server.off('error', onError);
resolve();
};
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
server.once('listening', onListening);
server.once('error', onError);
server.listen(port, host);
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server address');
}
return address.port;
}
async function setupRemoteServer() {
const hits = [];
const origins = [];
const server = createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
hits.push(url.pathname);
origins.push(req.headers.origin || null);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
source: 'remote',
path: url.pathname,
origin: req.headers.origin || null,
}));
});
const port = await listen(server);
return {
hits,
origins,
remoteBase: `http://127.0.0.1:${port}`,
async close() {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
async function setupApiDir(files) {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-test-'));
const apiDir = path.join(tempRoot, 'api');
await mkdir(apiDir, { recursive: true });
await Promise.all(
Object.entries(files).map(async ([relativePath, source]) => {
const absolute = path.join(apiDir, relativePath);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, source, 'utf8');
})
);
return {
apiDir,
async cleanup() {
await rm(tempRoot, { recursive: true, force: true });
},
};
}
async function setupResourceDirWithUpApi(files) {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-resource-test-'));
const apiDir = path.join(tempRoot, '_up_', 'api');
await mkdir(apiDir, { recursive: true });
await Promise.all(
Object.entries(files).map(async ([relativePath, source]) => {
const absolute = path.join(apiDir, relativePath);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, source, 'utf8');
})
);
return {
resourceDir: tempRoot,
apiDir,
async cleanup() {
await rm(tempRoot, { recursive: true, force: true });
},
};
}
test('falls back to cloud when local handler returns a 500', async () => {
const remote = await setupRemoteServer();
const localApi = await setupApiDir({
'fred-data.js': `
export default async function handler() {
return new Response(JSON.stringify({ source: 'local-error' }), {
status: 500,
headers: { 'content-type': 'application/json' }
});
}
`,
});
const app = await createLocalApiServer({
port: 0,
apiDir: localApi.apiDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/fred-data`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'remote');
assert.equal(remote.hits.includes('/api/fred-data'), true);
} finally {
await app.close();
await localApi.cleanup();
await remote.close();
}
});
test('uses local handler response when local handler succeeds', async () => {
const remote = await setupRemoteServer();
const localApi = await setupApiDir({
'live.js': `
export default async function handler() {
return new Response(JSON.stringify({ source: 'local-ok' }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
`,
});
const app = await createLocalApiServer({
port: 0,
apiDir: localApi.apiDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/live`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'local-ok');
assert.equal(remote.hits.length, 0);
} finally {
await app.close();
await localApi.cleanup();
await remote.close();
}
});
test('falls back to cloud when local route does not exist', async () => {
const remote = await setupRemoteServer();
const localApi = await setupApiDir({});
const app = await createLocalApiServer({
port: 0,
apiDir: localApi.apiDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/not-found`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'remote');
assert.equal(remote.hits.includes('/api/not-found'), true);
} finally {
await app.close();
await localApi.cleanup();
await remote.close();
}
});
test('strips browser origin headers before invoking local handlers', async () => {
const remote = await setupRemoteServer();
const localApi = await setupApiDir({
'origin-check.js': `
export default async function handler(req) {
const origin = req.headers.get('origin');
return new Response(JSON.stringify({
source: 'local',
originPresent: Boolean(origin),
}), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
`,
});
const app = await createLocalApiServer({
port: 0,
apiDir: localApi.apiDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/origin-check`, {
headers: { Origin: 'https://tauri.localhost' },
});
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'local');
assert.equal(body.originPresent, false);
assert.equal(remote.hits.length, 0);
} finally {
await app.close();
await localApi.cleanup();
await remote.close();
}
});
test('strips browser origin headers when proxying to cloud fallback', async () => {
const remote = await setupRemoteServer();
const localApi = await setupApiDir({});
const app = await createLocalApiServer({
port: 0,
apiDir: localApi.apiDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/no-local-handler`, {
headers: { Origin: 'https://tauri.localhost' },
});
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'remote');
assert.equal(body.origin, null);
assert.equal(remote.origins[0], null);
} finally {
await app.close();
await localApi.cleanup();
await remote.close();
}
});
test('resolves packaged tauri resource layout under _up_/api', async () => {
const remote = await setupRemoteServer();
const localResource = await setupResourceDirWithUpApi({
'live.js': `
export default async function handler() {
return new Response(JSON.stringify({ source: 'local-up' }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
`,
});
const app = await createLocalApiServer({
port: 0,
resourceDir: localResource.resourceDir,
remoteBase: remote.remoteBase,
logger: { log() {}, warn() {}, error() {} },
});
const { port } = await app.start();
try {
assert.equal(app.context.apiDir, localResource.apiDir);
assert.equal(app.routes.length, 1);
const response = await fetch(`http://127.0.0.1:${port}/api/live`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.source, 'local-up');
assert.equal(remote.hits.length, 0);
} finally {
await app.close();
await localResource.cleanup();
await remote.close();
}
});

View File

@@ -1,15 +1,25 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::env;
use keyring::Entry;
use serde_json::{Map, Value};
use tauri::{AppHandle, Manager, RunEvent};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder};
const LOCAL_API_PORT: &str = "46123";
const KEYRING_SERVICE: &str = "world-monitor";
const LOCAL_API_LOG_FILE: &str = "local-api.log";
const DESKTOP_LOG_FILE: &str = "desktop.log";
const MENU_FILE_SETTINGS_ID: &str = "file.settings";
const MENU_DEBUG_OPEN_LOGS_ID: &str = "debug.open_logs";
const MENU_DEBUG_OPEN_SIDECAR_LOG_ID: &str = "debug.open_sidecar_log";
const SUPPORTED_SECRET_KEYS: [&str; 13] = [
"GROQ_API_KEY",
"OPENROUTER_API_KEY",
@@ -123,6 +133,180 @@ fn write_cache_entry(app: AppHandle, key: String, value: String) -> Result<(), S
.map_err(|e| format!("Failed to write cache store {}: {e}", path.display()))
}
fn logs_dir_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_log_dir()
.map_err(|e| format!("Failed to resolve app log dir: {e}"))?;
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create app log dir {}: {e}", dir.display()))?;
Ok(dir)
}
fn sidecar_log_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(logs_dir_path(app)?.join(LOCAL_API_LOG_FILE))
}
fn desktop_log_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(logs_dir_path(app)?.join(DESKTOP_LOG_FILE))
}
fn append_desktop_log(app: &AppHandle, level: &str, message: &str) {
let Ok(path) = desktop_log_path(app) else {
return;
};
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else {
return;
};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(file, "[{timestamp}][{level}] {message}");
}
fn open_path_in_shell(path: &Path) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut command = {
let mut cmd = Command::new("open");
cmd.arg(path);
cmd
};
#[cfg(target_os = "windows")]
let mut command = {
let mut cmd = Command::new("explorer");
cmd.arg(path);
cmd
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut command = {
let mut cmd = Command::new("xdg-open");
cmd.arg(path);
cmd
};
command
.spawn()
.map(|_| ())
.map_err(|e| format!("Failed to open {}: {e}", path.display()))
}
fn open_logs_folder_impl(app: &AppHandle) -> Result<PathBuf, String> {
let dir = logs_dir_path(app)?;
open_path_in_shell(&dir)?;
Ok(dir)
}
fn open_sidecar_log_impl(app: &AppHandle) -> Result<PathBuf, String> {
let log_path = sidecar_log_path(app)?;
if !log_path.exists() {
File::create(&log_path)
.map_err(|e| format!("Failed to create sidecar log {}: {e}", log_path.display()))?;
}
open_path_in_shell(&log_path)?;
Ok(log_path)
}
#[tauri::command]
fn open_logs_folder(app: AppHandle) -> Result<String, String> {
open_logs_folder_impl(&app).map(|path| path.display().to_string())
}
#[tauri::command]
fn open_sidecar_log_file(app: AppHandle) -> Result<String, String> {
open_sidecar_log_impl(&app).map(|path| path.display().to_string())
}
#[tauri::command]
fn open_settings_window_command(app: AppHandle) -> Result<(), String> {
open_settings_window(&app)
}
fn open_settings_window(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("settings") {
let _ = window.show();
window
.set_focus()
.map_err(|e| format!("Failed to focus settings window: {e}"))?;
return Ok(());
}
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
.title("World Monitor Settings")
.inner_size(980.0, 760.0)
.min_inner_size(820.0, 620.0)
.resizable(true)
.build()
.map_err(|e| format!("Failed to create settings window: {e}"))?;
Ok(())
}
fn build_app_menu(handle: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let settings_item = MenuItem::with_id(
handle,
MENU_FILE_SETTINGS_ID,
"Settings...",
true,
Some("CmdOrCtrl+,"),
)?;
let separator = PredefinedMenuItem::separator(handle)?;
let quit_item = PredefinedMenuItem::quit(handle, Some("Quit"))?;
let file_menu =
Submenu::with_items(handle, "File", true, &[&settings_item, &separator, &quit_item])?;
let open_logs_item = MenuItem::with_id(
handle,
MENU_DEBUG_OPEN_LOGS_ID,
"Open Logs Folder",
true,
None::<&str>,
)?;
let open_sidecar_log_item = MenuItem::with_id(
handle,
MENU_DEBUG_OPEN_SIDECAR_LOG_ID,
"Open Local API Log",
true,
None::<&str>,
)?;
let debug_menu = Submenu::with_items(
handle,
"Debug",
true,
&[&open_logs_item, &open_sidecar_log_item],
)?;
Menu::with_items(handle, &[&file_menu, &debug_menu])
}
fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
match event.id().as_ref() {
MENU_FILE_SETTINGS_ID => {
if let Err(err) = open_settings_window(app) {
append_desktop_log(app, "ERROR", &format!("settings menu failed: {err}"));
eprintln!("[tauri] settings menu failed: {err}");
}
}
MENU_DEBUG_OPEN_LOGS_ID => {
if let Err(err) = open_logs_folder_impl(app) {
append_desktop_log(app, "ERROR", &format!("open logs folder failed: {err}"));
eprintln!("[tauri] open logs folder failed: {err}");
}
}
MENU_DEBUG_OPEN_SIDECAR_LOG_ID => {
if let Err(err) = open_sidecar_log_impl(app) {
append_desktop_log(app, "ERROR", &format!("open sidecar log failed: {err}"));
eprintln!("[tauri] open sidecar log failed: {err}");
}
}
_ => {}
}
}
fn local_api_paths(app: &AppHandle) -> (PathBuf, PathBuf) {
let resource_dir = app
.path()
@@ -141,12 +325,56 @@ fn local_api_paths(app: &AppHandle) -> (PathBuf, PathBuf) {
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
} else {
resource_dir
let direct_api = resource_dir.join("api");
let lifted_root = resource_dir.join("_up_");
let lifted_api = lifted_root.join("api");
if direct_api.exists() {
resource_dir
} else if lifted_api.exists() {
lifted_root
} else {
resource_dir
}
};
(sidecar_script, api_dir_root)
}
fn resolve_node_binary() -> Option<PathBuf> {
if let Ok(explicit) = env::var("LOCAL_API_NODE_BIN") {
let explicit_path = PathBuf::from(explicit);
if explicit_path.exists() {
return Some(explicit_path);
}
}
let node_name = if cfg!(windows) { "node.exe" } else { "node" };
if let Some(path_var) = env::var_os("PATH") {
for dir in env::split_paths(&path_var) {
let candidate = dir.join(node_name);
if candidate.exists() {
return Some(candidate);
}
}
}
let common_locations = if cfg!(windows) {
vec![
PathBuf::from(r"C:\Program Files\nodejs\node.exe"),
PathBuf::from(r"C:\Program Files (x86)\nodejs\node.exe"),
]
} else {
vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
PathBuf::from("/usr/bin/node"),
PathBuf::from("/opt/local/bin/node"),
]
};
common_locations.into_iter().find(|path| path.exists())
}
fn start_local_api(app: &AppHandle) -> Result<(), String> {
let state = app.state::<LocalApiState>();
let mut slot = state
@@ -164,18 +392,44 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
script.display()
));
}
let node_binary = resolve_node_binary().ok_or_else(|| {
"Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string()
})?;
let mut cmd = Command::new("node");
let log_path = sidecar_log_path(app)?;
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|e| format!("Failed to open local API log {}: {e}", log_path.display()))?;
let log_file_err = log_file
.try_clone()
.map_err(|e| format!("Failed to clone local API log handle: {e}"))?;
append_desktop_log(
app,
"INFO",
&format!(
"starting local API sidecar script={} resource_root={} log={}",
script.display(),
resource_root.display(),
log_path.display()
),
);
append_desktop_log(app, "INFO", &format!("resolved node binary={}", node_binary.display()));
let mut cmd = Command::new(&node_binary);
cmd.arg(&script)
.env("LOCAL_API_PORT", LOCAL_API_PORT)
.env("LOCAL_API_RESOURCE_DIR", resource_root)
.env("LOCAL_API_MODE", "tauri-sidecar")
.stdout(Stdio::null())
.stderr(Stdio::inherit());
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_file_err));
let child = cmd
.spawn()
.map_err(|e| format!("Failed to launch local API: {e}"))?;
append_desktop_log(app, "INFO", &format!("local API sidecar started pid={}", child.id()));
*slot = Some(child);
Ok(())
}
@@ -185,6 +439,7 @@ fn stop_local_api(app: &AppHandle) {
if let Ok(mut slot) = state.child.lock() {
if let Some(mut child) = slot.take() {
let _ = child.kill();
append_desktop_log(app, "INFO", "local API sidecar stopped");
}
}
}
@@ -192,6 +447,8 @@ fn stop_local_api(app: &AppHandle) {
fn main() {
tauri::Builder::default()
.menu(build_app_menu)
.on_menu_event(handle_menu_event)
.manage(LocalApiState::default())
.invoke_handler(tauri::generate_handler![
list_supported_secret_keys,
@@ -199,10 +456,18 @@ fn main() {
set_secret,
delete_secret,
read_cache_entry,
write_cache_entry
write_cache_entry,
open_logs_folder,
open_sidecar_log_file,
open_settings_window_command
])
.setup(|app| {
if let Err(err) = start_local_api(&app.handle()) {
append_desktop_log(
&app.handle(),
"ERROR",
&format!("local API sidecar failed to start: {err}"),
);
eprintln!("[tauri] local API sidecar failed to start: {err}");
}
Ok(())

View File

@@ -23,7 +23,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' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:"
"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' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.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://www.youtube.com https://www.youtube-nocookie.com;"
}
},
"bundle": {
@@ -37,7 +37,13 @@
"category": "Productivity",
"shortDescription": "World Monitor desktop app (supports World and Tech variants)",
"longDescription": "World Monitor desktop app for real-time global intelligence. Build with VITE_VARIANT=tech to package Tech Monitor branding and dataset defaults.",
"icon": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"../api",
"sidecar/local-api-server.mjs",

View File

@@ -86,6 +86,7 @@ import { TECH_COMPANIES } from '@/config/tech-companies';
import { AI_RESEARCH_LABS } from '@/config/ai-research-labs';
import { STARTUP_ECOSYSTEMS } from '@/config/startup-ecosystems';
import { TECH_HQS, ACCELERATORS } from '@/config/tech-geo';
import { isDesktopRuntime } from '@/services/runtime';
import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types';
export class App {
@@ -131,6 +132,7 @@ export class App {
private initialLoadComplete = false;
private criticalBannerEl: HTMLElement | null = null;
private countryIntelModal: CountryIntelModal | null = null;
private readonly isDesktopApp = isDesktopRuntime();
constructor(containerId: string) {
const el = document.getElementById(containerId);
@@ -217,6 +219,18 @@ export class App {
}
}
// Desktop key management panel must always remain accessible in Tauri.
if (this.isDesktopApp) {
const runtimePanel = this.panelSettings['runtime-config'] ?? {
name: 'Desktop Configuration',
enabled: true,
priority: 2,
};
runtimePanel.enabled = true;
this.panelSettings['runtime-config'] = runtimePanel;
saveToStorage(STORAGE_KEYS.panels, this.panelSettings);
}
this.initialUrlState = parseMapUrlState(window.location.search, this.mapLayers);
if (this.initialUrlState.layers) {
// For tech variant, filter out geopolitical layers from URL
@@ -1093,18 +1107,18 @@ export class App {
<div class="header">
<div class="header-left">
<div class="variant-switcher">
<a href="${SITE_VARIANT === 'tech' ? 'https://worldmonitor.app' : '#'}"
class="variant-option ${SITE_VARIANT !== 'tech' ? 'active' : ''}"
<a href="${this.isDesktopApp ? '#' : (SITE_VARIANT === 'tech' ? 'https://worldmonitor.app' : '#')}"
class="variant-option ${SITE_VARIANT !== 'tech' ? 'active' : ''} ${this.isDesktopApp ? 'desktop-locked' : ''}"
data-variant="world"
title="Geopolitical Intelligence">
title="${this.isDesktopApp ? 'Desktop build runs a single bundled variant' : 'Geopolitical Intelligence'}">
<span class="variant-icon">🌍</span>
<span class="variant-label">WORLD</span>
</a>
<span class="variant-divider"></span>
<a href="${SITE_VARIANT === 'tech' ? '#' : 'https://tech.worldmonitor.app'}"
class="variant-option ${SITE_VARIANT === 'tech' ? 'active' : ''}"
<a href="${this.isDesktopApp ? '#' : (SITE_VARIANT === 'tech' ? '#' : 'https://tech.worldmonitor.app')}"
class="variant-option ${SITE_VARIANT === 'tech' ? 'active' : ''} ${this.isDesktopApp ? 'desktop-locked' : ''}"
data-variant="tech"
title="Tech & AI Intelligence">
title="${this.isDesktopApp ? 'Desktop build runs a single bundled variant' : 'Tech & AI Intelligence'}">
<span class="variant-icon">💻</span>
<span class="variant-label">TECH</span>
</a>
@@ -1136,9 +1150,9 @@ export class App {
</div>
<div class="header-right">
<button class="search-btn" id="searchBtn"><kbd>⌘K</kbd> Search</button>
<button class="copy-link-btn" id="copyLinkBtn">Copy Link</button>
${this.isDesktopApp ? '' : '<button class="copy-link-btn" id="copyLinkBtn">Copy Link</button>'}
<span class="time-display" id="timeDisplay">--:--:-- UTC</span>
<button class="fullscreen-btn" id="fullscreenBtn" title="Toggle Fullscreen">⛶</button>
${this.isDesktopApp ? '' : '<button class="fullscreen-btn" id="fullscreenBtn" title="Toggle Fullscreen">⛶</button>'}
<button class="settings-btn" id="settingsBtn">⚙ PANELS</button>
<button class="sources-btn" id="sourcesBtn">📡 SOURCES</button>
</div>
@@ -1562,7 +1576,7 @@ export class App {
const serviceStatusPanel = new ServiceStatusPanel();
this.panels['service-status'] = serviceStatusPanel;
const runtimeConfigPanel = new RuntimeConfigPanel();
const runtimeConfigPanel = new RuntimeConfigPanel({ mode: this.isDesktopApp ? 'alert' : 'full' });
this.panels['runtime-config'] = runtimeConfigPanel;
// Tech Readiness Panel (tech variant only - World Bank tech indicators)
@@ -1607,6 +1621,17 @@ export class App {
panelOrder.unshift('live-news');
}
// Desktop configuration should stay easy to reach in Tauri builds.
if (this.isDesktopApp) {
const runtimeIdx = panelOrder.indexOf('runtime-config');
if (runtimeIdx > 1) {
panelOrder.splice(runtimeIdx, 1);
panelOrder.splice(1, 0, 'runtime-config');
} else if (runtimeIdx === -1) {
panelOrder.splice(1, 0, 'runtime-config');
}
}
panelOrder.forEach((key: string) => {
const panel = this.panels[key];
if (panel) {
@@ -1813,12 +1838,14 @@ export class App {
// Fullscreen toggle
const fullscreenBtn = document.getElementById('fullscreenBtn');
fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
this.boundFullscreenHandler = () => {
fullscreenBtn!.textContent = document.fullscreenElement ? '⛶' : '⛶';
fullscreenBtn!.classList.toggle('active', !!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', this.boundFullscreenHandler);
if (!this.isDesktopApp && fullscreenBtn) {
fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
this.boundFullscreenHandler = () => {
fullscreenBtn.textContent = document.fullscreenElement ? '⛶' : '⛶';
fullscreenBtn.classList.toggle('active', !!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', this.boundFullscreenHandler);
}
// Region selector
const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement;

View File

@@ -24,6 +24,7 @@ interface ETFFlowsResult {
outflowCount: number;
};
etfs: ETFData[];
unavailable?: boolean;
}
function formatVolume(v: number): string {
@@ -90,6 +91,11 @@ export class ETFFlowsPanel extends Panel {
}
const d = this.data;
if (!d.etfs.length) {
this.setContent('<div class="panel-loading-text">ETF data temporarily unavailable</div>');
return;
}
const s = d.summary;
const dirClass = s.netDirection.includes('INFLOW') ? 'flow-inflow' : s.netDirection.includes('OUTFLOW') ? 'flow-outflow' : 'flow-neutral';

View File

@@ -1,5 +1,6 @@
import { Panel } from './Panel';
import { fetchLiveVideoId } from '@/services/live-news';
import { isDesktopRuntime, toRuntimeUrl } from '@/services/runtime';
// YouTube IFrame Player API types
type YouTubePlayer = {
@@ -9,6 +10,7 @@ type YouTubePlayer = {
pauseVideo(): void;
loadVideoById(videoId: string): void;
cueVideoById(videoId: string): void;
getIframe?(): HTMLIFrameElement;
destroy(): void;
};
@@ -16,9 +18,11 @@ type YouTubePlayerConstructor = new (
elementId: string | HTMLElement,
options: {
videoId: string;
host?: string;
playerVars: Record<string, number | string>;
events: {
onReady: () => void;
onError?: (event: { data: number }) => void;
};
},
) => YouTubePlayer;
@@ -89,9 +93,19 @@ export class LiveNewsPanel extends Panel {
private playerElementId: string;
private isPlayerReady = false;
private currentVideoId: string | null = null;
private readonly youtubeOrigin: string | null;
private forceFallbackVideoForNextInit = false;
// Desktop fallback: embed via local HTTP bridge page to avoid YouTube 153
// on tauri:// initial launches where referer/origin can be missing.
private readonly useDesktopEmbedProxy = isDesktopRuntime();
private desktopEmbedIframe: HTMLIFrameElement | null = null;
private desktopEmbedStateKey: string | null = null;
private desktopEmbedRenderToken = 0;
constructor() {
super({ id: 'live-news', title: 'Live News', showCount: false, trackActivity: false });
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
this.playerElementId = `live-news-player-${Date.now()}`;
this.element.classList.add('panel-wide');
this.createLiveButton();
@@ -101,6 +115,30 @@ export class LiveNewsPanel extends Panel {
this.setupIdleDetection();
}
private static resolveYouTubeOrigin(): string | null {
const fallbackOrigin = SITE_VARIANT === 'tech'
? 'https://worldmonitor.app'
: 'https://worldmonitor.app';
try {
const { protocol, origin, host } = window.location;
if (protocol === 'http:' || protocol === 'https:') {
// Desktop webviews commonly run from tauri.localhost which can trigger
// YouTube embed restrictions. Use canonical public origin instead.
if (host === 'tauri.localhost' || host.endsWith('.tauri.localhost')) {
return fallbackOrigin;
}
return origin;
}
if (protocol === 'tauri:' || protocol === 'asset:') {
return fallbackOrigin;
}
} catch {
// Ignore invalid location values.
}
return fallbackOrigin;
}
private setupIdleDetection(): void {
// Suspend idle timer when hidden, resume when visible
this.boundVisibilityHandler = () => {
@@ -134,7 +172,6 @@ export class LiveNewsPanel extends Panel {
this.isPlaying = false;
this.updateLiveIndicator();
}
// Destroy player completely to free memory (iframe consumes ~115 kB/s even when paused)
this.destroyPlayer();
}
@@ -143,15 +180,25 @@ export class LiveNewsPanel extends Panel {
this.player.destroy();
this.player = null;
}
this.desktopEmbedIframe = null;
this.desktopEmbedStateKey = null;
this.desktopEmbedRenderToken += 1;
this.isPlayerReady = false;
this.currentVideoId = null;
// Clear the container to remove the iframe
// Clear the container to remove player/iframe
if (this.playerContainer) {
this.playerContainer.innerHTML = '';
// Recreate the player element for when we resume
this.playerElement = document.createElement('div');
this.playerElement.id = this.playerElementId;
this.playerContainer.appendChild(this.playerElement);
if (!this.useDesktopEmbedProxy) {
// Recreate player element for JS API mode
this.playerElement = document.createElement('div');
this.playerElement.id = this.playerElementId;
this.playerContainer.appendChild(this.playerElement);
} else {
this.playerElement = null;
}
}
}
@@ -159,7 +206,6 @@ export class LiveNewsPanel extends Panel {
if (this.wasPlayingBeforeIdle && !this.isPlaying) {
this.isPlaying = true;
this.updateLiveIndicator();
// Reinitialize the player
void this.initializePlayer();
}
}
@@ -188,7 +234,7 @@ export class LiveNewsPanel extends Panel {
private togglePlayback(): void {
this.isPlaying = !this.isPlaying;
this.wasPlayingBeforeIdle = this.isPlaying; // Track user intent
this.wasPlayingBeforeIdle = this.isPlaying;
this.updateLiveIndicator();
this.syncPlayerState();
}
@@ -237,6 +283,14 @@ export class LiveNewsPanel extends Panel {
this.element.insertBefore(this.channelSwitcher, this.content);
}
private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
const preferStableDesktopFallback = this.useDesktopEmbedProxy && !!channel.fallbackVideoId;
const useFallbackVideo = channel.useFallbackOnly || forceFallback || preferStableDesktopFallback;
const liveVideoId = useFallbackVideo ? null : await fetchLiveVideoId(channel.handle);
channel.videoId = liveVideoId || channel.fallbackVideoId;
channel.isLive = !!liveVideoId;
}
private async switchChannel(channel: LiveChannel): Promise<void> {
if (channel.id === this.activeChannel.id) return;
@@ -250,12 +304,8 @@ export class LiveNewsPanel extends Panel {
}
});
// Fetch live video ID dynamically
const liveVideoId = channel.useFallbackOnly ? null : await fetchLiveVideoId(channel.handle);
channel.videoId = liveVideoId || channel.fallbackVideoId;
channel.isLive = !!liveVideoId;
await this.resolveChannelVideo(channel);
// Update button state
this.channelSwitcher?.querySelectorAll('.live-channel-btn').forEach(btn => {
const btnEl = btn as HTMLElement;
btnEl.classList.remove('loading');
@@ -269,6 +319,17 @@ export class LiveNewsPanel extends Panel {
return;
}
if (this.useDesktopEmbedProxy) {
this.renderDesktopEmbed(true);
return;
}
if (!this.player) {
this.ensurePlayerContainer();
void this.initializePlayer();
return;
}
this.syncPlayerState();
}
@@ -282,25 +343,143 @@ export class LiveNewsPanel extends Panel {
`;
}
private showEmbedError(channel: LiveChannel, errorCode: number): void {
const watchUrl = channel.videoId
? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}`
: `https://www.youtube.com/${channel.handle}`;
this.content.innerHTML = `
<div class="live-offline">
<div class="offline-icon">!</div>
<div class="offline-text">${channel.name} cannot be embedded in this app (YouTube ${errorCode})</div>
<a class="offline-retry" href="${watchUrl}" target="_blank" rel="noopener noreferrer">Open on YouTube</a>
</div>
`;
}
private renderPlayer(): void {
this.ensurePlayerContainer();
void this.initializePlayer();
}
private ensurePlayerContainer(): void {
if (this.playerContainer && this.playerElement) return;
this.content.innerHTML = '';
this.playerContainer = document.createElement('div');
this.playerContainer.className = 'live-news-player';
this.playerElement = document.createElement('div');
this.playerElement.id = this.playerElementId;
this.playerContainer.appendChild(this.playerElement);
if (!this.useDesktopEmbedProxy) {
this.playerElement = document.createElement('div');
this.playerElement.id = this.playerElementId;
this.playerContainer.appendChild(this.playerElement);
} else {
this.playerElement = null;
}
this.content.appendChild(this.playerContainer);
}
private buildDesktopEmbedPath(videoId: string): string {
const params = new URLSearchParams({
videoId,
autoplay: this.isPlaying ? '1' : '0',
mute: this.isMuted ? '1' : '0',
});
return `/api/youtube/embed?${params.toString()}`;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async ensureDesktopEmbedRouteReady(videoId: string): Promise<void> {
const path = toRuntimeUrl(this.buildDesktopEmbedPath(videoId));
const maxAttempts = 8;
let lastError = 'unknown';
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(path, { cache: 'no-store' });
if (response.ok) {
return;
}
lastError = `HTTP ${response.status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : 'network';
}
if (attempt < maxAttempts) {
await this.sleep(120 * attempt);
}
}
throw new Error(`Desktop embed route unavailable at ${path}: ${lastError}`);
}
private renderDesktopEmbed(force = false): void {
if (!this.useDesktopEmbedProxy) return;
void this.renderDesktopEmbedAsync(force);
}
private async renderDesktopEmbedAsync(force = false): Promise<void> {
const videoId = this.activeChannel.videoId;
if (!videoId) {
this.showOfflineMessage(this.activeChannel);
return;
}
const stateKey = `${videoId}|${this.isPlaying ? 1 : 0}|${this.isMuted ? 1 : 0}`;
if (!force && this.desktopEmbedStateKey === stateKey && this.desktopEmbedIframe) {
return;
}
const renderToken = ++this.desktopEmbedRenderToken;
this.desktopEmbedStateKey = stateKey;
this.currentVideoId = videoId;
this.isPlayerReady = true;
if (!this.playerContainer) {
this.ensurePlayerContainer();
}
if (!this.playerContainer) {
return;
}
this.playerContainer.innerHTML = '<div class="panel-loading-text">Loading live stream...</div>';
try {
const embedUrl = toRuntimeUrl(this.buildDesktopEmbedPath(videoId));
await this.ensureDesktopEmbedRouteReady(videoId);
if (renderToken !== this.desktopEmbedRenderToken || !this.playerContainer) {
return;
}
this.playerContainer.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.className = 'live-news-embed-frame';
iframe.src = embedUrl;
iframe.title = `${this.activeChannel.name} live feed`;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = '0';
iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.setAttribute('loading', 'eager');
this.playerContainer.appendChild(iframe);
this.desktopEmbedIframe = iframe;
} catch (error) {
if (renderToken !== this.desktopEmbedRenderToken) {
return;
}
console.warn('[LiveNews] Desktop embed bridge failed', error);
this.showEmbedError(this.activeChannel, 153);
}
}
private static loadYouTubeApi(): Promise<void> {
if (LiveNewsPanel.apiPromise) return LiveNewsPanel.apiPromise;
@@ -345,42 +524,85 @@ export class LiveNewsPanel extends Panel {
}
private async initializePlayer(): Promise<void> {
if (this.player) return;
if (!this.useDesktopEmbedProxy && this.player) return;
// Fetch live video ID for initial channel
const liveVideoId = this.activeChannel.useFallbackOnly ? null : await fetchLiveVideoId(this.activeChannel.handle);
this.activeChannel.videoId = liveVideoId || this.activeChannel.fallbackVideoId;
this.activeChannel.isLive = !!liveVideoId;
const useFallbackVideo = this.activeChannel.useFallbackOnly || this.forceFallbackVideoForNextInit;
this.forceFallbackVideoForNextInit = false;
await this.resolveChannelVideo(this.activeChannel, useFallbackVideo);
if (!this.activeChannel.videoId) {
this.showOfflineMessage(this.activeChannel);
return;
}
if (this.useDesktopEmbedProxy) {
this.renderDesktopEmbed(true);
return;
}
await LiveNewsPanel.loadYouTubeApi();
if (this.player || !this.playerElement) return;
this.player = new window.YT!.Player(this.playerElement, {
host: 'https://www.youtube.com',
videoId: this.activeChannel.videoId,
playerVars: {
autoplay: this.isPlaying ? 1 : 0,
mute: this.isMuted ? 1 : 0,
rel: 0,
playsinline: 1,
origin: window.location.origin,
enablejsapi: 1,
...(this.youtubeOrigin
? {
origin: this.youtubeOrigin,
widget_referrer: this.youtubeOrigin,
}
: {}),
},
events: {
onReady: () => {
this.isPlayerReady = true;
this.currentVideoId = this.activeChannel.videoId || null;
const iframe = this.player?.getIframe?.();
if (iframe) iframe.referrerPolicy = 'strict-origin-when-cross-origin';
this.syncPlayerState();
},
onError: (event) => {
const errorCode = Number(event?.data ?? 0);
// Retry once with known fallback stream.
if (
errorCode === 153 &&
this.activeChannel.fallbackVideoId &&
this.activeChannel.videoId !== this.activeChannel.fallbackVideoId
) {
this.destroyPlayer();
this.forceFallbackVideoForNextInit = true;
this.ensurePlayerContainer();
void this.initializePlayer();
return;
}
// Desktop-specific last resort: use local HTTP embed proxy.
if (errorCode === 153 && isDesktopRuntime()) {
this.destroyPlayer();
this.renderDesktopEmbed(true);
return;
}
this.destroyPlayer();
this.showEmbedError(this.activeChannel, errorCode);
},
},
});
}
private syncPlayerState(): void {
if (this.useDesktopEmbedProxy) {
this.renderDesktopEmbed();
return;
}
if (!this.player || !this.isPlayerReady) return;
const videoId = this.activeChannel.videoId;
@@ -389,7 +611,6 @@ export class LiveNewsPanel extends Panel {
// Handle channel switch
if (this.currentVideoId !== videoId) {
this.currentVideoId = videoId;
// Re-render player container if it was showing offline message
if (!this.playerElement || !document.getElementById(this.playerElementId)) {
this.ensurePlayerContainer();
void this.initializePlayer();
@@ -402,14 +623,12 @@ export class LiveNewsPanel extends Panel {
}
}
// Handle mute state
if (this.isMuted) {
this.player.mute();
} else {
this.player.unMute();
}
// Handle play/pause state
if (this.isPlaying) {
this.player.playVideo();
} else {
@@ -422,28 +641,26 @@ export class LiveNewsPanel extends Panel {
}
public destroy(): void {
// Clear idle timeout
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
// Remove global event listeners
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
document.removeEventListener(event, this.boundIdleResetHandler);
});
// Destroy YouTube player
if (this.player) {
this.player.destroy();
this.player = null;
}
this.desktopEmbedIframe = null;
this.desktopEmbedStateKey = null;
this.isPlayerReady = false;
this.playerContainer = null;
this.playerElement = null;
// Call parent destroy
super.destroy();
}
}

View File

@@ -11,14 +11,21 @@ import {
type RuntimeFeatureDefinition,
type RuntimeSecretKey,
} from '@/services/runtime-config';
import { invokeTauri } from '@/services/tauri-bridge';
import { escapeHtml } from '@/utils/sanitize';
import { isDesktopRuntime } from '@/services/runtime';
interface RuntimeConfigPanelOptions {
mode?: 'full' | 'alert';
}
export class RuntimeConfigPanel extends Panel {
private unsubscribe: (() => void) | null = null;
private readonly mode: 'full' | 'alert';
constructor() {
constructor(options: RuntimeConfigPanelOptions = {}) {
super({ id: 'runtime-config', title: 'Desktop Configuration', showCount: false });
this.mode = options.mode ?? (isDesktopRuntime() ? 'alert' : 'full');
this.unsubscribe = subscribeRuntimeConfig(() => this.render());
this.render();
}
@@ -30,9 +37,45 @@ export class RuntimeConfigPanel extends Panel {
protected render(): void {
const snapshot = getRuntimeConfigSnapshot();
const desktop = isDesktopRuntime();
if (desktop && this.mode === 'alert') {
const totalFeatures = RUNTIME_FEATURES.length;
const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length;
const missingFeatures = Math.max(0, totalFeatures - availableFeatures);
const missingSecrets = Array.from(
new Set(
RUNTIME_FEATURES
.flatMap((feature) => feature.requiredSecrets)
.filter((key) => !getSecretState(key).valid),
),
);
const alertTitle = missingFeatures > 0 ? 'Settings not configured' : 'Desktop settings configured';
const alertClass = missingFeatures > 0 ? 'warn' : 'ok';
const missingPreview = missingSecrets.length > 0
? missingSecrets.slice(0, 4).join(', ')
: 'None';
const missingTail = missingSecrets.length > 4 ? ` +${missingSecrets.length - 4} more` : '';
this.content.innerHTML = `
<section class="runtime-alert runtime-alert-${alertClass}">
<h3>${alertTitle}</h3>
<p>
${availableFeatures}/${totalFeatures} features available · ${Object.keys(snapshot.secrets).length} local secrets configured.
</p>
<p class="runtime-alert-missing">
Missing keys: ${escapeHtml(`${missingPreview}${missingTail}`)}
</p>
<button type="button" class="runtime-open-settings-btn" data-open-settings>
Open Settings
</button>
</section>
`;
this.attachListeners();
return;
}
this.content.innerHTML = `
<div class="runtime-config-summary">
${desktop ? 'Desktop mode' : 'Web mode (read-only, server-managed credentials)'} · ${Object.keys(snapshot.secrets).length} local secrets configured · ${RUNTIME_FEATURES.filter(f => isFeatureAvailable(f.id)).length}/${RUNTIME_FEATURES.length} features available
@@ -83,6 +126,15 @@ export class RuntimeConfigPanel extends Panel {
private attachListeners(): void {
if (!isDesktopRuntime()) return;
if (this.mode === 'alert') {
this.content.querySelector<HTMLButtonElement>('[data-open-settings]')?.addEventListener('click', () => {
void invokeTauri<void>('open_settings_window_command').catch((error) => {
console.warn('[runtime-config] Failed to open settings window', error);
});
});
return;
}
this.content.querySelectorAll<HTMLInputElement>('input[data-toggle]').forEach((input) => {
input.addEventListener('change', () => {
const featureId = input.dataset.toggle as RuntimeFeatureDefinition['id'] | undefined;

View File

@@ -25,6 +25,7 @@ interface StablecoinResult {
healthStatus: string;
};
stablecoins: StablecoinData[];
unavailable?: boolean;
}
function formatLargeNum(v: number): string {
@@ -91,6 +92,11 @@ export class StablecoinPanel extends Panel {
}
const d = this.data;
if (!d.stablecoins.length) {
this.setContent('<div class="panel-loading-text">Stablecoin data temporarily unavailable</div>');
return;
}
const s = d.summary;
const pegRows = d.stablecoins.map(c => `

View File

@@ -1,4 +1,5 @@
import { isDesktopRuntime } from './runtime';
import { invokeTauri } from './tauri-bridge';
type CacheEnvelope<T> = {
key: string;
@@ -8,13 +9,6 @@ type CacheEnvelope<T> = {
const CACHE_PREFIX = 'worldmonitor-persistent-cache:';
async function invokeTauri<T>(command: string, payload?: Record<string, unknown>): Promise<T> {
const tauriWindow = window as unknown as { __TAURI__?: { core?: { invoke?: <U>(cmd: string, args?: Record<string, unknown>) => Promise<U> } } };
const invoke = tauriWindow.__TAURI__?.core?.invoke;
if (!invoke) throw new Error('Tauri invoke bridge unavailable');
return invoke<T>(command, payload);
}
export async function getPersistentCache<T>(key: string): Promise<CacheEnvelope<T> | null> {
if (isDesktopRuntime()) {
try {

View File

@@ -1,4 +1,5 @@
import { isDesktopRuntime } from './runtime';
import { invokeTauri } from './tauri-bridge';
export type RuntimeSecretKey =
| 'GROQ_API_KEY'
@@ -128,13 +129,6 @@ function readEnvSecret(key: RuntimeSecretKey): string {
return typeof envValue === 'string' ? envValue.trim() : '';
}
async function invokeTauri<T>(command: string, payload?: Record<string, unknown>): Promise<T> {
const tauriWindow = window as unknown as { __TAURI__?: { core?: { invoke?: <U>(cmd: string, args?: Record<string, unknown>) => Promise<U> } } };
const invoke = tauriWindow.__TAURI__?.core?.invoke;
if (!invoke) throw new Error('Tauri invoke bridge unavailable');
return invoke<T>(command, payload);
}
function readStoredToggles(): Record<RuntimeFeatureId, boolean> {
try {
const stored = localStorage.getItem(TOGGLES_STORAGE_KEY);

View File

@@ -5,21 +5,61 @@ const DEFAULT_REMOTE_HOSTS: Record<string, string> = {
};
const DEFAULT_LOCAL_API_BASE = 'http://127.0.0.1:46123';
const FORCE_DESKTOP_RUNTIME = import.meta.env.VITE_DESKTOP_RUNTIME === '1';
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, '');
}
type RuntimeProbe = {
hasTauriGlobals: boolean;
userAgent: string;
locationProtocol: string;
locationHost: string;
locationOrigin: string;
};
export function detectDesktopRuntime(probe: RuntimeProbe): boolean {
const tauriInUserAgent = probe.userAgent.includes('Tauri');
const secureLocalhostOrigin = (
probe.locationProtocol === 'https:' && (
probe.locationHost === 'localhost' ||
probe.locationHost.startsWith('localhost:') ||
probe.locationHost === '127.0.0.1' ||
probe.locationHost.startsWith('127.0.0.1:')
)
);
// Tauri production windows can expose tauri-like hosts/schemes without
// always exposing bridge globals at first paint.
const tauriLikeLocation = (
probe.locationProtocol === 'tauri:' ||
probe.locationProtocol === 'asset:' ||
probe.locationHost === 'tauri.localhost' ||
probe.locationHost.endsWith('.tauri.localhost') ||
probe.locationOrigin.startsWith('tauri://') ||
secureLocalhostOrigin
);
return probe.hasTauriGlobals || tauriInUserAgent || tauriLikeLocation;
}
export function isDesktopRuntime(): boolean {
if (FORCE_DESKTOP_RUNTIME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
const hasTauriGlobals = '__TAURI_INTERNALS__' in window || '__TAURI__' in window;
const userAgent = window.navigator?.userAgent ?? '';
const tauriInUserAgent = userAgent.includes('Tauri');
return hasTauriGlobals || tauriInUserAgent;
return detectDesktopRuntime({
hasTauriGlobals: '__TAURI_INTERNALS__' in window || '__TAURI__' in window,
userAgent: window.navigator?.userAgent ?? '',
locationProtocol: window.location?.protocol ?? '',
locationHost: window.location?.host ?? '',
locationOrigin: window.location?.origin ?? '',
});
}
export function getApiBaseUrl(): string {
@@ -81,6 +121,42 @@ function getApiTargetFromRequestInput(input: RequestInfo | URL): string | null {
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchLocalWithStartupRetry(
nativeFetch: typeof window.fetch,
localUrl: string,
init?: RequestInit,
): Promise<Response> {
const maxAttempts = 4;
let lastError: unknown = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await nativeFetch(localUrl, init);
} catch (error) {
lastError = error;
// Preserve caller intent for aborted requests.
if (init?.signal?.aborted) {
throw error;
}
if (attempt === maxAttempts) {
break;
}
await sleep(125 * attempt);
}
}
throw lastError instanceof Error
? lastError
: new Error('Local API unavailable');
}
export function installRuntimeFetchPatch(): void {
if (!isDesktopRuntime() || typeof window === 'undefined' || (window as unknown as Record<string, unknown>).__wmFetchPatched) {
return;
@@ -100,7 +176,23 @@ export function installRuntimeFetchPatch(): void {
const remoteUrl = `${remoteBase}${target}`;
try {
return await nativeFetch(localUrl, init);
const localResponse = await fetchLocalWithStartupRetry(nativeFetch, localUrl, init);
if (localResponse.ok) {
return localResponse;
}
// Desktop local handlers can return 4xx/5xx when API keys are missing.
// Prefer remote parity response when available.
try {
const remoteResponse = await nativeFetch(remoteUrl, init);
if (remoteResponse.ok) {
return remoteResponse;
}
} catch (remoteError) {
console.warn(`[runtime] Remote API fallback failed for ${target}`, remoteError);
}
return localResponse;
} catch (error) {
console.warn(`[runtime] Local API fetch failed for ${target}, falling back to cloud`, error);
return nativeFetch(remoteUrl, init);

View File

@@ -0,0 +1,46 @@
type TauriInvoke = <T>(command: string, payload?: Record<string, unknown>) => Promise<T>;
function resolveInvokeBridge(): TauriInvoke | null {
if (typeof window === 'undefined') {
return null;
}
const tauriWindow = window as unknown as {
__TAURI__?: { core?: { invoke?: TauriInvoke } };
__TAURI_INTERNALS__?: { invoke?: TauriInvoke };
};
const invoke =
tauriWindow.__TAURI__?.core?.invoke ??
tauriWindow.__TAURI_INTERNALS__?.invoke;
return typeof invoke === 'function' ? invoke : null;
}
export function hasTauriInvokeBridge(): boolean {
return resolveInvokeBridge() !== null;
}
export async function invokeTauri<T>(
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
const invoke = resolveInvokeBridge();
if (!invoke) {
throw new Error('Tauri invoke bridge unavailable');
}
return invoke<T>(command, payload);
}
export async function tryInvokeTauri<T>(
command: string,
payload?: Record<string, unknown>,
): Promise<T | null> {
try {
return await invokeTauri<T>(command, payload);
} catch (error) {
console.warn(`[tauri-bridge] Command failed: ${command}`, error);
return null;
}
}

53
src/settings-main.ts Normal file
View File

@@ -0,0 +1,53 @@
import './styles/main.css';
import './styles/settings-window.css';
import { RuntimeConfigPanel } from '@/components/RuntimeConfigPanel';
import { loadDesktopSecrets } from '@/services/runtime-config';
import { tryInvokeTauri } from '@/services/tauri-bridge';
function setActionStatus(message: string, tone: 'ok' | 'error' = 'ok'): void {
const statusEl = document.getElementById('settingsActionStatus');
if (!statusEl) return;
statusEl.textContent = message;
statusEl.classList.remove('ok', 'error');
statusEl.classList.add(tone);
}
async function invokeDesktopAction(command: string, successLabel: string): Promise<void> {
const result = await tryInvokeTauri<string>(command);
if (result) {
setActionStatus(`${successLabel}: ${result}`, 'ok');
return;
}
setActionStatus(`Failed to run ${command}. Check desktop log.`, 'error');
}
async function initSettingsWindow(): Promise<void> {
await loadDesktopSecrets();
const mount = document.getElementById('settingsApp');
if (!mount) return;
const panel = new RuntimeConfigPanel({ mode: 'full' });
const panelElement = panel.getElement();
panelElement.classList.remove('resized', 'span-2', 'span-3', 'span-4');
panelElement.classList.add('settings-runtime-panel');
mount.appendChild(panelElement);
window.addEventListener('beforeunload', () => panel.destroy());
const openLogsBtn = document.getElementById('openLogsBtn');
openLogsBtn?.addEventListener('click', () => {
void invokeDesktopAction('open_logs_folder', 'Opened logs folder');
});
const openSidecarLogBtn = document.getElementById('openSidecarLogBtn');
openSidecarLogBtn?.addEventListener('click', () => {
void invokeDesktopAction('open_sidecar_log_file', 'Opened API log');
});
setActionStatus('Use File -> Settings to configure desktop keys.', 'ok');
}
void initSettingsWindow();

View File

@@ -138,6 +138,11 @@ body.animations-paused *::after {
pointer-events: none;
}
.variant-option.desktop-locked {
opacity: 0.65;
pointer-events: none;
}
.variant-option.active .variant-icon {
filter: grayscale(0%) drop-shadow(0 0 4px var(--green));
}
@@ -11465,6 +11470,53 @@ body.has-critical-banner .panels-grid {
margin-bottom: 10px;
}
.runtime-alert {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 8px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
}
.runtime-alert h3 {
margin: 0 0 8px;
font-size: 16px;
letter-spacing: 0.5px;
}
.runtime-alert p {
margin: 0 0 8px;
font-size: 13px;
color: rgba(255,255,255,0.78);
}
.runtime-alert.runtime-alert-warn {
border-color: rgba(255, 210, 124, 0.45);
}
.runtime-alert.runtime-alert-ok {
border-color: rgba(125, 227, 157, 0.45);
}
.runtime-alert-missing {
color: #ffd27c;
font-size: 12px;
}
.runtime-open-settings-btn {
border: 1px solid rgba(125, 227, 157, 0.45);
background: rgba(125, 227, 157, 0.08);
color: #d9ffe6;
border-radius: 6px;
padding: 8px 12px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.runtime-open-settings-btn:hover {
background: rgba(125, 227, 157, 0.16);
}
.runtime-config-list {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,121 @@
.settings-shell {
height: 100vh;
background: var(--bg);
color: var(--text);
padding: 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
border: 1px solid var(--border);
background: var(--surface);
padding: 10px 12px;
margin-bottom: 8px;
}
.settings-header h1 {
margin: 0;
font-size: 18px;
letter-spacing: 1px;
text-transform: uppercase;
}
.settings-header p {
margin: 6px 0 0;
color: var(--text-dim);
font-size: 12px;
}
.settings-actions {
display: flex;
gap: 8px;
}
.settings-actions button {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font: inherit;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
padding: 8px 10px;
cursor: pointer;
}
.settings-actions button:hover {
border-color: var(--accent);
background: rgba(0, 255, 136, 0.08);
}
.settings-action-status {
margin: 0 0 8px;
min-height: 16px;
font-size: 11px;
color: var(--text-dim);
}
.settings-action-status.ok {
color: #7de39d;
}
.settings-action-status.error {
color: #ff8e8e;
}
.settings-content {
flex: 1;
min-height: 0;
}
.settings-runtime-panel {
cursor: default;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.settings-runtime-panel .panel-resize-handle {
display: none;
}
.settings-runtime-panel .panel-content {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
.settings-runtime-panel .runtime-config-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 10px;
}
.settings-runtime-panel .runtime-feature {
margin: 0;
}
@media (max-width: 860px) {
.settings-header {
flex-direction: column;
align-items: stretch;
}
.settings-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.settings-runtime-panel .runtime-config-list {
grid-template-columns: 1fr;
}
}

View File

@@ -131,6 +131,10 @@ export default defineConfig({
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
settings: resolve(__dirname, 'settings.html'),
},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {