test updates
CI / Build & Test (push) Successful in 4m5s

This commit is contained in:
Raven Scott
2026-03-15 06:00:44 -04:00
parent c29968b079
commit 3d70f322cc
5 changed files with 277 additions and 3 deletions
+46
View File
@@ -623,6 +623,10 @@
<tr><td colspan="2" class="empty-cell">Not linked</td></tr> <tr><td colspan="2" class="empty-cell">Not linked</td></tr>
</tbody> </tbody>
</table> </table>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
<button class="btn btn-secondary" id="btnDelink">Delink</button>
<button class="btn btn-secondary" id="btnDisband" style="display:none;">Disband group</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1334,6 +1338,48 @@
</div> </div>
<!-- ── Modal: Delink (sync) confirm ────────────────────── -->
<div class="modal-backdrop" id="modal-delink">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-delink-title" style="max-width:420px;">
<div class="modal-header">
<span class="modal-title" id="modal-delink-title">Delink this device</span>
<button class="btn-icon" data-close-modal="modal-delink" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<p class="modal-desc">This will remove this device from the sync group. Peers and the master will see you leave. Your local state (virtual hosts, tunnels, SSH/RDP connections, settings) will be <strong>reset to defaults</strong>. This cannot be undone.</p>
<p class="modal-desc" style="margin-top:8px;color:var(--amber);">Create a backup first if you want to keep a copy.</p>
<div class="modal-error" id="delinkError"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-close-modal="modal-delink">Cancel</button>
<button class="btn btn-primary" id="delinkConfirm">Delink and reset</button>
</div>
</div>
</div>
<!-- ── Modal: Disband (sync) confirm ───────────────────── -->
<div class="modal-backdrop" id="modal-disband">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-disband-title" style="max-width:420px;">
<div class="modal-header">
<span class="modal-title" id="modal-disband-title">Disband sync group</span>
<button class="btn-icon" data-close-modal="modal-disband" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<p class="modal-desc">As the master, you can disband the sync group. <strong>All other linked devices will be reset to defaults</strong> and removed from the group; they can create a new group or join another. Your own state on this device is kept.</p>
<p class="modal-desc" style="margin-top:8px;">This device will no longer be linked after disbanding.</p>
<div class="modal-error" id="disbandError"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-close-modal="modal-disband">Cancel</button>
<button class="btn btn-primary" id="disbandConfirm">Disband group</button>
</div>
</div>
</div>
<!-- ── Modal: Link device (sync) confirm ────────────────── --> <!-- ── Modal: Link device (sync) confirm ────────────────── -->
<div class="modal-backdrop" id="modal-linkDevice"> <div class="modal-backdrop" id="modal-linkDevice">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-linkDevice-title" style="max-width:420px;"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-linkDevice-title" style="max-width:420px;">
+59 -1
View File
@@ -46,7 +46,9 @@ function updateSyncStatus() {
} }
const card = $('syncLinkedDevicesCard'); const card = $('syncLinkedDevicesCard');
const tbody = $('syncLinkedDevicesTable'); const tbody = $('syncLinkedDevicesTable');
const btnDisband = $('btnDisband');
if (card) card.style.display = 'block'; if (card) card.style.display = 'block';
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
if (tbody) { if (tbody) {
const syncGroupId = response.syncGroupId || '—'; const syncGroupId = response.syncGroupId || '—';
const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : []; const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : [];
@@ -55,7 +57,7 @@ function updateSyncStatus() {
if (linkedDevices.length > 0) { if (linkedDevices.length > 0) {
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.isCurrent && response.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>'; rows += '<tr><td>' + escapeHtml(label) + masterBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td></tr>';
}); });
} else { } else {
@@ -166,4 +168,60 @@ function setupSyncEvents() {
} }
); );
}); });
const btnDelink = $('btnDelink');
const btnDisband = $('btnDisband');
btnDelink?.addEventListener('click', () => {
const errEl = $('delinkError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
openModal('modal-delink');
});
$('delinkConfirm')?.addEventListener('click', () => {
closeModal('modal-delink');
if (btnDelink) { btnDelink.disabled = true; btnDelink.textContent = 'Delinking…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'delink' } },
(response) => {
if (btnDelink) { btnDelink.disabled = false; btnDelink.textContent = 'Delink'; }
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
}
if (response && response.ok) {
showToast('Device delinked. State reset to defaults.', 'success');
updateSyncStatus();
if (typeof refresh === 'function') refresh();
} else {
showToast(response && response.error ? response.error : 'Delink failed', 'error');
}
}
);
});
btnDisband?.addEventListener('click', () => {
const errEl = $('disbandError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
openModal('modal-disband');
});
$('disbandConfirm')?.addEventListener('click', () => {
closeModal('modal-disband');
if (btnDisband) { btnDisband.disabled = true; btnDisband.textContent = 'Disbanding…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'disband' } },
(response) => {
if (btnDisband) { btnDisband.disabled = false; btnDisband.textContent = 'Disband group'; }
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
}
if (response && response.ok) {
showToast('Sync group disbanded. Other devices have been reset.', 'success');
updateSyncStatus();
if (typeof refresh === 'function') refresh();
} else {
showToast(response && response.error ? response.error : 'Disband failed', 'error');
}
}
);
});
} }
+15
View File
@@ -174,6 +174,20 @@ async function cleanup() {
await svcModule.cleanupServiceTunnels(); await svcModule.cleanupServiceTunnels();
} }
/**
* Reset persistent state to defaults: write default state to disk, clear deviceNames cache,
* then return the result of restorePersistedState() so the caller can run restorePersistedTunnels.
* Call cleanup() before this. Used by sync-manager when a device delinks or when peers receive a disband.
* @returns {{settings: object, servers: Array, virtualHosts: Array, serviceTunnels: Array, sshConnections: Array, rdpConnections: Array}}
*/
async function resetStateToDefaults() {
await cleanup();
const defaultState = stateModule.buildDefaultState();
stateModule.saveStateSync(defaultState);
stateModule.setDeviceNames({});
return restorePersistedState();
}
// ── Re-export full original API ─────────────────────────────────────────────── // ── Re-export full original API ───────────────────────────────────────────────
module.exports = { module.exports = {
@@ -186,6 +200,7 @@ module.exports = {
setStateSaveSuppressed, setStateSaveSuppressed,
getStateSnapshot, getStateSnapshot,
applySnapshotData, applySnapshotData,
resetStateToDefaults,
// Settings // Settings
getSettings: settingsModule.getSettings, getSettings: settingsModule.getSettings,
updateSettings: settingsModule.updateSettings, updateSettings: settingsModule.updateSettings,
+22
View File
@@ -39,6 +39,28 @@ function register(deps) {
reply({ ok: false, error: e.message }); reply({ ok: false, error: e.message });
} }
} }
},
{
type: 'delink',
handle: async (payload, reply) => {
try {
const result = await syncManager.delink();
reply(result);
} catch (e) {
reply({ ok: false, error: e.message });
}
}
},
{
type: 'disband',
handle: async (payload, reply) => {
try {
const result = await syncManager.disband();
reply(result);
} catch (e) {
reply({ ok: false, error: e.message });
}
}
} }
]; ];
} }
+135 -2
View File
@@ -212,6 +212,10 @@ async function applyRemoteUpdate() {
const str = typeof entry.value === 'string' ? entry.value : (entry.value && entry.value.toString ? entry.value.toString() : ''); const str = typeof entry.value === 'string' ? entry.value : (entry.value && entry.value.toString ? entry.value.toString() : '');
if (!str) return; if (!str) return;
const snapshot = JSON.parse(str); const snapshot = JSON.parse(str);
if (snapshot.syncGroupDisbanded === true) {
await handleDisbandFromMaster();
return;
}
const statePath = getStateFilePath(); const statePath = getStateFilePath();
if (statePath && fs.existsSync(statePath)) { if (statePath && fs.existsSync(statePath)) {
try { try {
@@ -228,6 +232,33 @@ async function applyRemoteUpdate() {
} }
} }
/**
* Called when this device receives a state update with syncGroupDisbanded: true (master disbanded the group).
* Resets local state to defaults and removes sync data so this device can create or join another group.
*/
async function handleDisbandFromMaster() {
if (!holesailManager || !setTunnelsRestoredPromise || !restorePersistedTunnels) return;
applyingSync = true;
try {
if (pass && pass.off) pass.off('update', onRemoteUpdate);
await closePass();
removeAutopassDir();
clearIdentity();
initPromise = null;
const restored = await holesailManager.resetStateToDefaults();
const restorePromise = restorePersistedTunnels(holesailManager, restored).catch((e) =>
log('Post-disband tunnel restore failed:', e.message)
);
setTunnelsRestoredPromise(restorePromise);
await restorePromise;
lastSyncedAt = null;
if (emitEvent) emitEvent('syncApplied', {});
log('Sync: disbanded by master; state reset to defaults');
} finally {
applyingSync = false;
}
}
/** /**
* Compare two objects by relevant keys (for tunnel configs). * Compare two objects by relevant keys (for tunnel configs).
*/ */
@@ -389,6 +420,13 @@ function onStateSaved(snapshot) {
snapshot.deviceNames = snapshot.deviceNames || {}; snapshot.deviceNames = snapshot.deviceNames || {};
snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device'; snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device';
} }
const identity = loadIdentity();
if (identity && identity.isMaster) {
snapshot.syncMasterDeviceId = myId;
} else {
const existing = readSyncMasterDeviceIdFromState();
if (existing) snapshot.syncMasterDeviceId = existing;
}
const statePath = getStateFilePath(); const statePath = getStateFilePath();
if (statePath) { if (statePath) {
try { try {
@@ -431,6 +469,19 @@ function readDeviceNamesFromState() {
} }
} }
function readSyncMasterDeviceIdFromState() {
const statePath = getStateFilePath();
if (!statePath || !fs.existsSync(statePath)) return null;
try {
const raw = fs.readFileSync(statePath, 'utf8');
const data = JSON.parse(raw);
const id = data.syncMasterDeviceId;
return typeof id === 'string' && id.length > 0 ? id : null;
} catch (_) {
return null;
}
}
async function getSyncStatus() { async function getSyncStatus() {
await ensureInitialized(); await ensureInitialized();
const out = { const out = {
@@ -448,6 +499,7 @@ async function getSyncStatus() {
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 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) {
if (!w || !w.core || !w.core.key) continue; if (!w || !w.core || !w.core.key) continue;
@@ -457,12 +509,13 @@ async function getSyncStatus() {
out.linkedDevices.push({ out.linkedDevices.push({
id, id,
isCurrent, isCurrent,
isMaster: id === masterDeviceId,
name: deviceNames[id] || (isCurrent ? out.deviceName : null) name: deviceNames[id] || (isCurrent ? out.deviceName : null)
}); });
} }
} }
if (out.linkedDevices.length === 0 && out.deviceId) { if (out.linkedDevices.length === 0 && out.deviceId) {
out.linkedDevices = [{ id: out.deviceId, isCurrent: true, name: out.deviceName }]; out.linkedDevices = [{ id: out.deviceId, isCurrent: true, isMaster: out.deviceId === masterDeviceId || out.isMaster, name: out.deviceName }];
} }
// Ensure our hostname is published so other devices see our name (fire-and-forget) // Ensure our hostname is published so other devices see our name (fire-and-forget)
const myId = out.deviceId; const myId = out.deviceId;
@@ -533,7 +586,14 @@ async function createSyncInvite() {
saveIdentity(pass.key, pass.encryptionKey, true); saveIdentity(pass.key, pass.encryptionKey, true);
currentInvite = await pass.createInvite(); currentInvite = await pass.createInvite();
const snapshot = readCurrentStateFromDisk(); const snapshot = readCurrentStateFromDisk();
if (snapshot) await pass.add(STATE_KEY, JSON.stringify(snapshot)); if (snapshot) {
snapshot.syncMasterDeviceId = shortId(pass.writerKey);
const statePath = getStateFilePath();
if (statePath) {
try { fs.writeFileSync(statePath, JSON.stringify(snapshot, null, 2), 'utf8'); } catch (_) {}
}
await pass.add(STATE_KEY, JSON.stringify(snapshot));
}
log('Sync: created new sync group (this device is Master) and invite'); log('Sync: created new sync group (this device is Master) and invite');
return { ok: true, invite: currentInvite }; return { ok: true, invite: currentInvite };
} catch (e) { } catch (e) {
@@ -595,6 +655,77 @@ function readCurrentStateFromDisk() {
} }
} }
/**
* Delink this device: notify peers (removeWriter), remove sync data, reset state to defaults.
* Peers will see this device drop from activeWriters and can remove it from their linked list.
*/
async function delink() {
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
await ensureInitialized();
if (!pass) return { ok: false, error: 'Not linked' };
const setTunnelsRestoredPromiseFn = setTunnelsRestoredPromise;
const restorePersistedTunnelsFn = restorePersistedTunnels;
try {
try {
await pass.removeWriter(pass.writerKey);
if (pass.member && typeof pass.member.flushed === 'function') await pass.member.flushed();
} catch (e) {
if (process.stderr) process.stderr.write('[sync-manager] delink removeWriter: ' + e.message + '\n');
}
await closePass();
removeAutopassDir();
clearIdentity();
const restored = await holesailManager.resetStateToDefaults();
if (setTunnelsRestoredPromiseFn && restorePersistedTunnelsFn) {
const restorePromise = restorePersistedTunnelsFn(holesailManager, restored).catch((e) =>
log('Post-delink tunnel restore failed:', e.message)
);
setTunnelsRestoredPromiseFn(restorePromise);
await restorePromise;
}
lastSyncedAt = null;
log('Sync: delinked; state reset to defaults');
return { ok: true };
} catch (e) {
if (process.stderr) process.stderr.write('[sync-manager] delink failed: ' + e.message + '\n');
return { ok: false, error: e.message };
}
}
/**
* Disband the sync group (master only). Pushes state with syncGroupDisbanded: true so peers
* reset to defaults and remove their sync data. Master keeps local state but removes own sync data.
*/
async function disband() {
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
const identity = loadIdentity();
if (!identity || !identity.isMaster) return { ok: false, error: 'Only the master can disband the sync group' };
await ensureInitialized();
if (!pass) return { ok: false, error: 'Not linked' };
const setStateSaveSuppressed = holesailManager.setStateSaveSuppressed;
try {
const snapshot = holesailManager.getStateSnapshot();
snapshot.syncGroupDisbanded = true;
if (typeof setStateSaveSuppressed === 'function') setStateSaveSuppressed(true);
try {
await pass.add(STATE_KEY, JSON.stringify(snapshot));
if (pass.member && typeof pass.member.flushed === 'function') await pass.member.flushed();
} finally {
if (typeof setStateSaveSuppressed === 'function') setStateSaveSuppressed(false);
}
await closePass();
removeAutopassDir();
clearIdentity();
lastSyncedAt = null;
log('Sync: group disbanded by master');
return { ok: true };
} catch (e) {
if (typeof setStateSaveSuppressed === 'function') setStateSaveSuppressed(false);
if (process.stderr) process.stderr.write('[sync-manager] disband failed: ' + e.message + '\n');
return { ok: false, error: e.message };
}
}
async function cleanup() { async function cleanup() {
initPromise = null; initPromise = null;
weJustPushed = false; weJustPushed = false;
@@ -613,6 +744,8 @@ module.exports = {
getSyncStatus, getSyncStatus,
createSyncInvite, createSyncInvite,
pairWithInvite, pairWithInvite,
delink,
disband,
onStateSaved, onStateSaved,
applySyncedState, applySyncedState,
closeSyncForBackup, closeSyncForBackup,