Fix Tauri desktop runtime reliability and settings UX
5
.gitignore
vendored
@@ -9,3 +9,8 @@ dist/
|
||||
.claude/
|
||||
.cursor/
|
||||
.env.vercel-backup
|
||||
.agent/
|
||||
.factory/
|
||||
.windsurf/
|
||||
skills/
|
||||
test-results/
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
28
api/youtube/embed.test.mjs
Normal 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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
10
package.json
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
2
src-tauri/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
/target
|
||||
/.cargo/config.local.toml
|
||||
/Cargo.lock
|
||||
/gen
|
||||
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 105 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
@@ -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})`);
|
||||
});
|
||||
}
|
||||
|
||||
299
src-tauri/sidecar/local-api-server.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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(())
|
||||
|
||||
@@ -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",
|
||||
|
||||
57
src/App.ts
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => `
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
46
src/services/tauri-bridge.ts
Normal 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
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
121
src/styles/settings-window.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||