61cdce9b60
Lint & Test / test (push) Failing after 8s
Adds cross-platform foreground-window tracking and exposes it over REST (/api/foreground) and the existing WebSocket feed. - foreground_service.py: Windows probe via ctypes (HANDLE-correct argtypes to avoid 64-bit handle truncation); macOS via AppKit; Linux via Xlib (Wayland returns unavailable). TTL cache + per-platform fallback. - browser_url_service.py: when foreground is a recognised browser, extract the page title from the window title (browser-name suffix stripped) and surface `is_browser` + `browser_page_title`. Optional UIA-based URL extraction behind MEDIA_SERVER_BROWSER_UIA env flag (off by default — Chromium browsers keep their accessibility tree dormant otherwise). - websocket_manager: poll foreground every 1s inside the existing status loop, broadcast `foreground` on connect and `foreground_update` on change. Diff only on user-visible fields to avoid geometry spam. - WebUI: new editorial card rendered under the monitor list on the Display tab — process name, window title, fullscreen/minimized/monitor chips, browser block when applicable, exe path, PID, started-ago, geometry, platform. 16px inter-section gap matches Settings cadence. - i18n: 25 new keys added to both en.json and ru.json. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
189 lines
7.2 KiB
JavaScript
189 lines
7.2 KiB
JavaScript
// ============================================================
|
||
// Foreground: Currently-focused desktop process card (rendered at
|
||
// the top of the Display tab)
|
||
// ============================================================
|
||
|
||
import { t } from './core.js';
|
||
|
||
let latestForeground = null;
|
||
let agoTickTimer = null;
|
||
|
||
function escapeHtml(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function formatAgo(epoch) {
|
||
if (!epoch) return '';
|
||
const now = Date.now() / 1000;
|
||
const diff = Math.max(0, now - epoch);
|
||
if (diff < 60) {
|
||
return t('foreground.ago.seconds', { n: Math.floor(diff) });
|
||
}
|
||
if (diff < 3600) {
|
||
return t('foreground.ago.minutes', { n: Math.floor(diff / 60) });
|
||
}
|
||
if (diff < 86400) {
|
||
const h = Math.floor(diff / 3600);
|
||
const m = Math.floor((diff % 3600) / 60);
|
||
return t('foreground.ago.hours', { n: h, m: m });
|
||
}
|
||
return t('foreground.ago.days', { n: Math.floor(diff / 86400) });
|
||
}
|
||
|
||
function formatGeometry(g) {
|
||
if (!g) return '—';
|
||
const w = g.width ?? (g.right - g.left);
|
||
const h = g.height ?? (g.bottom - g.top);
|
||
return `${w}×${h} @ (${g.left}, ${g.top})`;
|
||
}
|
||
|
||
function truncatePath(p, max = 64) {
|
||
if (!p) return '';
|
||
if (p.length <= max) return p;
|
||
// Keep the tail (filename) visible — that's the part the user cares about.
|
||
return '…' + p.slice(-(max - 1));
|
||
}
|
||
|
||
function renderEmpty(message, errorMsg) {
|
||
const stage = document.getElementById('foregroundStage');
|
||
if (!stage) return;
|
||
stage.innerHTML = `
|
||
<div class="empty-state-illustration foreground-empty">
|
||
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||
<p>${escapeHtml(message)}</p>
|
||
${errorMsg ? `<p class="foreground-empty-error">${escapeHtml(errorMsg)}</p>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderTile(data) {
|
||
const stage = document.getElementById('foregroundStage');
|
||
if (!stage) return;
|
||
|
||
const procName = data.process_name || '—';
|
||
const winTitle = data.window_title || '';
|
||
const execPath = data.executable_path || '';
|
||
const pid = data.pid ?? '—';
|
||
const startedEpoch = data.started_at;
|
||
const startedAgo = startedEpoch ? formatAgo(startedEpoch) : '—';
|
||
const startedAbs = startedEpoch
|
||
? new Date(startedEpoch * 1000).toLocaleString()
|
||
: '';
|
||
const geom = formatGeometry(data.window_geometry);
|
||
const platform = data.platform || '—';
|
||
const monitorId = data.monitor_id;
|
||
|
||
// Chips: only render ones that apply
|
||
const chips = [];
|
||
if (data.is_fullscreen) {
|
||
chips.push(`<span class="fg-chip fg-chip-accent">${escapeHtml(t('foreground.fullscreen'))}</span>`);
|
||
} else if (!data.is_minimized) {
|
||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.windowed'))}</span>`);
|
||
}
|
||
if (data.is_minimized) {
|
||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.minimized'))}</span>`);
|
||
}
|
||
if (monitorId !== null && monitorId !== undefined) {
|
||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.monitor', { n: monitorId + 1 }))}</span>`);
|
||
}
|
||
if (data.is_browser) {
|
||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.browser'))}</span>`);
|
||
}
|
||
|
||
// Optional browser-only detail rows (page title + URL when available)
|
||
const browserRows = [];
|
||
if (data.is_browser) {
|
||
if (data.browser_page_title) {
|
||
browserRows.push(`
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.page_title'))}</dt>
|
||
<dd title="${escapeHtml(data.browser_page_title)}">${escapeHtml(data.browser_page_title)}</dd>
|
||
</div>
|
||
`);
|
||
}
|
||
if (data.browser_url) {
|
||
browserRows.push(`
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.url'))}</dt>
|
||
<dd title="${escapeHtml(data.browser_url)}"><span class="fg-mono">${escapeHtml(truncatePath(data.browser_url, 80))}</span></dd>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
stage.innerHTML = `
|
||
<article class="foreground-card" data-fullscreen="${data.is_fullscreen ? '1' : '0'}">
|
||
<div class="fg-kicker">
|
||
<span data-i18n="foreground.kicker">Foreground</span>
|
||
</div>
|
||
<h1 class="fg-process" title="${escapeHtml(procName)}">${escapeHtml(procName)}</h1>
|
||
<div class="fg-window-title" title="${escapeHtml(winTitle)}">${escapeHtml(winTitle)}</div>
|
||
|
||
<div class="fg-chips">${chips.join('')}</div>
|
||
|
||
<dl class="fg-details">
|
||
${browserRows.join('')}
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.executable'))}</dt>
|
||
<dd title="${escapeHtml(execPath)}"><span class="fg-mono">${escapeHtml(truncatePath(execPath))}</span></dd>
|
||
</div>
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.pid'))}</dt>
|
||
<dd><span class="fg-mono">${escapeHtml(String(pid))}</span></dd>
|
||
</div>
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.started'))}</dt>
|
||
<dd title="${escapeHtml(startedAbs)}"><span class="fg-ago" data-started="${startedEpoch ?? ''}">${escapeHtml(startedAgo)}</span></dd>
|
||
</div>
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.geometry'))}</dt>
|
||
<dd><span class="fg-mono">${escapeHtml(geom)}</span></dd>
|
||
</div>
|
||
<div class="fg-row">
|
||
<dt>${escapeHtml(t('foreground.platform'))}</dt>
|
||
<dd>${escapeHtml(platform)}</dd>
|
||
</div>
|
||
</dl>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function startAgoTicker() {
|
||
if (agoTickTimer) return;
|
||
agoTickTimer = setInterval(() => {
|
||
const el = document.querySelector('.fg-ago[data-started]');
|
||
if (!el) return;
|
||
const epoch = parseFloat(el.getAttribute('data-started'));
|
||
if (!epoch) return;
|
||
el.textContent = formatAgo(epoch);
|
||
}, 15000);
|
||
}
|
||
|
||
export function updateForegroundUI(data) {
|
||
latestForeground = data;
|
||
|
||
if (!data || data.available === false) {
|
||
const errMsg = data && data.error ? data.error : '';
|
||
renderEmpty(t('foreground.unavailable'), errMsg);
|
||
} else if (!data.process_name && !data.pid) {
|
||
renderEmpty(t('foreground.no_process'), '');
|
||
} else {
|
||
renderTile(data);
|
||
startAgoTicker();
|
||
}
|
||
}
|
||
|
||
export function loadForegroundProcess() {
|
||
// Push-only — just render the cached state. If nothing has arrived
|
||
// yet, leave the loading placeholder visible.
|
||
if (latestForeground !== null) {
|
||
updateForegroundUI(latestForeground);
|
||
}
|
||
}
|