feat(sync): Delink and Disband with master state retention
CI / Build & Test (push) Successful in 4m4s

- Delink: any peer can leave; removeWriter notifies others, local state reset to defaults.
- Disband: master only; push syncGroupDisbanded so peers reset; master exports state to
  state.json, then reloads (cleanup + restorePersistedState + restorePersistedTunnels).
- Skip handleDisbandFromMaster when receiver is master so master state is not reset.
- Store syncMasterDeviceId in synced state so MASTER badge shows on all devices.
- UI: show Delink for peers, Disband for master only; confirm modals for both.
This commit is contained in:
Raven Scott
2026-03-15 06:22:40 -04:00
parent 1f5ec4bd14
commit ad9c22f1b6
+30 -3
View File
@@ -213,6 +213,8 @@ async function applyRemoteUpdate() {
if (!str) return;
const snapshot = JSON.parse(str);
if (snapshot.syncGroupDisbanded === true) {
const identity = loadIdentity();
if (identity && identity.isMaster) return;
await handleDisbandFromMaster();
return;
}
@@ -693,8 +695,8 @@ async function delink() {
}
/**
* 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.
* Disband the sync group (master only). Exports current state to state.json, pushes
* syncGroupDisbanded so peers reset, then removes sync data and reloads state from state.json.
*/
async function disband() {
if (!storageDir || !holesailManager) return { ok: false, error: 'Storage path or holesail manager not set' };
@@ -703,8 +705,23 @@ async function disband() {
await ensureInitialized();
if (!pass) return { ok: false, error: 'Not linked' };
const setStateSaveSuppressed = holesailManager.setStateSaveSuppressed;
const setTunnelsRestoredPromiseFn = setTunnelsRestoredPromise;
const restorePersistedTunnelsFn = restorePersistedTunnels;
try {
const statePath = getStateFilePath();
const snapshot = holesailManager.getStateSnapshot();
const deviceNames = readDeviceNamesFromState();
if (Object.keys(deviceNames).length > 0) snapshot.deviceNames = deviceNames;
delete snapshot.syncGroupDisbanded;
if (statePath) {
try {
fs.writeFileSync(statePath, JSON.stringify(snapshot, null, 2), 'utf8');
log('Sync: state exported to state.json');
} catch (e) {
if (process.stderr) process.stderr.write('[sync-manager] disband export failed: ' + e.message + '\n');
return { ok: false, error: e.message };
}
}
snapshot.syncGroupDisbanded = true;
if (typeof setStateSaveSuppressed === 'function') setStateSaveSuppressed(true);
try {
@@ -716,8 +733,18 @@ async function disband() {
await closePass();
removeAutopassDir();
clearIdentity();
initPromise = null;
lastSyncedAt = null;
log('Sync: group disbanded by master');
if (setTunnelsRestoredPromiseFn && restorePersistedTunnelsFn) {
await holesailManager.cleanup().catch(() => {});
const restored = holesailManager.restorePersistedState();
const restorePromise = restorePersistedTunnelsFn(holesailManager, restored).catch((e) =>
log('Sync: post-disband restore failed:', e.message)
);
setTunnelsRestoredPromiseFn(restorePromise);
await restorePromise;
}
log('Sync: group disbanded by master; state reloaded from state.json');
return { ok: true };
} catch (e) {
if (typeof setStateSaveSuppressed === 'function') setStateSaveSuppressed(false);