feat(sync): device label, runtime kind, and Sync UI parity (Browser + Pear)
CI / Build & Test (push) Successful in 4m12s
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:
@@ -1019,6 +1019,97 @@
|
||||
#terminalContainer .xterm-viewport { 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 {
|
||||
position: fixed;
|
||||
|
||||
@@ -601,6 +601,28 @@
|
||||
|
||||
<!-- ── Sync page ─────────────────────────────── -->
|
||||
<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-body">
|
||||
<div class="section-heading">Device sync</div>
|
||||
|
||||
@@ -3,6 +3,96 @@
|
||||
* 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.
|
||||
*/
|
||||
@@ -11,6 +101,7 @@ function updateSyncStatus() {
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
applySyncDeviceNamePanel(response);
|
||||
const dot = $('syncStatusDot');
|
||||
const text = $('syncStatusText');
|
||||
const lastSynced = $('syncLastSynced');
|
||||
@@ -18,7 +109,7 @@ function updateSyncStatus() {
|
||||
const inviteDisplay = $('syncInviteDisplay');
|
||||
if (!text) return;
|
||||
const linkDeviceForm = $('syncLinkDeviceForm');
|
||||
if (!response || response.error) {
|
||||
if (!response || response.error || response.ok === false) {
|
||||
if (dot) dot.style.background = 'var(--text4)';
|
||||
text.textContent = 'Not linked';
|
||||
if (lastSynced) lastSynced.textContent = '';
|
||||
@@ -60,12 +151,14 @@ function updateSyncStatus() {
|
||||
linkedDevices.forEach((d, i) => {
|
||||
const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1)));
|
||||
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 {
|
||||
const curName = response.deviceName || 'This device';
|
||||
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>';
|
||||
tbody.innerHTML = rows;
|
||||
@@ -96,6 +189,38 @@ function setupSyncEvents() {
|
||||
const inviteInput = $('syncInviteInput');
|
||||
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', () => {
|
||||
if (btnCreate.disabled) return;
|
||||
btnCreate.disabled = true;
|
||||
|
||||
@@ -66,7 +66,8 @@ function saveState() {
|
||||
serviceTunnels: serviceTunnelsList,
|
||||
sshConnections: connectionsModule.getSshConnections(),
|
||||
rdpConnections: connectionsModule.getRdpConnections(),
|
||||
deviceNames: stateModule.getDeviceNames()
|
||||
deviceNames: stateModule.getDeviceNames(),
|
||||
deviceRuntimes: stateModule.getDeviceRuntimes()
|
||||
};
|
||||
stateModule.saveStateSync(snapshot);
|
||||
if (onStateSaved) onStateSaved(snapshot);
|
||||
@@ -96,7 +97,8 @@ function getStateSnapshot() {
|
||||
serviceTunnels: serviceTunnelsList,
|
||||
sshConnections: connectionsModule.getSshConnections(),
|
||||
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.nextServiceTunnelId === 'number') svcModule.applyLoaded(snapshot.nextServiceTunnelId);
|
||||
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
|
||||
@@ -185,6 +188,7 @@ async function resetStateToDefaults() {
|
||||
const defaultState = stateModule.buildDefaultState();
|
||||
stateModule.saveStateSync(defaultState);
|
||||
stateModule.setDeviceNames({});
|
||||
stateModule.setDeviceRuntimes({});
|
||||
return restorePersistedState();
|
||||
}
|
||||
|
||||
@@ -198,6 +202,7 @@ module.exports = {
|
||||
ensureStorageDir,
|
||||
setOnStateSaved,
|
||||
setStateSaveSuppressed,
|
||||
saveState,
|
||||
getStateSnapshot,
|
||||
applySnapshotData,
|
||||
resetStateToDefaults,
|
||||
|
||||
@@ -37,6 +37,8 @@ function debugLog(...args) {
|
||||
let stateFilePath = null;
|
||||
/** In-memory cache of device names (deviceId -> hostname) so they are never lost when saving. */
|
||||
let lastLoadedDeviceNames = {};
|
||||
/** Synced map: deviceId -> 'browser-extension' | 'desktop'. */
|
||||
let lastLoadedDeviceRuntimes = {};
|
||||
|
||||
function setStoragePath(baseDir) {
|
||||
if (baseDir && typeof baseDir === 'string') {
|
||||
@@ -76,7 +78,8 @@ function buildDefaultState() {
|
||||
rdpConnections: [],
|
||||
nextServerId: 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) {
|
||||
const file = getStatePath();
|
||||
const deviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? data.deviceNames : lastLoadedDeviceNames;
|
||||
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);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
@@ -115,6 +130,7 @@ function loadState() {
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || typeof data !== 'object') return buildDefaultState();
|
||||
lastLoadedDeviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {};
|
||||
lastLoadedDeviceRuntimes = (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? { ...data.deviceRuntimes } : {};
|
||||
const out = {
|
||||
settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS },
|
||||
servers: Array.isArray(data.servers) ? data.servers : [],
|
||||
@@ -124,7 +140,8 @@ function loadState() {
|
||||
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
|
||||
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 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);
|
||||
if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
|
||||
@@ -147,6 +164,7 @@ function loadState() {
|
||||
const data = JSON.parse(raw);
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n');
|
||||
lastLoadedDeviceNames = {};
|
||||
lastLoadedDeviceRuntimes = {};
|
||||
const out = {
|
||||
settings: { ...SETTINGS_DEFAULTS },
|
||||
servers: Array.isArray(data.servers) ? data.servers : [],
|
||||
@@ -156,7 +174,8 @@ function loadState() {
|
||||
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
|
||||
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
|
||||
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0,
|
||||
deviceNames: {}
|
||||
deviceNames: {},
|
||||
deviceRuntimes: {}
|
||||
};
|
||||
try { fs.unlinkSync(legacyFile); } catch (_) {}
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n');
|
||||
@@ -170,4 +189,15 @@ function loadState() {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -61,6 +61,17 @@ function register(deps) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,6 +58,50 @@ function getIdentityPath() {
|
||||
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() {
|
||||
const file = getIdentityPath();
|
||||
if (!file || !fs.existsSync(file)) return null;
|
||||
@@ -310,9 +354,13 @@ async function applySyncedStateIncremental(snapshot) {
|
||||
setStateSaveSuppressed(true);
|
||||
try {
|
||||
snapshot.deviceNames = snapshot.deviceNames || {};
|
||||
snapshot.deviceRuntimes = snapshot.deviceRuntimes || {};
|
||||
if (pass && 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 settings = holesailManager.getSettings ? holesailManager.getSettings() : {};
|
||||
@@ -399,9 +447,13 @@ async function applySyncedStateFull(snapshot) {
|
||||
applyingSync = true;
|
||||
try {
|
||||
snapshot.deviceNames = snapshot.deviceNames || {};
|
||||
snapshot.deviceRuntimes = snapshot.deviceRuntimes || {};
|
||||
if (pass && 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() : {};
|
||||
if (settings.syncServers !== true) {
|
||||
@@ -446,7 +498,9 @@ function onStateSaved(snapshot) {
|
||||
const myId = shortId(pass.writerKey);
|
||||
if (myId) {
|
||||
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();
|
||||
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() {
|
||||
const statePath = getStateFilePath();
|
||||
if (!statePath || !fs.existsSync(statePath)) return null;
|
||||
@@ -512,10 +578,14 @@ function readSyncMasterDeviceIdFromState() {
|
||||
|
||||
async function getSyncStatus() {
|
||||
await ensureInitialized();
|
||||
const effName = getEffectiveDeviceName();
|
||||
const out = {
|
||||
linked: !!pass,
|
||||
invite: currentInvite || null,
|
||||
lastSyncedAt: lastSyncedAt || null
|
||||
lastSyncedAt: lastSyncedAt || null,
|
||||
localDisplayName: readLocalDeviceDisplayName(),
|
||||
runtimeKind: getLocalRuntimeKind(),
|
||||
effectiveDeviceName: effName
|
||||
};
|
||||
if (pass) {
|
||||
try {
|
||||
@@ -523,10 +593,11 @@ async function getSyncStatus() {
|
||||
out.isMaster = identity && identity.isMaster === true;
|
||||
out.deviceId = shortId(pass.writerKey) || null;
|
||||
out.syncGroupId = shortId(pass.discoveryKey) || null;
|
||||
out.deviceName = typeof os.hostname === 'function' ? os.hostname() : null;
|
||||
out.deviceName = effName;
|
||||
out.linkedDevices = [];
|
||||
const myKeyHex = b4a.toString(pass.writerKey, 'hex');
|
||||
const deviceNames = readDeviceNamesFromState();
|
||||
const deviceRuntimes = readDeviceRuntimesFromState();
|
||||
const masterDeviceId = readSyncMasterDeviceIdFromState();
|
||||
if (pass.base && pass.base.activeWriters) {
|
||||
for (const w of pass.base.activeWriters) {
|
||||
@@ -538,16 +609,24 @@ async function getSyncStatus() {
|
||||
id,
|
||||
isCurrent,
|
||||
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) {
|
||||
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 currentName = out.deviceName;
|
||||
const currentRuntime = getLocalRuntimeKind();
|
||||
if (myId && currentName) {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -555,10 +634,20 @@ async function getSyncStatus() {
|
||||
if (!statePath || !fs.existsSync(statePath) || !pass) return;
|
||||
const raw = fs.readFileSync(statePath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
const deviceNames = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {};
|
||||
if (deviceNames[myId] === currentName) return;
|
||||
deviceNames[myId] = currentName;
|
||||
data.deviceNames = deviceNames;
|
||||
const dn = (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {};
|
||||
const dr = (data.deviceRuntimes && typeof data.deviceRuntimes === 'object') ? { ...data.deviceRuntimes } : {};
|
||||
let changed = false;
|
||||
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');
|
||||
if (!pass) return;
|
||||
const value = JSON.stringify(data);
|
||||
@@ -571,7 +660,7 @@ async function getSyncStatus() {
|
||||
out.deviceId = null;
|
||||
out.syncGroupId = 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;
|
||||
@@ -754,6 +843,8 @@ async function disband() {
|
||||
const snapshot = holesailManager.getStateSnapshot();
|
||||
const deviceNames = readDeviceNamesFromState();
|
||||
if (Object.keys(deviceNames).length > 0) snapshot.deviceNames = deviceNames;
|
||||
const deviceRuntimes = readDeviceRuntimesFromState();
|
||||
if (Object.keys(deviceRuntimes).length > 0) snapshot.deviceRuntimes = deviceRuntimes;
|
||||
delete snapshot.syncGroupDisbanded;
|
||||
if (statePath) {
|
||||
try {
|
||||
@@ -817,6 +908,27 @@ function hasSyncIdentity() {
|
||||
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 = {
|
||||
setStoragePath,
|
||||
setDeps,
|
||||
@@ -830,5 +942,6 @@ module.exports = {
|
||||
closeSyncForBackup,
|
||||
cleanup,
|
||||
warmupSyncAtStartup,
|
||||
hasSyncIdentity
|
||||
hasSyncIdentity,
|
||||
setDeviceDisplayName
|
||||
};
|
||||
|
||||
@@ -72,7 +72,8 @@ function makeMockDeps(overrides = {}) {
|
||||
createSyncInvite: async () => ({}),
|
||||
pairWithInvite: async () => ({}),
|
||||
warmupSyncAtStartup: async () => {},
|
||||
hasSyncIdentity: () => false
|
||||
hasSyncIdentity: () => false,
|
||||
setDeviceDisplayName: () => ({ ok: true })
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
@@ -92,7 +93,7 @@ describe('handler registry', () => {
|
||||
'startSshSession', 'stopSshSession', 'resizeSshSession', 'getSshSessions',
|
||||
'startRdpSession', 'stopRdpSession', 'getRdpSessions',
|
||||
'createBackup', 'listBackups', 'restoreBackup', 'deleteBackup',
|
||||
'getSyncStatus', 'createSyncInvite', 'pairWithInvite'
|
||||
'getSyncStatus', 'createSyncInvite', 'pairWithInvite', 'setDeviceDisplayName'
|
||||
];
|
||||
for (const type of expectedTypes) {
|
||||
assert.ok(handlers.has(type), `missing handler: ${type}`);
|
||||
|
||||
Reference in New Issue
Block a user