@@ -623,6 +623,10 @@
|
||||
<tr><td colspan="2" class="empty-cell">Not linked</td></tr>
|
||||
</tbody>
|
||||
</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>
|
||||
@@ -1334,6 +1338,48 @@
|
||||
</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 ────────────────── -->
|
||||
<div class="modal-backdrop" id="modal-linkDevice">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-linkDevice-title" style="max-width:420px;">
|
||||
|
||||
@@ -46,7 +46,9 @@ function updateSyncStatus() {
|
||||
}
|
||||
const card = $('syncLinkedDevicesCard');
|
||||
const tbody = $('syncLinkedDevicesTable');
|
||||
const btnDisband = $('btnDisband');
|
||||
if (card) card.style.display = 'block';
|
||||
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
|
||||
if (tbody) {
|
||||
const syncGroupId = response.syncGroupId || '—';
|
||||
const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : [];
|
||||
@@ -55,7 +57,7 @@ function updateSyncStatus() {
|
||||
if (linkedDevices.length > 0) {
|
||||
linkedDevices.forEach((d, i) => {
|
||||
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>';
|
||||
});
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,6 +174,20 @@ async function cleanup() {
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
@@ -186,6 +200,7 @@ module.exports = {
|
||||
setStateSaveSuppressed,
|
||||
getStateSnapshot,
|
||||
applySnapshotData,
|
||||
resetStateToDefaults,
|
||||
// Settings
|
||||
getSettings: settingsModule.getSettings,
|
||||
updateSettings: settingsModule.updateSettings,
|
||||
|
||||
@@ -39,6 +39,28 @@ function register(deps) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -212,6 +212,10 @@ async function applyRemoteUpdate() {
|
||||
const str = typeof entry.value === 'string' ? entry.value : (entry.value && entry.value.toString ? entry.value.toString() : '');
|
||||
if (!str) return;
|
||||
const snapshot = JSON.parse(str);
|
||||
if (snapshot.syncGroupDisbanded === true) {
|
||||
await handleDisbandFromMaster();
|
||||
return;
|
||||
}
|
||||
const statePath = getStateFilePath();
|
||||
if (statePath && fs.existsSync(statePath)) {
|
||||
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).
|
||||
*/
|
||||
@@ -389,6 +420,13 @@ function onStateSaved(snapshot) {
|
||||
snapshot.deviceNames = snapshot.deviceNames || {};
|
||||
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();
|
||||
if (statePath) {
|
||||
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() {
|
||||
await ensureInitialized();
|
||||
const out = {
|
||||
@@ -448,6 +499,7 @@ async function getSyncStatus() {
|
||||
out.linkedDevices = [];
|
||||
const myKeyHex = b4a.toString(pass.writerKey, 'hex');
|
||||
const deviceNames = readDeviceNamesFromState();
|
||||
const masterDeviceId = readSyncMasterDeviceIdFromState();
|
||||
if (pass.base && pass.base.activeWriters) {
|
||||
for (const w of pass.base.activeWriters) {
|
||||
if (!w || !w.core || !w.core.key) continue;
|
||||
@@ -457,12 +509,13 @@ async function getSyncStatus() {
|
||||
out.linkedDevices.push({
|
||||
id,
|
||||
isCurrent,
|
||||
isMaster: id === masterDeviceId,
|
||||
name: deviceNames[id] || (isCurrent ? out.deviceName : null)
|
||||
});
|
||||
}
|
||||
}
|
||||
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)
|
||||
const myId = out.deviceId;
|
||||
@@ -533,7 +586,14 @@ async function createSyncInvite() {
|
||||
saveIdentity(pass.key, pass.encryptionKey, true);
|
||||
currentInvite = await pass.createInvite();
|
||||
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');
|
||||
return { ok: true, invite: currentInvite };
|
||||
} 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() {
|
||||
initPromise = null;
|
||||
weJustPushed = false;
|
||||
@@ -613,6 +744,8 @@ module.exports = {
|
||||
getSyncStatus,
|
||||
createSyncInvite,
|
||||
pairWithInvite,
|
||||
delink,
|
||||
disband,
|
||||
onStateSaved,
|
||||
applySyncedState,
|
||||
closeSyncForBackup,
|
||||
|
||||
Reference in New Issue
Block a user