fix(sync): stop sync loop; add link warning and linked-devices table
CI / Build & Test (push) Successful in 4m22s

- Await restorePersistedTunnels so applyingSync stays true for full restore,
  preventing saveState() during tunnel startup from pushing back and looping
- Debounce onRemoteUpdate (600ms) and skip apply when state unchanged
  (canonical fingerprint) to avoid redundant restarts
- Add confirm modal before "Link device": warns that current state will be
  replaced and suggests creating a backup first
- Add Linked devices table when linked: show This device ID (writerKey) and
  Sync group ID (discoveryKey); getSyncStatus returns deviceId and syncGroupId
This commit is contained in:
Raven Scott
2026-03-15 01:47:34 -04:00
parent 33ea92cd43
commit 925376a6bd
3 changed files with 87 additions and 7 deletions
+38
View File
@@ -609,6 +609,22 @@
<div id="syncInviteDisplay" style="display:none;margin-top:12px;padding:10px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);font-family:var(--font-mono);font-size:12px;word-break:break-all;"></div>
</div>
</div>
<div class="card" id="syncLinkedDevicesCard" style="display:none;margin-bottom:16px;">
<div class="card-body flush">
<div class="section-heading" style="padding:0 0 10px 0;">Linked devices</div>
<table class="table">
<thead>
<tr>
<th>Device</th>
<th>ID</th>
</tr>
</thead>
<tbody id="syncLinkedDevicesTable">
<tr><td colspan="2" class="empty-cell">Not linked</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── Settings page ──────────────────────────── -->
@@ -1318,6 +1334,28 @@
</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;">
<div class="modal-header">
<span class="modal-title" id="modal-linkDevice-title">Link device</span>
<button class="btn-icon" data-close-modal="modal-linkDevice" 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">Linking will <strong>replace all current state on this device</strong> with the state from the other device.</p>
<p class="modal-desc" style="margin-top:8px;color:var(--amber);">Your virtual hosts, server tunnels, service tunnels, SSH and RDP connections, and settings on this device will be removed and replaced. This cannot be undone. Create a backup first if you want to keep a copy.</p>
<p class="modal-desc" style="margin-top:8px;">After linking, both devices will stay in sync: changes on either device will update the other.</p>
<div class="modal-error" id="linkDeviceError"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-close-modal="modal-linkDevice">Cancel</button>
<button class="btn btn-primary" id="linkDeviceConfirm">Replace state and link</button>
</div>
</div>
</div>
<!-- ── Modal: Holesail Lookup Result ───────────────────── -->
<div class="modal-backdrop" id="modal-holesailLookup">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-holesailLookup-title" style="max-width:420px;">
+29 -6
View File
@@ -1,6 +1,6 @@
/**
* Sync page — device linking via autopass; create invite, pair with invite, show status.
* Depends: core/utils.js ($), core/messaging.js (sendToNative), ui/toast.js (showToast)
* Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast)
*/
/**
@@ -41,12 +41,25 @@ function updateSyncStatus() {
if (btnCopy) btnCopy.style.display = 'none';
if (inviteDisplay) inviteDisplay.style.display = 'none';
}
const card = $('syncLinkedDevicesCard');
const tbody = $('syncLinkedDevicesTable');
if (card) card.style.display = 'block';
if (tbody) {
const deviceId = response.deviceId || '—';
const syncGroupId = response.syncGroupId || '—';
tbody.innerHTML = '<tr><td>This device</td><td class="mono" style="font-size:12px;">' + escapeHtml(deviceId) + '</td></tr>' +
'<tr><td>Sync group</td><td class="mono" style="font-size:12px;">' + escapeHtml(syncGroupId) + '</td></tr>';
}
} else {
if (dot) dot.style.background = 'var(--text4)';
text.textContent = 'Not linked';
if (lastSynced) lastSynced.textContent = '';
if (btnCopy) btnCopy.style.display = 'none';
if (inviteDisplay) inviteDisplay.style.display = 'none';
const card = $('syncLinkedDevicesCard');
const tbody = $('syncLinkedDevicesTable');
if (card) card.style.display = 'none';
if (tbody) tbody.innerHTML = '<tr><td colspan="2" class="empty-cell">Not linked</td></tr>';
}
}
);
@@ -103,14 +116,24 @@ function setupSyncEvents() {
showToast('Paste an invite first', 'error');
return;
}
if (btnPair.disabled) return;
btnPair.disabled = true;
btnPair.textContent = 'Linking…';
const errEl = $('linkDeviceError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
openModal('modal-linkDevice');
});
$('linkDeviceConfirm')?.addEventListener('click', () => {
const invite = inviteInput?.value?.trim();
const errEl = $('linkDeviceError');
if (!invite) {
if (errEl) { errEl.textContent = 'Paste an invite in the field below first.'; errEl.style.display = 'block'; }
return;
}
closeModal('modal-linkDevice');
if (btnPair) { btnPair.disabled = true; btnPair.textContent = 'Linking…'; }
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'pairWithInvite', payload: { invite } } },
(response) => {
btnPair.disabled = false;
btnPair.textContent = 'Link device';
if (btnPair) { btnPair.disabled = false; btnPair.textContent = 'Link device'; }
if (chrome.runtime.lastError) {
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
return;
+20 -1
View File
@@ -237,13 +237,32 @@ function onStateSaved(snapshot) {
})();
}
/**
* Short readable ID from a buffer (first 12 hex chars).
*/
function shortId(buf) {
if (!buf) return null;
const hex = b4a.toString(buf, 'hex');
return hex.slice(0, 12);
}
async function getSyncStatus() {
await ensureInitialized();
return {
const out = {
linked: !!pass,
invite: currentInvite || null,
lastSyncedAt: lastSyncedAt || null
};
if (pass) {
try {
out.deviceId = shortId(pass.writerKey) || null;
out.syncGroupId = shortId(pass.discoveryKey) || null;
} catch (e) {
out.deviceId = null;
out.syncGroupId = null;
}
}
return out;
}
async function createSyncInvite() {