/** * @local/dsh-remote — browser side. * * - Sidebar panel "Devices" (sidebar.panellist + keyed main slot): every linked device * with its sessions grouped by workspace, live running state, "open" (local session → * in place, remote → /r//#/session/), "new session on device". * - Settings section "Remote devices": role, link status, pairing (master: create code; * slave: enter master URL + code), device list with revoke/remove and per-device sync * policy (enabled, allow/deny, opt-in secrets), "sync now". * - Toast when a session on any device finishes (server-sent events). * - When the page itself is served under /r//, a small banner shows which * device you are looking at, with a link back to the local DSH. */ window.__ModuleLoader__.load({ id: '@local/dsh-remote', factory(require) { const React = require('react'); const ReactDOM = require('react-dom'); const h = React.createElement; const { useState, useEffect, useCallback, useMemo, useRef } = React; let P = {}; try { P = require('@deepseek-ai/dsh-client-ui-primitives') || {}; } catch { P = {}; } const PANEL_ID = 'dsh-remote-devices'; // When this UI is itself proxied (/r//), API calls must go to *this* page's // host DSH, i.e. the remote device. Relative URLs already do that. For the device // overview we always want the browser's own DSH, which is the URL root before /r/. const mount = (() => { const m = location.pathname.match(/^(.*?)\/r\/([a-z0-9-]+)\//); return m ? { root: m[1] + '/', device: m[2] } : { root: location.pathname.replace(/[^/]*$/, ''), device: null }; })(); const rootUrl = (p) => mount.root + p; async function api(path, init) { const res = await fetch(rootUrl('dsh-remote/' + path), { ...init, headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) }, credentials: 'same-origin', }); let json; try { json = await res.json(); } catch { json = {}; } if (!res.ok) throw new Error(json.error || res.statusText || String(res.status)); return json; } const post = (path, body) => api(path, { method: 'POST', body: JSON.stringify(body || {}) }); function sessionUrl(device, selfId, sessionId) { const base = device === selfId ? mount.root : `${mount.root}r/${device}/`; return `${base}#/session/${encodeURIComponent(sessionId)}`; } const ago = (t) => { if (!t) return ''; const s = Math.max(0, (Date.now() - t) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; }; const base = (p) => String(p || '').replace(/\/+$/, '').split('/').pop() || p || ''; // ------------------------------------------------------------------ styles const S = { page: { padding: '20px 28px', overflow: 'auto', height: '100%', boxSizing: 'border-box', color: 'var(--color-text, inherit)' }, h1: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' }, sub: { opacity: 0.65, fontSize: 13, margin: '0 0 18px' }, card: { border: '1px solid var(--color-border, rgba(127,127,127,.25))', borderRadius: 10, padding: '12px 14px', margin: '0 0 14px', background: 'var(--color-surface, transparent)' }, devHead: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }, dot: (on) => ({ width: 9, height: 9, borderRadius: 9, background: on ? '#3fb950' : '#8b949e', flex: 'none' }), badge: (tone) => ({ fontSize: 11, padding: '1px 7px', borderRadius: 9, border: '1px solid currentColor', opacity: 0.8, color: tone === 'accent' ? '#58a6ff' : tone === 'warn' ? '#d29922' : tone === 'ok' ? '#3fb950' : 'inherit' }), ws: { fontSize: 12, textTransform: 'uppercase', letterSpacing: '.04em', opacity: 0.6, margin: '10px 0 4px' }, row: { display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, cursor: 'pointer', fontSize: 14 }, title: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, meta: { fontSize: 12, opacity: 0.55, flex: 'none' }, btn: { font: 'inherit', fontSize: 13, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', cursor: 'pointer' }, btnPrimary: { font: 'inherit', fontSize: 13, padding: '5px 12px', borderRadius: 6, border: '1px solid #2f81f7', background: '#2f81f7', color: '#fff', cursor: 'pointer' }, input: { font: 'inherit', fontSize: 13, padding: '5px 8px', borderRadius: 6, border: '1px solid var(--color-border, rgba(127,127,127,.35))', background: 'transparent', color: 'inherit', minWidth: 0 }, err: { color: '#f85149', fontSize: 13 }, code: { fontFamily: 'ui-monospace, monospace', fontSize: 18, letterSpacing: '.08em', padding: '6px 10px', border: '1px dashed currentColor', borderRadius: 6, display: 'inline-block' }, spin: { display: 'inline-block', width: 8, height: 8, borderRadius: 8, background: '#d29922', animation: 'dshr-pulse 1s infinite' }, }; if (!document.getElementById('dshr-style')) { const st = document.createElement('style'); st.id = 'dshr-style'; st.textContent = '@keyframes dshr-pulse{0%,100%{opacity:.3}50%{opacity:1}} .dshr-row:hover{background:var(--color-hover,rgba(127,127,127,.12))}'; document.head.appendChild(st); } // ------------------------------------------------------------------ live store const store = { state: null, sessions: null, error: null, listeners: new Set(), events: new Set(), emit() { for (const l of this.listeners) l(); }, async refresh() { try { const [state, sessions] = await Promise.all([api('state'), api('sessions')]); this.state = state; this.sessions = sessions.devices; this.error = null; } catch (e) { this.error = e.message; } this.emit(); }, }; let es = null; let refreshTimer = null; function startStream() { if (es) return; try { es = new EventSource(rootUrl('dsh-remote/stream')); es.onmessage = (m) => { let ev; try { ev = JSON.parse(m.data); } catch { return; } for (const l of store.events) l(ev); clearTimeout(refreshTimer); refreshTimer = setTimeout(() => store.refresh(), 300); }; es.onerror = () => { /* EventSource reconnects on its own */ }; } catch { es = null; } } function useStore() { const [, force] = useState(0); useEffect(() => { const l = () => force((x) => x + 1); store.listeners.add(l); if (!store.state) store.refresh(); const t = setInterval(() => store.refresh(), 10000); return () => { store.listeners.delete(l); clearInterval(t); }; }, []); return store; } // ------------------------------------------------------------------ Devices panel function DeviceIcon({ size }) { return h('svg', { 'data-dsh-panel-entry': PANEL_ID, viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('rect', { x: 1.5, y: 2.5, width: 9, height: 6.5, rx: 1 }), h('path', { d: 'M4 12h4M6 9v3' }), h('rect', { x: 11.5, y: 5.5, width: 3, height: 8, rx: .8 })); } function SessionRow({ dev, s, selfId }) { return h('a', { className: 'dshr-row', style: { ...S.row, color: 'inherit', textDecoration: 'none' }, href: sessionUrl(dev.device, selfId, s.sessionId), title: `${s.title || s.sessionId}\n${s.cwd || ''}`, }, s.running ? h('span', { style: S.spin, title: 'running' }) : h('span', { style: { width: 8, flex: 'none' } }), h('span', { style: S.title }, s.title || h('i', { style: { opacity: .6 } }, 'untitled')), h('span', { style: S.meta }, ago(s.updatedAt))); } function NewSession({ dev, selfId }) { const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const options = dev.workspaces || []; const [ws, setWs] = useState(options[0]?.id || ''); const go = async () => { setBusy(true); setErr(''); try { const r = await post('new-session', { device: dev.device, workspaceId: ws || undefined }); location.href = sessionUrl(dev.device, selfId, r.sessionId); if (dev.device === selfId) setTimeout(() => location.reload(), 50); } catch (e) { setErr(e.message); setBusy(false); } }; if (!open) return h('button', { style: S.btn, onClick: () => setOpen(true) }, '+ New session'); return h('span', { style: { display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } }, h('select', { style: S.input, value: ws, onChange: (e) => setWs(e.target.value) }, options.length ? options.map((w) => h('option', { key: w.id, value: w.id }, w.title || base(w.path))) : h('option', { value: '' }, '(no workspace)')), h('button', { style: S.btnPrimary, disabled: busy || !options.length, onClick: go }, busy ? 'Creating…' : 'Create'), h('button', { style: S.btn, onClick: () => setOpen(false) }, 'Cancel'), err && h('span', { style: S.err }, err)); } function DevicesPage() { const st = useStore(); const [filter, setFilter] = useState(''); const self = st.state?.self; if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…'); const devices = st.sessions || []; const q = filter.trim().toLowerCase(); const meta = Object.fromEntries((st.state?.devices || []).map((d) => [d.id, d])); return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' }, h('h1', { style: S.h1 }, 'Devices'), h('p', { style: S.sub }, st.state ? `This device: ${self?.name} (${st.state.role})` : '', st.state?.role === 'slave' && st.state.master ? (st.state.master.linked ? ` · linked to ${st.state.master.info?.name || st.state.master.url}` : ` · master unreachable${st.state.master.error ? ': ' + st.state.master.error : ''}`) : ''), st.error && h('p', { style: S.err }, st.error), h('div', { style: { display: 'flex', gap: 8, margin: '0 0 14px' } }, h('input', { style: { ...S.input, flex: 1 }, placeholder: 'Filter sessions…', value: filter, onChange: (e) => setFilter(e.target.value) }), h('button', { style: S.btn, onClick: () => store.refresh() }, 'Refresh')), devices.map((dev) => { const m = meta[dev.device] || {}; const items = (dev.items || []).filter((s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q)); const byWs = new Map(); for (const s of items) { const key = s.cwd || ''; if (!byWs.has(key)) byWs.set(key, []); byWs.get(key).push(s); } const wsTitle = (cwd) => (dev.workspaces || []).find((w) => w.path === cwd)?.title || base(cwd) || 'No workspace'; return h('div', { key: dev.device, style: S.card }, h('div', { style: S.devHead }, h('span', { style: S.dot(!dev.error) }), h('strong', null, dev.name || dev.device), dev.self && h('span', { style: S.badge('accent') }, 'this device'), m.role === 'master' && h('span', { style: S.badge() }, 'master'), m.via && h('span', { style: S.badge() }, 'via master'), h('span', { style: { flex: 1 } }), !dev.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${dev.device}/` }, 'Open DSH ↗'), h(NewSession, { dev, selfId: self?.id })), dev.error && h('div', { style: S.err }, dev.error), !dev.error && !items.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No sessions yet.'), [...byWs.entries()].map(([cwd, list]) => h('div', { key: cwd }, h('div', { style: S.ws, title: cwd }, wsTitle(cwd)), list.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id }))))); })); } // ------------------------------------------------------------------ Settings section function Field({ label, children, hint }) { return h('label', { style: { display: 'grid', gap: 4, margin: '0 0 10px', fontSize: 13 } }, h('span', { style: { opacity: .8 } }, label), children, hint && h('span', { style: { opacity: .55, fontSize: 12 } }, hint)); } const listToText = (l) => (l || []).join(', '); const textToList = (t) => String(t || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean); function DeviceSync({ d, offer, onSaved }) { const [enabled, setEnabled] = useState(d.sync?.enabled !== false); const [allow, setAllow] = useState(listToText(d.sync?.allow)); const [deny, setDeny] = useState(listToText(d.sync?.deny)); const [secrets, setSecrets] = useState(new Set(d.sync?.secrets || [])); const [msg, setMsg] = useState(''); const save = async () => { try { await post(`devices/${d.id}/sync`, { enabled, allow: textToList(allow), deny: textToList(deny), secrets: [...secrets] }); setMsg('Saved; device notified.'); onSaved(); } catch (e) { setMsg(e.message); } }; return h('div', { style: { margin: '8px 0 0 18px', paddingLeft: 10, borderLeft: '2px solid rgba(127,127,127,.25)' } }, h('label', { style: { display: 'flex', gap: 6, alignItems: 'center', fontSize: 13, marginBottom: 8 } }, h('input', { type: 'checkbox', checked: enabled, onChange: (e) => setEnabled(e.target.checked) }), 'Sync plugins + config to this device'), h(Field, { label: 'Only these bundles (empty = all)', hint: 'Exact names or prefix*, comma separated' }, h('input', { style: S.input, value: allow, onChange: (e) => setAllow(e.target.value) })), h(Field, { label: 'Never these bundles' }, h('input', { style: S.input, value: deny, onChange: (e) => setDeny(e.target.value) })), h('div', { style: { fontSize: 13, margin: '0 0 8px' } }, h('div', { style: { opacity: .8, marginBottom: 4 } }, 'Secrets this device may pull (it must also opt in locally)'), offer.length ? offer.map((n) => h('label', { key: n, style: { display: 'inline-flex', gap: 4, marginRight: 12 } }, h('input', { type: 'checkbox', checked: secrets.has(n), onChange: (e) => { const s = new Set(secrets); e.target.checked ? s.add(n) : s.delete(n); setSecrets(s); } }), h('code', null, n))) : h('span', { style: { opacity: .55 } }, 'None offered. Add names to sync.offerSecrets in the dsh-remote config.')), h('button', { style: S.btn, onClick: save }, 'Save sync policy'), ' ', msg && h('span', { style: { fontSize: 12, opacity: .75 } }, msg)); } /** * Auth proxy (edge) credentials for reaching the master through Authentik / * oauth2-proxy / Cloudflare Access / Basic auth. Secrets are sent once and stored by * the host as DSH credential refs; the form never shows them again. */ function EdgeAuthFields({ value, onChange }) { const v = value || { mode: 'none' }; const set = (patch) => onChange({ ...v, ...patch }); const row = (label, el, hint) => h(Field, { label, hint }, el); const input = (key, props = {}) => h('input', { style: S.input, value: v[key] || '', onChange: (e) => set({ [key]: e.target.value }), ...props }); return h('div', { style: { margin: '6px 0 10px', padding: '8px 10px', border: '1px dashed rgba(127,127,127,.35)', borderRadius: 8 } }, row('Auth proxy in front of the master', h('select', { style: S.input, value: v.mode, onChange: (e) => onChange({ mode: e.target.value }) }, h('option', { value: 'none' }, 'None (LAN / proxy exempts /dsh-remote/*)'), h('option', { value: 'basic' }, 'Basic: user + (app) password (Authentik, nginx, Traefik)'), h('option', { value: 'oauth2' }, 'OAuth2 client credentials (Authentik machine-to-machine)'), h('option', { value: 'bearer' }, 'Bearer token'), h('option', { value: 'cookie' }, 'Session cookie (paste, e.g. authentik_proxy_… from a signed-in browser)'), h('option', { value: 'headers' }, 'Custom headers (Cloudflare Access service token, …)')), 'The slave logs in to the proxy itself, keeps the session cookie and replays it on the link.'), v.mode === 'basic' && h(React.Fragment, null, row('Username', input('username', { placeholder: 'e.g. dsh-remote-mac (Authentik service account)' })), row('Password / app password', input('password', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_PASSWORD)' }), 'Authentik: Directory → Tokens and App passwords → create an App password for the user, and enable “Intercept header authentication” on the proxy provider.')), v.mode === 'oauth2' && h(React.Fragment, null, row('Token URL', input('tokenUrl', { placeholder: 'https://auth.rizzy.dev/application/o/token/' })), row('Client ID', input('clientId', { placeholder: 'proxy provider client_id' })), row('Username (Authentik service account)', input('username')), row('App password', input('password', { type: 'password' })), row('Client secret (optional)', input('clientSecret', { type: 'password' }))), v.mode === 'bearer' && row('Token', input('token', { type: 'password', placeholder: '(stored as credential DSH_REMOTE_EDGE_TOKEN)' })), v.mode === 'cookie' && row('Proxy session cookie', h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.cookie || '', placeholder: 'authentik_proxy_a420a2e1=ABC… (or the whole "Cookie:" header)', onChange: (e) => set({ cookie: e.target.value }) }), 'Sign in to the master in a browser, then copy the proxy cookie: DevTools → Application → Cookies → the master site (Authentik: authentik_proxy_). It is stored as credential DSH_REMOTE_EDGE_COOKIE; the proxy\'s refreshed cookies are saved back automatically. The master DSH\'s own dsh-auth-* cookie is ignored.'), v.mode === 'headers' && row('Headers (one per line, "Name: value")', h('textarea', { style: { ...S.input, minHeight: 60, fontFamily: 'ui-monospace,monospace' }, value: v.headersText || '', placeholder: 'CF-Access-Client-Id: xxx.access\nCF-Access-Client-Secret: yyy', onChange: (e) => set({ headersText: e.target.value }) }))); } function edgeSpec(v) { if (!v || v.mode === 'none') return { mode: 'none' }; if (v.mode === 'headers') { const headers = {}; for (const line of String(v.headersText || '').split('\n')) { const at = line.indexOf(':'); if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim(); } return { mode: 'headers', headers }; } const { headersText, ...rest } = v; return rest; } function EdgeAuthCard({ s }) { const [edit, setEdit] = useState(null); const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); const run = async (body) => { setBusy(true); try { setInfo(body ? await post('edge-auth', body) : await api('edge-auth')); if (body) setEdit(null); } catch (e) { setInfo({ error: e.message }); } setBusy(false); store.refresh(); }; if (s.role !== 'slave' || !s.master?.url) return null; const cur = info?.edgeAuth || s.master.edgeAuth || { mode: 'none' }; return h('div', { style: S.card }, h('strong', null, 'Auth proxy (edge) login'), h('div', { style: { fontSize: 13, margin: '4px 0 8px' } }, 'Mode: ', h('code', null, cur.mode || 'none'), cur.username ? h('span', null, ' · user ', h('code', null, cur.username)) : null, cur.tokenValidUntil ? ` · token valid until ${new Date(cur.tokenValidUntil).toLocaleTimeString()}` : '', cur.cookies?.length ? ` · proxy session cookie: ${cur.cookies.join(', ')}` : ''), info?.probe && h('div', { style: { fontSize: 13, margin: '0 0 8px', color: info.probe.reachedDsh ? '#3fb950' : '#f85149' } }, info.probe.reachedDsh ? '✓ The proxy lets this device through to the master DSH.' : `✗ ${info.probe.error}${info.probe.hint ? ` (${info.probe.hint})` : ''}`), info?.error && h('div', { style: S.err }, info.error), edit ? h('div', null, h(EdgeAuthFields, { value: edit, onChange: setEdit }), h('button', { style: S.btnPrimary, disabled: busy, onClick: () => run({ edgeAuth: edgeSpec(edit) }) }, busy ? 'Saving…' : 'Save & reconnect'), ' ', h('button', { style: S.btn, onClick: () => setEdit(null) }, 'Cancel')) : h('span', null, h('button', { style: S.btn, disabled: busy, onClick: () => run(null) }, busy ? 'Testing…' : 'Test'), ' ', h('button', { style: S.btn, onClick: () => setEdit({ mode: cur.mode || 'none', username: cur.username, tokenUrl: cur.tokenUrl, clientId: cur.clientId }) }, 'Change'))); } function SettingsPanel() { const st = useStore(); const [code, setCode] = useState(null); const [masterUrl, setMasterUrl] = useState(''); const [pairCode, setPairCode] = useState(''); const [edge, setEdge] = useState({ mode: 'none' }); const [msg, setMsg] = useState(''); const [busy, setBusy] = useState(false); const [open, setOpen] = useState({}); const [syncRes, setSyncRes] = useState(null); const s = st.state; if (!s) return h('div', { style: { padding: 4 } }, st.error ? h('span', { style: S.err }, st.error) : 'Loading…'); const act = async (fn) => { setBusy(true); setMsg(''); try { await fn(); } catch (e) { setMsg(e.message); } setBusy(false); store.refresh(); }; const header = h('div', { style: S.card }, h('div', { style: S.devHead }, h('strong', null, s.self.name), h('span', { style: S.badge('accent') }, s.role), h('code', { style: { opacity: .6, fontSize: 12 } }, s.self.id)), s.role === 'master' && h('div', { style: { fontSize: 13, opacity: .8 } }, 'Slaves pair with this URL: ', h('code', null, s.publicUrl || s.lanUrl || location.origin + mount.root.replace(/\/$/, '')), s.publicUrl && s.lanUrl ? h('span', null, ' (LAN: ', h('code', null, s.lanUrl), ')') : null), s.role === 'slave' && h('div', { style: { fontSize: 13 } }, h('span', { style: S.dot(s.master?.linked) }), ' ', s.master?.linked ? `Linked to ${s.master.info?.name} · ${s.master.url}` : `Not linked (${s.master?.url || 'no master'})${s.master?.error ? ': ' + s.master.error : ''}`)); const pairing = s.role === 'master' ? h('div', { style: S.card }, h('strong', null, 'Pair a new device'), h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 10px' } }, 'Create a one-time code (valid 15 minutes), then enter it in Settings → Remote devices on the new machine.'), code ? h('div', null, h('span', { style: S.code }, code.code), h('div', { style: { fontSize: 12, opacity: .6, marginTop: 4 } }, `expires ${new Date(code.expiresAt).toLocaleTimeString()}`)) : h('button', { style: S.btnPrimary, disabled: busy, onClick: () => act(async () => setCode(await post('pairing', {}))) }, 'Create pairing code')) : h('div', { style: S.card }, h('strong', null, s.master?.url ? 'Re-pair with a master' : 'Pair with a master'), h(Field, { label: 'Master URL', hint: 'e.g. https://dsh.rizzy.dev or http://192.168.0.111:3080' }, h('input', { style: S.input, value: masterUrl || window.__dshrMasterOrigin || '', placeholder: 'https://…', onChange: (e) => setMasterUrl(e.target.value) })), h(Field, { label: 'Pairing code' }, h('input', { style: { ...S.input, fontFamily: 'ui-monospace,monospace' }, value: pairCode, placeholder: 'ABCD-EFGH-…', onChange: (e) => setPairCode(e.target.value.toUpperCase()) })), h(EdgeAuthFields, { value: edge, onChange: setEdge }), h('button', { style: S.btnPrimary, disabled: busy || !(masterUrl || window.__dshrMasterOrigin) || !pairCode, onClick: () => act(async () => { const r = await post('pair', { masterUrl: masterUrl || window.__dshrMasterOrigin, code: pairCode, edgeAuth: edgeSpec(edge) }); setMsg(r.restartRequired ? 'Paired. Restart DSH on this machine to switch it to slave mode.' : `Paired with ${r.master?.name}.`); }) }, 'Pair'), s.master?.url && h('span', null, ' ', h('button', { style: S.btn, disabled: busy, onClick: () => act(() => post('unpair', {})) }, 'Unpair'))); const devices = s.role === 'master' && h('div', { style: S.card }, h('strong', null, 'Paired devices'), (s.devices || []).filter((d) => !d.self).length === 0 && h('div', { style: { fontSize: 13, opacity: .6, marginTop: 6 } }, 'No devices yet.'), (s.devices || []).filter((d) => !d.self).map((d) => h('div', { key: d.id, style: { padding: '8px 0', borderTop: '1px solid rgba(127,127,127,.15)' } }, h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 14 } }, h('span', { style: S.dot(d.online) }), h('strong', null, d.name), h('code', { style: { fontSize: 12, opacity: .6 } }, d.id), d.revoked && h('span', { style: S.badge('warn') }, 'revoked'), h('span', { style: { fontSize: 12, opacity: .6 } }, d.online ? `online since ${ago(d.connectedAt)}` : d.lastSeenAt ? `seen ${ago(d.lastSeenAt)}` : 'never connected'), h('span', { style: { flex: 1 } }), h('button', { style: S.btn, onClick: () => setOpen({ ...open, [d.id]: !open[d.id] }) }, open[d.id] ? 'Hide sync' : 'Sync policy'), !d.revoked && h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Revoke ${d.name}? It will be disconnected and must re-pair.`) && act(() => post(`devices/${d.id}/revoke`, {})) }, 'Revoke'), h('button', { style: S.btn, disabled: busy, onClick: () => confirm(`Remove ${d.name} completely?`) && act(() => post(`devices/${d.id}/remove`, {})) }, 'Remove')), open[d.id] && h(DeviceSync, { d, offer: s.sync?.offerSecrets || [], onSaved: () => store.refresh() })))); const syncCard = h('div', { style: S.card }, h('strong', null, 'Plugin + config sync'), s.role === 'master' ? h('p', { style: { fontSize: 13, opacity: .75, margin: '4px 0 8px' } }, 'Slaves pull this machine\'s bundles (registry + local link: plugins) and its cordis.patch.yml (minus machine-local entries) into a managed layer that their own config overrides.') : h('div', { style: { fontSize: 13, margin: '4px 0 8px' } }, h('div', null, `Auto sync: ${s.sync?.auto ? 'on' : 'off'} · allow: ${listToText(s.sync?.allow) || 'all'} · deny: ${listToText(s.sync?.deny) || 'none'}`), h('div', null, 'Accepted secrets: ', listToText(s.sync?.secrets) || h('span', { style: { opacity: .6 } }, 'none (set sync.secrets in this device\'s dsh-remote config)')), s.lastSync && h('div', { style: { marginTop: 6, opacity: .8 } }, `Last sync ${ago(s.lastSync.at)}: ${s.lastSync.installed?.length || 0} installed, ${s.lastSync.updated?.length || 0} updated, ${s.lastSync.failed?.length || 0} failed, ${s.lastSync.patchEntries || 0} config entries`, (s.lastSync.failed || []).map((f, i) => h('div', { key: i, style: S.err }, f)))), h('button', { style: S.btn, disabled: busy, onClick: () => act(async () => { const r = await post('sync', {}); setSyncRes(r); if (r.restartRequired) setMsg('Synced. Some plugins changed: restart DSH on this device to load them.'); }) }, s.role === 'master' ? 'Push sync to all devices' : 'Sync now'), syncRes && s.role === 'master' && h('span', { style: { fontSize: 12, opacity: .7 } }, ` notified ${syncRes.pushed} device(s)`)); return h('div', { 'data-dsh-plugin': 'dsh-remote' }, h(NodeCard, { s }), header, pairing, h(EdgeAuthCard, { s }), devices, syncCard, msg && h('div', { style: { ...S.card, fontSize: 13 } }, msg)); } // ------------------------------------------------------------------ toasts function ToastHost() { const [items, setItems] = useState([]); useEffect(() => { const l = (ev) => { if (ev.type !== 'session-finished') return; const id = `${ev.device}:${ev.sessionId}:${ev.at}`; setItems((xs) => [...xs.slice(-2), { id, ev }]); }; store.events.add(l); return () => store.events.delete(l); }, []); return h(React.Fragment, null, items.map(({ id, ev }) => { const text = `${ev.deviceName}: “${ev.title || 'session'}” finished`; const done = () => setItems((xs) => xs.filter((x) => x.id !== id)); const actions = [{ label: 'Open', prefix: ' · ', onClick: () => { location.href = sessionUrl(ev.device, store.state?.self?.id, ev.sessionId); done(); } }]; if (P.Toast) return h(P.Toast, { key: id, text, tone: 'success', holdMs: 6000, actions, onDone: done }); return h('div', { key: id, onClick: actions[0].onClick, style: { position: 'fixed', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, background: '#1f6feb', color: '#fff', padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 14 } }, text); })); } function mountToasts() { const el = document.createElement('div'); el.id = 'dshr-toasts'; document.body.appendChild(el); const root = ReactDOM.createRoot ? ReactDOM.createRoot(el) : null; if (root) root.render(h(ToastHost)); else ReactDOM.render(h(ToastHost), el); return () => { try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(el); } catch { /* ignore */ } el.remove(); }; } // ------------------------------------------------------------------ auth-proxy sign-in popup // When the slave's link meets a non-DSH answer from the master (a login wall) and "auth // middleware" is on, open the master's /dsh-remote/signin through the proxy in a popup. The // user signs in to the proxy there; the page hands the proxy session cookie back with // postMessage (after an explicit click) and we store it and retry the link. const signIn = { win: null, lastAt: 0 }; async function startSignIn(masterUrl, { force = false } = {}) { if (!masterUrl) return { ok: false, error: 'no master origin' }; if (signIn.win && !signIn.win.closed && !force) { signIn.win.focus(); return { ok: true, pending: true }; } const url = `${masterUrl.replace(/\/+$/, '')}/dsh-remote/signin?origin=${encodeURIComponent(location.origin)}`; const win = window.open(url, 'dshr-signin', 'popup=yes,width=520,height=720'); if (!win) return { ok: false, blocked: true, url }; signIn.win = win; signIn.lastAt = Date.now(); return { ok: true, url }; } window.addEventListener('message', async (ev) => { const d = ev.data; if (!d || d.type !== 'dsh-remote-signin' || typeof d.cookie !== 'string') return; // Only accept from the master we are paired with. const master = store.state?.master?.url; let ok = false; try { ok = !!master && new URL(master).origin === ev.origin; } catch { ok = false; } if (!ok) return; try { await post('edge-auth', { edgeAuth: { mode: 'cookie', cookie: d.cookie } }); await post('retry-link', {}); } catch (e) { console.warn('[dsh-remote] sign-in hand-over failed:', e.message); } store.refresh(); }); // Auto-open once per distinct auth-required event, when the setting is on. let lastPrompted = 0; store.events.add((ev) => { if (ev.type !== 'auth-required' || mount.device) return; if (!ev.at || ev.at === lastPrompted || Date.now() - signIn.lastAt < 30000) return; lastPrompted = ev.at; showSignInBanner(ev); }); let bannerEl = null; function showSignInBanner(ev) { if (bannerEl) return; bannerEl = document.createElement('div'); Object.assign(bannerEl.style, { position: 'fixed', top: '14px', left: '50%', transform: 'translateX(-50%)', zIndex: 10000, background: '#1f2937', color: '#fff', padding: '10px 14px', borderRadius: '10px', fontSize: '13px', fontFamily: 'system-ui, sans-serif', boxShadow: '0 6px 24px rgba(0,0,0,.35)', display: 'flex', gap: '10px', alignItems: 'center', maxWidth: '90vw' }); const span = document.createElement('span'); span.textContent = 'The auth proxy in front of the master needs you to sign in.'; const go = document.createElement('button'); go.textContent = 'Sign in'; Object.assign(go.style, { font: 'inherit', padding: '4px 12px', borderRadius: '6px', border: 0, background: '#2f81f7', color: '#fff', cursor: 'pointer' }); const no = document.createElement('button'); no.textContent = 'Later'; Object.assign(no.style, { font: 'inherit', padding: '4px 10px', borderRadius: '6px', border: '1px solid #555', background: 'none', color: '#ddd', cursor: 'pointer' }); const close = () => { bannerEl?.remove(); bannerEl = null; }; // window.open from a click (not from the event) so popup blockers allow it. go.onclick = async () => { close(); const r = await startSignIn(ev.masterUrl, { force: true }); if (r.blocked) alert(`Allow popups for this site, or open ${r.url} manually.`); }; no.onclick = close; bannerEl.append(span, go, no); document.body.appendChild(bannerEl); } /** Settings card: node type, origin, auth middleware. */ function NodeCard({ s }) { const node = s.node || {}; const [type, setType] = useState(node.nodeType || s.role); const [middleware, setMiddleware] = useState(!!node.authMiddleware); const [origin, setOrigin] = useState(''); const [msg, setMsg] = useState(''); const [busy, setBusy] = useState(false); const own = location.origin + mount.root.replace(/\/$/, ''); const save = async (patch) => { setBusy(true); setMsg(''); try { const r = await post('node', patch); setMsg(r.restartRequired ? 'Saved. Restart DSH on this machine to switch its node type.' : 'Saved.'); } catch (e) { setMsg(e.message); } setBusy(false); store.refresh(); }; const restart = async () => { try { await post('restart', {}); setMsg('Restarting… reload the page in a few seconds.'); } catch (e) { setMsg(e.message); } }; const effective = node.effectiveNodeType || s.role; const needsRestart = node.restartRequired; return h('div', { style: S.card }, h('strong', null, 'Node'), h(Field, { label: 'Type' }, h('select', { style: S.input, value: type, disabled: busy, onChange: (e) => { setType(e.target.value); save({ nodeType: e.target.value }); } }, h('option', { value: 'master' }, 'Master: holds plugins, config and sessions; slaves connect to it'), h('option', { value: 'slave' }, 'Slave: connects to a master and syncs from it'))), needsRestart && h('div', { style: { fontSize: 13, margin: '0 0 10px', color: '#d29922' } }, `Running as ${effective}; ${node.nodeType} applies after a restart. `, h('button', { style: S.btn, onClick: restart }, 'Restart DSH')), effective === 'master' ? h(Field, { label: 'Origin', hint: 'The address slaves use to reach this master (browser-visible origin of this page).' }, h('input', { style: { ...S.input, opacity: .75 }, value: s.publicUrl || s.lanUrl || own, disabled: true, readOnly: true })) : h(Field, { label: 'Master origin', hint: store.state?.master?.url ? 'Paired. To use another master, re-pair below.' : 'Enter the master\'s origin, then pair below with a pairing code.' }, store.state?.master?.url ? h('input', { style: { ...S.input, opacity: .75 }, value: store.state.master.url, disabled: true, readOnly: true }) : h('input', { style: S.input, value: origin, placeholder: 'https://dsh.example.com or http://192.168.0.111:3080', onChange: (e) => { setOrigin(e.target.value); window.__dshrMasterOrigin = e.target.value; } })), h('label', { style: { display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 13, margin: '4px 0 6px' } }, h('input', { type: 'checkbox', checked: middleware, disabled: busy, onChange: (e) => { setMiddleware(e.target.checked); save({ authMiddleware: e.target.checked }); } }), h('span', null, h('strong', null, 'Master has an auth middleware'), h('div', { style: { opacity: .65, fontSize: 12, marginTop: 2 } }, 'On: when the link meets something that is not DSH (a sign-in page of Authentik, oauth2-proxy, …), a sign-in window opens, then the link retries. Off: work as usual, no prompts.'))), node.authRequired && effective === 'slave' && h('div', { style: { fontSize: 13, margin: '6px 0', color: '#f85149' } }, node.authRequired.reason || 'The master asked for sign-in.', ' ', h('button', { style: S.btnPrimary, onClick: async () => { const r = await startSignIn(store.state?.master?.url, { force: true }); if (r.blocked) setMsg(`Popup blocked: open ${r.url}`); } }, 'Sign in')), middleware && effective === 'slave' && store.state?.master?.url && !node.authRequired && h('div', null, h('button', { style: S.btn, onClick: () => startSignIn(store.state.master.url, { force: true }) }, 'Sign in to the auth proxy…'), ' ', h('button', { style: S.btn, onClick: async () => { try { const r = await post('retry-link', {}); setMsg(r.linked ? 'Linked.' : (r.error || 'Not linked yet.')); } catch (e) { setMsg(e.message); } store.refresh(); } }, 'Retry link')), msg && h('div', { style: { fontSize: 12, opacity: .8, marginTop: 6 } }, msg)); } function mountRemoteBanner() { if (!mount.device) return () => {}; const el = document.createElement('a'); el.href = mount.root; el.textContent = `● Remote: ${mount.device} · back to local`; el.title = 'You are using the DSH of another device through dsh-remote. Sessions run there.'; Object.assign(el.style, { position: 'fixed', bottom: '10px', right: '12px', zIndex: 9998, background: 'rgba(31,111,235,.92)', color: '#fff', padding: '4px 10px', borderRadius: '12px', fontSize: '12px', textDecoration: 'none', fontFamily: 'system-ui, sans-serif' }); document.body.appendChild(el); return () => el.remove(); } // ------------------------------------------------------------------ plugin return { inject: ['slots'], apply(ctx) { // Toasts / banner only belong to the outermost (local) UI, not proxied copies. startStream(); const disposers = []; if (!mount.device) disposers.push(mountToasts()); disposers.push(mountRemoteBanner()); disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: 'Devices', }, DeviceIcon))); disposers.push(ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, DevicesPage))); disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-remote', order: 160, label: 'Remote devices', }, SettingsPanel))); ctx.effect(() => () => { for (const d of disposers.splice(0)) { try { d && d(); } catch { /* ignore */ } } try { es && es.close(); } catch { /* ignore */ } es = null; }, 'dsh-remote: ui'); }, }; }, });