fix(sync): stop sync loop; add link warning and linked-devices table
CI / Build & Test (push) Successful in 4m22s
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:
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user