feat(sync): device label, runtime kind, and Sync UI parity (Browser + Pear)
CI / Build & Test (push) Successful in 4m12s

- Persist optional display name locally; publish effective name + runtime to peers
- Map deviceRuntimes (browser extension vs desktop) alongside deviceNames
- Dashboard: badges, dirty/focus guards against auto-refresh, hero + Change name
This commit is contained in:
Raven Scott
2026-03-27 01:20:24 -04:00
parent 8d153d74ba
commit 64d8eb4d7c
8 changed files with 424 additions and 26 deletions
+91
View File
@@ -1019,6 +1019,97 @@
#terminalContainer .xterm-viewport { background: transparent !important; } #terminalContainer .xterm-viewport { background: transparent !important; }
#terminalContainer .xterm-screen { background: transparent !important; } #terminalContainer .xterm-screen { background: transparent !important; }
.sync-runtime-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 99px;
border: 1px solid var(--border);
color: var(--text3);
background: var(--surface);
white-space: nowrap;
}
.sync-runtime-badge--browser {
background: var(--cyan-dim);
border-color: var(--cyan-mid);
color: var(--cyan);
}
.sync-runtime-badge--desktop {
background: var(--amber-dim);
border-color: color-mix(in srgb, var(--amber) 35%, transparent);
color: var(--amber);
}
/* ── Sync: this device (setup vs named hero) ───────────────────────── */
.sync-device-intro {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--text3);
}
.sync-device-card.sync-device-card--named:not(.sync-device-card--editing) {
border-color: color-mix(in srgb, var(--cyan) 24%, var(--border));
background: linear-gradient(
145deg,
color-mix(in srgb, var(--cyan) 7%, var(--card)) 0%,
var(--card) 52%
);
}
.sync-device-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid color-mix(in srgb, var(--cyan) 18%, var(--border));
background: color-mix(in srgb, var(--cyan) 5%, var(--elevated));
}
.sync-device-hero__main {
min-width: 0;
flex: 1;
}
.sync-device-hero-name {
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text1);
line-height: 1.25;
word-break: break-word;
}
.sync-device-hero-line {
margin: 6px 0 0 0;
font-size: 12px;
color: var(--text4);
line-height: 1.35;
}
.sync-device-edit-wrap {
margin-bottom: 0;
}
.sync-device-edit-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.sync-device-input {
max-width: 340px;
flex: 1;
min-width: 200px;
}
.sync-device-hint {
margin: 0;
font-size: 12px;
color: var(--text4);
}
.sync-device-change-btn {
margin-top: 10px;
}
/* ── Boot splash (initial load / sync warmup) ───────────────────────── */ /* ── Boot splash (initial load / sync warmup) ───────────────────────── */
.boot-splash { .boot-splash {
position: fixed; position: fixed;
+22
View File
@@ -601,6 +601,28 @@
<!-- ── Sync page ─────────────────────────────── --> <!-- ── Sync page ─────────────────────────────── -->
<div class="page" id="page-sync"> <div class="page" id="page-sync">
<div class="card sync-device-card" id="syncDeviceCard" style="margin-bottom:16px;">
<div class="card-body">
<div class="section-heading">This device</div>
<p id="syncDeviceIntro" class="sync-device-intro">Custom name is shown to other linked devices and replaces your computer host name when set. Stored only on this machine; the chosen name still syncs in the group like before.</p>
<div id="syncDeviceHero" class="sync-device-hero" hidden>
<div class="sync-device-hero__main">
<div id="syncDeviceNameDisplay" class="sync-device-hero-name"></div>
<p class="sync-device-hero-line">This is how this device appears in your sync group.</p>
</div>
<span class="sync-runtime-badge" id="syncRuntimeBadgeHero" title="How this app runs"></span>
</div>
<div id="syncDeviceEditWrap" class="sync-device-edit-wrap">
<div class="sync-device-edit-row">
<input type="text" id="syncDeviceNameInput" class="input sync-device-input" placeholder="Use computer host name if empty" maxlength="64" autocomplete="off" />
<button type="button" class="btn btn-primary" id="btnSaveDeviceName">Save name</button>
<span class="sync-runtime-badge" id="syncRuntimeBadgeEdit" title="How this app runs"></span>
</div>
<p id="syncDeviceNameHint" class="sync-device-hint"></p>
</div>
<button type="button" class="btn btn-secondary sync-device-change-btn" id="btnChangeDeviceName" hidden>Change name</button>
</div>
</div>
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-body"> <div class="card-body">
<div class="section-heading">Device sync</div> <div class="section-heading">Device sync</div>
+128 -3
View File
@@ -3,6 +3,96 @@
* Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast) * Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast)
*/ */
/** True after the user edits the device name field; blocks refresh from overwriting until save. */
let _syncDeviceNameDirty = false;
function _markSyncDeviceNameDirty() { _syncDeviceNameDirty = true; }
function _clearSyncDeviceNameDirty() { _syncDeviceNameDirty = false; }
/** When a custom name exists, user can collapse to hero and reopen the editor. */
let _syncDeviceNameEditOpen = false;
function _openSyncDeviceNameEdit() { _syncDeviceNameEditOpen = true; }
function _closeSyncDeviceNameEdit() { _syncDeviceNameEditOpen = false; }
/** Last successful getSyncStatus payload (for instant layout when opening "Change name"). */
let _lastSyncStatusForDevicePanel = null;
function syncDevicePanelIsNamed(response) {
return (response.localDisplayName || '').trim().length > 0;
}
function applySyncRuntimeBadges(runtimeKind) {
const desktop = runtimeKind === 'desktop';
const text = desktop ? 'Desktop app' : 'Browser extension';
const cls = 'sync-runtime-badge ' + (desktop ? 'sync-runtime-badge--desktop' : 'sync-runtime-badge--browser');
const title = desktop ? 'Desktop app (Pear)' : 'Browser extension';
for (const id of ['syncRuntimeBadgeHero', 'syncRuntimeBadgeEdit']) {
const el = $(id);
if (!el) continue;
el.textContent = text;
el.className = cls;
el.title = title;
}
}
function syncRuntimeBadgeHtml(runtime) {
if (runtime === 'desktop') return ' <span class="sync-runtime-badge sync-runtime-badge--desktop">Desktop app</span>';
if (runtime === 'browser-extension') return ' <span class="sync-runtime-badge sync-runtime-badge--browser">Browser extension</span>';
return '';
}
function applySyncDeviceNamePanel(response) {
if (!response || response.ok !== true) return;
_lastSyncStatusForDevicePanel = response;
const input = $('syncDeviceNameInput');
const hint = $('syncDeviceNameHint');
const hero = $('syncDeviceHero');
const editWrap = $('syncDeviceEditWrap');
const intro = $('syncDeviceIntro');
const display = $('syncDeviceNameDisplay');
const btnChange = $('btnChangeDeviceName');
const card = $('syncDeviceCard');
const skipDeviceNameApply =
_syncDeviceNameDirty || !!(input && document.activeElement === input);
if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) {
input.value = response.localDisplayName;
}
if (response.runtimeKind) {
applySyncRuntimeBadges(response.runtimeKind);
}
const isNamed = syncDevicePanelIsNamed(response);
if (!isNamed) {
_closeSyncDeviceNameEdit();
}
const showEdit = !isNamed || _syncDeviceNameEditOpen;
const showHero = isNamed && !_syncDeviceNameEditOpen;
if (card) {
card.classList.toggle('sync-device-card--named', isNamed);
card.classList.toggle('sync-device-card--editing', showEdit);
}
if (intro) intro.hidden = !!isNamed;
if (hero) hero.hidden = !showHero;
if (editWrap) editWrap.hidden = !showEdit;
if (btnChange) btnChange.hidden = !isNamed || _syncDeviceNameEditOpen;
if (display && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) {
display.textContent = response.effectiveDeviceName || '';
}
if (hint && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) {
hint.textContent = response.effectiveDeviceName
? ('Shown to other devices as: ' + response.effectiveDeviceName)
: '';
}
if (hint) hint.hidden = !showEdit;
}
/** /**
* Update the sync status UI from the native host. * Update the sync status UI from the native host.
*/ */
@@ -11,6 +101,7 @@ function updateSyncStatus() {
{ target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } }, { target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } },
(response) => { (response) => {
if (chrome.runtime.lastError) return; if (chrome.runtime.lastError) return;
applySyncDeviceNamePanel(response);
const dot = $('syncStatusDot'); const dot = $('syncStatusDot');
const text = $('syncStatusText'); const text = $('syncStatusText');
const lastSynced = $('syncLastSynced'); const lastSynced = $('syncLastSynced');
@@ -18,7 +109,7 @@ function updateSyncStatus() {
const inviteDisplay = $('syncInviteDisplay'); const inviteDisplay = $('syncInviteDisplay');
if (!text) return; if (!text) return;
const linkDeviceForm = $('syncLinkDeviceForm'); const linkDeviceForm = $('syncLinkDeviceForm');
if (!response || response.error) { if (!response || response.error || response.ok === false) {
if (dot) dot.style.background = 'var(--text4)'; if (dot) dot.style.background = 'var(--text4)';
text.textContent = 'Not linked'; text.textContent = 'Not linked';
if (lastSynced) lastSynced.textContent = ''; if (lastSynced) lastSynced.textContent = '';
@@ -60,12 +151,14 @@ function updateSyncStatus() {
linkedDevices.forEach((d, i) => { linkedDevices.forEach((d, i) => {
const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1))); const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1)));
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : ''; const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
rows += '<tr><td>' + escapeHtml(label) + masterBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td></tr>'; const rt = syncRuntimeBadgeHtml(d.runtime);
rows += '<tr><td>' + escapeHtml(label) + masterBadge + rt + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td></tr>';
}); });
} else { } else {
const curName = response.deviceName || 'This device'; const curName = response.deviceName || 'This device';
const masterBadge = response.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : ''; const masterBadge = response.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
rows = '<tr><td>' + escapeHtml(curName) + masterBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td></tr>'; const rt = syncRuntimeBadgeHtml(response.runtimeKind);
rows = '<tr><td>' + escapeHtml(curName) + masterBadge + rt + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td></tr>';
} }
rows += '<tr><td>Sync group</td><td class="mono" style="font-size:12px;">' + escapeHtml(syncGroupId) + '</td></tr>'; rows += '<tr><td>Sync group</td><td class="mono" style="font-size:12px;">' + escapeHtml(syncGroupId) + '</td></tr>';
tbody.innerHTML = rows; tbody.innerHTML = rows;
@@ -96,6 +189,38 @@ function setupSyncEvents() {
const inviteInput = $('syncInviteInput'); const inviteInput = $('syncInviteInput');
const inviteDisplay = $('syncInviteDisplay'); const inviteDisplay = $('syncInviteDisplay');
$('syncDeviceNameInput')?.addEventListener('input', _markSyncDeviceNameDirty);
$('btnChangeDeviceName')?.addEventListener('click', () => {
_openSyncDeviceNameEdit();
if (_lastSyncStatusForDevicePanel) {
applySyncDeviceNamePanel(_lastSyncStatusForDevicePanel);
}
requestAnimationFrame(() => $('syncDeviceNameInput')?.focus());
});
$('btnSaveDeviceName')?.addEventListener('click', () => {
const input = $('syncDeviceNameInput');
const name = input && typeof input.value === 'string' ? input.value.trim() : '';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
(res) => {
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
}
if (res && res.ok) {
showToast('Device name saved', 'success');
_clearSyncDeviceNameDirty();
_closeSyncDeviceNameEdit();
updateSyncStatus();
} else {
showToast(res && res.error ? res.error : 'Could not save name', 'error');
}
}
);
});
btnCreate?.addEventListener('click', () => { btnCreate?.addEventListener('click', () => {
if (btnCreate.disabled) return; if (btnCreate.disabled) return;
btnCreate.disabled = true; btnCreate.disabled = true;
+7 -2
View File
@@ -66,7 +66,8 @@ function saveState() {
serviceTunnels: serviceTunnelsList, serviceTunnels: serviceTunnelsList,
sshConnections: connectionsModule.getSshConnections(), sshConnections: connectionsModule.getSshConnections(),
rdpConnections: connectionsModule.getRdpConnections(), rdpConnections: connectionsModule.getRdpConnections(),
deviceNames: stateModule.getDeviceNames() deviceNames: stateModule.getDeviceNames(),
deviceRuntimes: stateModule.getDeviceRuntimes()
}; };
stateModule.saveStateSync(snapshot); stateModule.saveStateSync(snapshot);
if (onStateSaved) onStateSaved(snapshot); if (onStateSaved) onStateSaved(snapshot);
@@ -96,7 +97,8 @@ function getStateSnapshot() {
serviceTunnels: serviceTunnelsList, serviceTunnels: serviceTunnelsList,
sshConnections: connectionsModule.getSshConnections(), sshConnections: connectionsModule.getSshConnections(),
rdpConnections: connectionsModule.getRdpConnections(), rdpConnections: connectionsModule.getRdpConnections(),
deviceNames: stateModule.getDeviceNames() deviceNames: stateModule.getDeviceNames(),
deviceRuntimes: stateModule.getDeviceRuntimes()
}; };
} }
@@ -114,6 +116,7 @@ function applySnapshotData(snapshot) {
if (typeof snapshot.nextServerId === 'number') serversModule.applyLoaded(snapshot.nextServerId); if (typeof snapshot.nextServerId === 'number') serversModule.applyLoaded(snapshot.nextServerId);
if (typeof snapshot.nextServiceTunnelId === 'number') svcModule.applyLoaded(snapshot.nextServiceTunnelId); if (typeof snapshot.nextServiceTunnelId === 'number') svcModule.applyLoaded(snapshot.nextServiceTunnelId);
if (snapshot.deviceNames != null && typeof snapshot.deviceNames === 'object') stateModule.setDeviceNames(snapshot.deviceNames); if (snapshot.deviceNames != null && typeof snapshot.deviceNames === 'object') stateModule.setDeviceNames(snapshot.deviceNames);
if (snapshot.deviceRuntimes != null && typeof snapshot.deviceRuntimes === 'object') stateModule.setDeviceRuntimes(snapshot.deviceRuntimes);
} }
// Inject the shared saveState and emit callbacks into each sub-module // Inject the shared saveState and emit callbacks into each sub-module
@@ -185,6 +188,7 @@ async function resetStateToDefaults() {
const defaultState = stateModule.buildDefaultState(); const defaultState = stateModule.buildDefaultState();
stateModule.saveStateSync(defaultState); stateModule.saveStateSync(defaultState);
stateModule.setDeviceNames({}); stateModule.setDeviceNames({});
stateModule.setDeviceRuntimes({});
return restorePersistedState(); return restorePersistedState();
} }
@@ -198,6 +202,7 @@ module.exports = {
ensureStorageDir, ensureStorageDir,
setOnStateSaved, setOnStateSaved,
setStateSaveSuppressed, setStateSaveSuppressed,
saveState,
getStateSnapshot, getStateSnapshot,
applySnapshotData, applySnapshotData,
resetStateToDefaults, resetStateToDefaults,
+35 -5
View File
@@ -37,6 +37,8 @@ function debugLog(...args) {
let stateFilePath = null; let stateFilePath = null;
/** In-memory cache of device names (deviceId -> hostname) so they are never lost when saving. */ /** In-memory cache of device names (deviceId -> hostname) so they are never lost when saving. */
let lastLoadedDeviceNames = {}; let lastLoadedDeviceNames = {};
/** Synced map: deviceId -> 'browser-extension' | 'desktop'. */
let lastLoadedDeviceRuntimes = {};
function setStoragePath(baseDir) { function setStoragePath(baseDir) {
if (baseDir && typeof baseDir === 'string') { if (baseDir && typeof baseDir === 'string') {
@@ -76,7 +78,8 @@ function buildDefaultState() {
rdpConnections: [], rdpConnections: [],
nextServerId: 0, nextServerId: 0,
nextServiceTunnelId: 0, nextServiceTunnelId: 0,
deviceNames: {} deviceNames: {},
deviceRuntimes: {}
}; };
} }
@@ -90,11 +93,23 @@ function setDeviceNames(obj) {
} }
} }
function getDeviceRuntimes() {
return { ...lastLoadedDeviceRuntimes };
}
function setDeviceRuntimes(obj) {
if (obj != null && typeof obj === 'object') {
lastLoadedDeviceRuntimes = { ...obj };
}
}
function saveStateSync(data) { function saveStateSync(data) {
const file = getStatePath(); const file = getStatePath();
const deviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? data.deviceNames : lastLoadedDeviceNames; const deviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? data.deviceNames : lastLoadedDeviceNames;
lastLoadedDeviceNames = { ...deviceNames }; lastLoadedDeviceNames = { ...deviceNames };
const toWrite = { ...data, deviceNames: lastLoadedDeviceNames }; const deviceRuntimes = (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? data.deviceRuntimes : lastLoadedDeviceRuntimes;
lastLoadedDeviceRuntimes = { ...deviceRuntimes };
const toWrite = { ...data, deviceNames: lastLoadedDeviceNames, deviceRuntimes: lastLoadedDeviceRuntimes };
const json = JSON.stringify(toWrite, null, 2); const json = JSON.stringify(toWrite, null, 2);
try { try {
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -115,6 +130,7 @@ function loadState() {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return buildDefaultState(); if (!data || typeof data !== 'object') return buildDefaultState();
lastLoadedDeviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {}; lastLoadedDeviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {};
lastLoadedDeviceRuntimes = (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? { ...data.deviceRuntimes } : {};
const out = { const out = {
settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS }, settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [], servers: Array.isArray(data.servers) ? data.servers : [],
@@ -124,7 +140,8 @@ function loadState() {
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [], rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0, nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0, nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0,
deviceNames: { ...lastLoadedDeviceNames } deviceNames: { ...lastLoadedDeviceNames },
deviceRuntimes: { ...lastLoadedDeviceRuntimes }
}; };
debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length); debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length);
if (process.stderr && (out.servers.length || out.virtualHosts.length)) { if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
@@ -147,6 +164,7 @@ function loadState() {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n'); if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n');
lastLoadedDeviceNames = {}; lastLoadedDeviceNames = {};
lastLoadedDeviceRuntimes = {};
const out = { const out = {
settings: { ...SETTINGS_DEFAULTS }, settings: { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [], servers: Array.isArray(data.servers) ? data.servers : [],
@@ -156,7 +174,8 @@ function loadState() {
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [], rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0, nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0, nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0,
deviceNames: {} deviceNames: {},
deviceRuntimes: {}
}; };
try { fs.unlinkSync(legacyFile); } catch (_) {} try { fs.unlinkSync(legacyFile); } catch (_) {}
if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n'); if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n');
@@ -170,4 +189,15 @@ function loadState() {
return buildDefaultState(); return buildDefaultState();
} }
module.exports = { SETTINGS_DEFAULTS, setStoragePath, loadState, saveStateSync, buildDefaultState, ensureStorageDir, getDeviceNames, setDeviceNames }; module.exports = {
SETTINGS_DEFAULTS,
setStoragePath,
loadState,
saveStateSync,
buildDefaultState,
ensureStorageDir,
getDeviceNames,
setDeviceNames,
getDeviceRuntimes,
setDeviceRuntimes
};
+11
View File
@@ -61,6 +61,17 @@ function register(deps) {
reply({ ok: false, error: e.message }); reply({ ok: false, error: e.message });
} }
} }
},
{
type: 'setDeviceDisplayName',
handle: async (payload, reply) => {
try {
const result = syncManager.setDeviceDisplayName(payload || {});
reply(result);
} catch (e) {
reply({ ok: false, error: e.message });
}
}
} }
]; ];
} }
+127 -14
View File
@@ -58,6 +58,50 @@ function getIdentityPath() {
return path.join(storageDir, IDENTITY_FILENAME); return path.join(storageDir, IDENTITY_FILENAME);
} }
const LOCAL_DEVICE_DISPLAY_FILENAME = 'device-display-name.json';
function getLocalDeviceDisplayNamePath() {
if (!storageDir) return null;
return path.join(storageDir, LOCAL_DEVICE_DISPLAY_FILENAME);
}
function readLocalDeviceDisplayName() {
const p = getLocalDeviceDisplayNamePath();
if (!p || !fs.existsSync(p)) return '';
try {
const raw = fs.readFileSync(p, 'utf8');
const data = JSON.parse(raw);
if (data && typeof data.displayName === 'string') {
return data.displayName.trim().slice(0, 64);
}
} catch (_) {}
return '';
}
function writeLocalDeviceDisplayName(name) {
const p = getLocalDeviceDisplayNamePath();
if (!p) return { ok: false, error: 'Storage path not set' };
const trimmed = typeof name === 'string' ? name.trim().slice(0, 64) : '';
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify({ displayName: trimmed }, null, 2), 'utf8');
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function getEffectiveDeviceName() {
const custom = readLocalDeviceDisplayName();
if (custom) return custom;
return typeof os.hostname === 'function' ? os.hostname() : 'device';
}
/** @returns {'browser-extension'|'desktop'} */
function getLocalRuntimeKind() {
return process.env.HOLESAIL_EMBEDDED === '1' ? 'desktop' : 'browser-extension';
}
function loadIdentity() { function loadIdentity() {
const file = getIdentityPath(); const file = getIdentityPath();
if (!file || !fs.existsSync(file)) return null; if (!file || !fs.existsSync(file)) return null;
@@ -310,9 +354,13 @@ async function applySyncedStateIncremental(snapshot) {
setStateSaveSuppressed(true); setStateSaveSuppressed(true);
try { try {
snapshot.deviceNames = snapshot.deviceNames || {}; snapshot.deviceNames = snapshot.deviceNames || {};
snapshot.deviceRuntimes = snapshot.deviceRuntimes || {};
if (pass && pass.writerKey) { if (pass && pass.writerKey) {
const myId = shortId(pass.writerKey); const myId = shortId(pass.writerKey);
if (myId) snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device'; if (myId) {
snapshot.deviceNames[myId] = getEffectiveDeviceName();
snapshot.deviceRuntimes[myId] = getLocalRuntimeKind();
}
} }
const current = getStateSnapshot(); const current = getStateSnapshot();
const settings = holesailManager.getSettings ? holesailManager.getSettings() : {}; const settings = holesailManager.getSettings ? holesailManager.getSettings() : {};
@@ -399,9 +447,13 @@ async function applySyncedStateFull(snapshot) {
applyingSync = true; applyingSync = true;
try { try {
snapshot.deviceNames = snapshot.deviceNames || {}; snapshot.deviceNames = snapshot.deviceNames || {};
snapshot.deviceRuntimes = snapshot.deviceRuntimes || {};
if (pass && pass.writerKey) { if (pass && pass.writerKey) {
const myId = shortId(pass.writerKey); const myId = shortId(pass.writerKey);
if (myId) snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device'; if (myId) {
snapshot.deviceNames[myId] = getEffectiveDeviceName();
snapshot.deviceRuntimes[myId] = getLocalRuntimeKind();
}
} }
const settings = holesailManager.getSettings ? holesailManager.getSettings() : {}; const settings = holesailManager.getSettings ? holesailManager.getSettings() : {};
if (settings.syncServers !== true) { if (settings.syncServers !== true) {
@@ -446,7 +498,9 @@ function onStateSaved(snapshot) {
const myId = shortId(pass.writerKey); const myId = shortId(pass.writerKey);
if (myId) { if (myId) {
snapshot.deviceNames = snapshot.deviceNames || {}; snapshot.deviceNames = snapshot.deviceNames || {};
snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device'; snapshot.deviceRuntimes = snapshot.deviceRuntimes || {};
snapshot.deviceNames[myId] = getEffectiveDeviceName();
snapshot.deviceRuntimes[myId] = getLocalRuntimeKind();
} }
const identity = loadIdentity(); const identity = loadIdentity();
if (identity && identity.isMaster) { if (identity && identity.isMaster) {
@@ -497,6 +551,18 @@ function readDeviceNamesFromState() {
} }
} }
function readDeviceRuntimesFromState() {
const statePath = getStateFilePath();
if (!statePath || !fs.existsSync(statePath)) return {};
try {
const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw);
return (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? data.deviceRuntimes : {};
} catch (_) {
return {};
}
}
function readSyncMasterDeviceIdFromState() { function readSyncMasterDeviceIdFromState() {
const statePath = getStateFilePath(); const statePath = getStateFilePath();
if (!statePath || !fs.existsSync(statePath)) return null; if (!statePath || !fs.existsSync(statePath)) return null;
@@ -512,10 +578,14 @@ function readSyncMasterDeviceIdFromState() {
async function getSyncStatus() { async function getSyncStatus() {
await ensureInitialized(); await ensureInitialized();
const effName = getEffectiveDeviceName();
const out = { const out = {
linked: !!pass, linked: !!pass,
invite: currentInvite || null, invite: currentInvite || null,
lastSyncedAt: lastSyncedAt || null lastSyncedAt: lastSyncedAt || null,
localDisplayName: readLocalDeviceDisplayName(),
runtimeKind: getLocalRuntimeKind(),
effectiveDeviceName: effName
}; };
if (pass) { if (pass) {
try { try {
@@ -523,10 +593,11 @@ async function getSyncStatus() {
out.isMaster = identity && identity.isMaster === true; out.isMaster = identity && identity.isMaster === true;
out.deviceId = shortId(pass.writerKey) || null; out.deviceId = shortId(pass.writerKey) || null;
out.syncGroupId = shortId(pass.discoveryKey) || null; out.syncGroupId = shortId(pass.discoveryKey) || null;
out.deviceName = typeof os.hostname === 'function' ? os.hostname() : null; out.deviceName = effName;
out.linkedDevices = []; out.linkedDevices = [];
const myKeyHex = b4a.toString(pass.writerKey, 'hex'); const myKeyHex = b4a.toString(pass.writerKey, 'hex');
const deviceNames = readDeviceNamesFromState(); const deviceNames = readDeviceNamesFromState();
const deviceRuntimes = readDeviceRuntimesFromState();
const masterDeviceId = readSyncMasterDeviceIdFromState(); const masterDeviceId = readSyncMasterDeviceIdFromState();
if (pass.base && pass.base.activeWriters) { if (pass.base && pass.base.activeWriters) {
for (const w of pass.base.activeWriters) { for (const w of pass.base.activeWriters) {
@@ -538,16 +609,24 @@ async function getSyncStatus() {
id, id,
isCurrent, isCurrent,
isMaster: id === masterDeviceId, isMaster: id === masterDeviceId,
name: deviceNames[id] || (isCurrent ? out.deviceName : null) name: deviceNames[id] || (isCurrent ? out.deviceName : null),
runtime: deviceRuntimes[id] || null
}); });
} }
} }
if (out.linkedDevices.length === 0 && out.deviceId) { if (out.linkedDevices.length === 0 && out.deviceId) {
out.linkedDevices = [{ id: out.deviceId, isCurrent: true, isMaster: out.deviceId === masterDeviceId || out.isMaster, name: out.deviceName }]; out.linkedDevices = [{
id: out.deviceId,
isCurrent: true,
isMaster: out.deviceId === masterDeviceId || out.isMaster,
name: out.deviceName,
runtime: deviceRuntimes[out.deviceId] || getLocalRuntimeKind()
}];
} }
// Ensure our hostname is published so other devices see our name (fire-and-forget) // Ensure our display name and runtime are published so other devices see them (fire-and-forget)
const myId = out.deviceId; const myId = out.deviceId;
const currentName = out.deviceName; const currentName = out.deviceName;
const currentRuntime = getLocalRuntimeKind();
if (myId && currentName) { if (myId && currentName) {
(async () => { (async () => {
try { try {
@@ -555,10 +634,20 @@ async function getSyncStatus() {
if (!statePath || !fs.existsSync(statePath) || !pass) return; if (!statePath || !fs.existsSync(statePath) || !pass) return;
const raw = fs.readFileSync(statePath, 'utf8'); const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw); const data = JSON.parse(raw);
const deviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {}; const dn = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {};
if (deviceNames[myId] === currentName) return; const dr = (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? { ...data.deviceRuntimes } : {};
deviceNames[myId] = currentName; let changed = false;
data.deviceNames = deviceNames; if (dn[myId] !== currentName) {
dn[myId] = currentName;
changed = true;
}
if (dr[myId] !== currentRuntime) {
dr[myId] = currentRuntime;
changed = true;
}
if (!changed) return;
data.deviceNames = dn;
data.deviceRuntimes = dr;
fs.writeFileSync(statePath, JSON.stringify(data, null, 2), 'utf8'); fs.writeFileSync(statePath, JSON.stringify(data, null, 2), 'utf8');
if (!pass) return; if (!pass) return;
const value = JSON.stringify(data); const value = JSON.stringify(data);
@@ -571,7 +660,7 @@ async function getSyncStatus() {
out.deviceId = null; out.deviceId = null;
out.syncGroupId = null; out.syncGroupId = null;
out.deviceName = null; out.deviceName = null;
out.linkedDevices = out.deviceId ? [{ id: out.deviceId, isCurrent: true, name: null }] : []; out.linkedDevices = out.deviceId ? [{ id: out.deviceId, isCurrent: true, name: null, runtime: null }] : [];
} }
} }
return out; return out;
@@ -754,6 +843,8 @@ async function disband() {
const snapshot = holesailManager.getStateSnapshot(); const snapshot = holesailManager.getStateSnapshot();
const deviceNames = readDeviceNamesFromState(); const deviceNames = readDeviceNamesFromState();
if (Object.keys(deviceNames).length > 0) snapshot.deviceNames = deviceNames; if (Object.keys(deviceNames).length > 0) snapshot.deviceNames = deviceNames;
const deviceRuntimes = readDeviceRuntimesFromState();
if (Object.keys(deviceRuntimes).length > 0) snapshot.deviceRuntimes = deviceRuntimes;
delete snapshot.syncGroupDisbanded; delete snapshot.syncGroupDisbanded;
if (statePath) { if (statePath) {
try { try {
@@ -817,6 +908,27 @@ function hasSyncIdentity() {
return !!loadIdentity(); return !!loadIdentity();
} }
/**
* Persist a custom device display name locally (not synced as a file the effective name syncs via deviceNames).
* @param {{ displayName?: string }} payload
* @returns {{ ok: boolean, effectiveName?: string, runtimeKind?: string, error?: string }}
*/
function setDeviceDisplayName(payload) {
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
const raw = payload && typeof payload.displayName === 'string' ? payload.displayName : '';
if (raw.length > 64) return { ok: false, error: 'displayName must be at most 64 characters' };
const w = writeLocalDeviceDisplayName(raw);
if (!w.ok) return w;
if (typeof holesailManager.saveState === 'function') {
holesailManager.saveState();
}
return {
ok: true,
effectiveName: getEffectiveDeviceName(),
runtimeKind: getLocalRuntimeKind()
};
}
module.exports = { module.exports = {
setStoragePath, setStoragePath,
setDeps, setDeps,
@@ -830,5 +942,6 @@ module.exports = {
closeSyncForBackup, closeSyncForBackup,
cleanup, cleanup,
warmupSyncAtStartup, warmupSyncAtStartup,
hasSyncIdentity hasSyncIdentity,
setDeviceDisplayName
}; };
+3 -2
View File
@@ -72,7 +72,8 @@ function makeMockDeps(overrides = {}) {
createSyncInvite: async () => ({}), createSyncInvite: async () => ({}),
pairWithInvite: async () => ({}), pairWithInvite: async () => ({}),
warmupSyncAtStartup: async () => {}, warmupSyncAtStartup: async () => {},
hasSyncIdentity: () => false hasSyncIdentity: () => false,
setDeviceDisplayName: () => ({ ok: true })
}, },
...overrides ...overrides
}; };
@@ -92,7 +93,7 @@ describe('handler registry', () => {
'startSshSession', 'stopSshSession', 'resizeSshSession', 'getSshSessions', 'startSshSession', 'stopSshSession', 'resizeSshSession', 'getSshSessions',
'startRdpSession', 'stopRdpSession', 'getRdpSessions', 'startRdpSession', 'stopRdpSession', 'getRdpSessions',
'createBackup', 'listBackups', 'restoreBackup', 'deleteBackup', 'createBackup', 'listBackups', 'restoreBackup', 'deleteBackup',
'getSyncStatus', 'createSyncInvite', 'pairWithInvite' 'getSyncStatus', 'createSyncInvite', 'pairWithInvite', 'setDeviceDisplayName'
]; ];
for (const type of expectedTypes) { for (const type of expectedTypes) {
assert.ok(handlers.has(type), `missing handler: ${type}`); assert.ok(handlers.has(type), `missing handler: ${type}`);