feat(sync): show device hostnames in Linked devices table; document sync fully
CI / Build & Test (push) Successful in 4m20s

- Sync: use OS hostname for each device in Linked devices table; persist
  deviceNames in state and merge on save/apply so peers see each other's names
- Docs: expand SYNC.md (linked devices table, replace-state modal, multi-device,
  offline); NATIVE-HOST getSyncStatus (deviceId, syncGroupId, linkedDevices,
  deviceName/name); ARCHITECTURE sync-manager and autopass paths; README
  features/dashboard/file locations/doc link; BACKUP and SECURITY cross-refs
This commit is contained in:
Raven Scott
2026-03-15 02:22:11 -04:00
parent 594ac19ca2
commit 7df3975bb0
6 changed files with 57 additions and 14 deletions
+4 -1
View File
@@ -350,10 +350,13 @@ All state is owned by the native host and persisted to `state.json` next to the
],
"rdpConnections": [
{ "id": "rdp-abc123", "label": "Work PC", "hsUrl": "hs://xyz...", "type": "vnc", "port": 5901, "width": 1280, "height": 720, "username": "", "passwordB64": "cGFzc3dvcmQ=" }
]
],
"deviceNames": { "a1b2c3d4e5f6": "macbook-pro", "b2c3d4e5f678": "work-laptop" }
}
```
When sync is used, `deviceNames` (optional) maps each devices short hex ID to its OS hostname so the Linked devices table can show names instead of “Device 2”, etc.
Additional settings (e.g. `notifyOnTunnelError`, `backupIntervalHours`, `tunnelAutoReconnect`, `latencyPingEnabled`, `latencyPingIntervalMs`) may be present; see `native-host/holesail-manager/state.js` `SETTINGS_DEFAULTS`.
## Native host lifecycle
+4 -3
View File
@@ -525,15 +525,16 @@ Return whether this device is linked for sync, optional invite/last-synced info,
"invite": "optional invite string if one was created",
"lastSyncedAt": 1710000000000,
"deviceId": "a1b2c3d4e5f6",
"deviceName": "macbook-pro",
"syncGroupId": "f6e5d4c3b2a1",
"linkedDevices": [
{ "id": "a1b2c3d4e5f6", "isCurrent": true },
{ "id": "b2c3d4e5f678", "isCurrent": false }
{ "id": "a1b2c3d4e5f6", "isCurrent": true, "name": "macbook-pro" },
{ "id": "b2c3d4e5f678", "isCurrent": false, "name": "work-laptop" }
]
}
```
When linked, `deviceId` is a short hex ID for this devices writer key and `syncGroupId` is a short hex ID for the shared discovery key. `linkedDevices` is populated from the autopass active writers; it may contain only this device until other peers have replicated at least once.
When linked, `deviceId` is a short hex ID for this devices writer key, `deviceName` is the OS hostname of this device, and `syncGroupId` is a short hex ID for the shared discovery key. `linkedDevices` is populated from the autopass active writers; each entry includes `name` (hostname) when known from synced state, so other peers hostnames appear after they have replicated at least once. It may contain only this device until other peers have replicated.
---
+2 -2
View File
@@ -19,8 +19,8 @@ Holesail Browser can sync its state across two devices using [autopass](https://
When linked, the Sync page shows a **Linked devices** table:
- **This device** — This machines device ID (a short hex fingerprint of its autopass writer key).
- **Device 2**, **Device 3**, … — Other devices in the same sync group, each with their own ID. Other peers appear in the table after they have replicated at least once; if the other device is offline or has not yet synced, you may only see “This device until replication runs.
- **Device name** — Each device is shown by its **hostname** (the OS hostname of the machine, e.g. `macbook-pro`, `work-laptop`). Hostnames are synced in state so once a device has replicated at least once, other peers will see its hostname in the table. If a hostname is not yet known, the table falls back to “This device” for the current machine or “Device 2”, “Device 3”, etc. for others.
- **Device ID** — The short hex ID (fingerprint of the devices autopass writer key) is shown next to each name. Other peers appear in the table after they have replicated at least once; if the other device is offline or has not yet synced, you may only see this device until replication runs.
- **Sync group** — A shared ID (fingerprint of the discovery key) that is the same on every device in the group. You can confirm both devices are in the same group by checking that the Sync group ID matches on each.
## More than two devices
+3 -2
View File
@@ -51,11 +51,12 @@ function updateSyncStatus() {
let rows = '';
if (linkedDevices.length > 0) {
linkedDevices.forEach((d, i) => {
const label = d.isCurrent ? 'This device' : ('Device ' + (i + 1));
const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1)));
rows += '<tr><td>' + escapeHtml(label) + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td></tr>';
});
} else {
rows = '<tr><td>This device</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td></tr>';
const curName = response.deviceName || 'This device';
rows = '<tr><td>' + escapeHtml(curName) + '</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;
+2 -1
View File
@@ -105,7 +105,8 @@ function loadState() {
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
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: (data.deviceNames && typeof data.deviceNames === 'object') ? { ...data.deviceNames } : {}
};
debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length);
if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
+42 -5
View File
@@ -7,6 +7,7 @@
const path = require('bare-path');
const fs = require('bare-fs');
const b4a = require('b4a');
const os = require('bare-os');
const STATE_KEY = 'holesail-state';
const IDENTITY_FILENAME = 'autopass-identity.json';
@@ -201,6 +202,11 @@ async function applySyncedState(snapshot) {
if (!statePath) return;
applyingSync = true;
try {
snapshot.deviceNames = snapshot.deviceNames || {};
if (pass && pass.writerKey) {
const myId = shortId(pass.writerKey);
if (myId) snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device';
}
const dir = path.dirname(statePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const json = JSON.stringify(snapshot, null, 2);
@@ -218,12 +224,25 @@ async function applySyncedState(snapshot) {
}
/**
* Called by holesail-manager after each saveState(). Push snapshot to autopass (if linked and not applying).
* Called by holesail-manager after each saveState(). Merge this device's hostname into state, write to disk, then push to autopass (if linked and not applying).
*/
function onStateSaved(snapshot) {
if (applyingSync || !pass) return;
(async () => {
try {
const myId = shortId(pass.writerKey);
if (myId) {
snapshot.deviceNames = snapshot.deviceNames || {};
snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device';
}
const statePath = getStateFilePath();
if (statePath) {
try {
fs.writeFileSync(statePath, JSON.stringify(snapshot, null, 2), 'utf8');
} catch (e) {
if (process.stderr) process.stderr.write('[sync-manager] write deviceNames failed: ' + e.message + '\n');
}
}
weJustPushed = true;
const value = JSON.stringify(snapshot);
await pass.add(STATE_KEY, value);
@@ -246,6 +265,18 @@ function shortId(buf) {
return hex.slice(0, 12);
}
function readDeviceNamesFromState() {
const statePath = getStateFilePath();
if (!statePath || !fs.existsSync(statePath)) return {};
try {
const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw);
return (data.deviceNames && typeof data.deviceNames === 'object') ? data.deviceNames : {};
} catch (_) {
return {};
}
}
async function getSyncStatus() {
await ensureInitialized();
const out = {
@@ -257,25 +288,31 @@ async function getSyncStatus() {
try {
out.deviceId = shortId(pass.writerKey) || null;
out.syncGroupId = shortId(pass.discoveryKey) || null;
out.deviceName = typeof os.hostname === 'function' ? os.hostname() : null;
out.linkedDevices = [];
const myKeyHex = b4a.toString(pass.writerKey, 'hex');
const deviceNames = readDeviceNamesFromState();
if (pass.base && pass.base.activeWriters) {
for (const w of pass.base.activeWriters) {
if (!w || !w.core || !w.core.key) continue;
const keyHex = b4a.toString(w.core.key, 'hex');
const id = shortId(w.core.key);
const isCurrent = keyHex === myKeyHex;
out.linkedDevices.push({
id: shortId(w.core.key),
isCurrent: keyHex === myKeyHex
id,
isCurrent,
name: deviceNames[id] || (isCurrent ? out.deviceName : null)
});
}
}
if (out.linkedDevices.length === 0 && out.deviceId) {
out.linkedDevices = [{ id: out.deviceId, isCurrent: true }];
out.linkedDevices = [{ id: out.deviceId, isCurrent: true, name: out.deviceName }];
}
} catch (e) {
out.deviceId = null;
out.syncGroupId = null;
out.linkedDevices = out.deviceId ? [{ id: out.deviceId, isCurrent: true }] : [];
out.deviceName = null;
out.linkedDevices = out.deviceId ? [{ id: out.deviceId, isCurrent: true, name: null }] : [];
}
}
return out;