dsh-remote 0.1.0: master/slave remote control for DSH

- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing
  codes, per-device keys (hashed), revoke/remove
- /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with
  per-device cookie minting; slave->slave relay through the master
- Devices sidebar panel (all devices' sessions, new session on device),
  Remote devices settings page, finished-session toasts
- plugin + config sync master->slave: registry bundles via pluginManager,
  link: plugins as tarballs, cordis.patch.yml into a managed bundle layer
  (!!js preserved), allow/deny, opt-in per-secret credential sync
- deploy helpers + e2e probes, unit tests
This commit is contained in:
lyuksovannyy committed 2026-10-11 08:23:19 +00:00
commit 8872e73e76
21 files changed
+2914

No files matched your search

+396
View File
@@ -0,0 +1,396 @@
/**
* @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/<device>/#/session/<id>), "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/<device>/, 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/<dev>/), 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));
}
function SettingsPanel() {
const st = useStore();
const [code, setCode] = useState(null);
const [masterUrl, setMasterUrl] = useState('');
const [pairCode, setPairCode] = useState('');
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, 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('button', { style: S.btnPrimary, disabled: busy || !masterUrl || !pairCode, onClick: () => act(async () => {
const r = await post('pair', { masterUrl, code: pairCode });
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' }, header, pairing, 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(); };
}
// Banner when this whole UI is a remote device seen through /r/<device>/.
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');
},
};
},
});