Files
Haven/public/js/servers.js
T

886 lines
36 KiB
JavaScript

// ═══════════════════════════════════════════════════════════
// Haven: Multi-Server Manager
// See other Haven servers in your sidebar with live status
// ═══════════════════════════════════════════════════════════
class ServerManager {
constructor() {
this.servers = this._load();
this.statusCache = new Map();
this.checkInterval = null;
this.selfFingerprint = null;
// Servers the Desktop app says the user removed, on any server. Filled
// from the Desktop app's shared list; always empty in a browser.
this.desktopRemoved = new Set();
// Servers in the Desktop app's shared list, as last read.
this.desktopListed = new Set();
this.selfFingerprintReady = this._fetchSelfFingerprint();
// Desktop bootstrap: pull the cross-server history from the Electron
// main process synchronously (preload exposes it as a plain array).
// This means the sidebar shows the user's known network IMMEDIATELY
// on first-join to a new server, instead of waiting for an async sync.
this.bootstrappedFromDesktop = this._mergeDesktopBootstrap();
}
_desktop() {
return (typeof window !== 'undefined' && window.havenDesktop) || null;
}
/** True in a Desktop app that asks the user itself, in its own dialog,
* before a server page adds, removes or renames a server. This page then
* does not ask a second time, and hands the app no automatic changes
* (the app would only ask about them, or refuse them). */
desktopGated() {
const desktop = this._desktop();
return !!(desktop && desktop.serverListGated === true);
}
/** True when the Desktop app asks the user before this server is removed
* or renamed (it is in the app's list), so the page's own question is
* skipped. */
desktopAsksFor(url) {
return this.desktopGated() && this.desktopListed.has(this._normalizeUrl(url));
}
/** Merge the Desktop app's cross-server history into the local list.
* Returns true if any new servers were added. Removed-server tracking
* is honored so the user doesn't see servers they intentionally deleted. */
_mergeDesktopBootstrap() {
const desktop = this._desktop();
// Newer Desktop versions keep one shared list (removals, order, names).
const shared = desktop && desktop.initialServerList;
if (shared && Array.isArray(shared.servers)) {
try { return this.applyDesktopList(shared).added > 0; }
catch (err) {
console.warn('[Servers] could not apply the Desktop server list', err);
return false;
}
}
try {
const history = (desktop && Array.isArray(desktop.initialServerHistory))
? desktop.initialServerHistory
: null;
if (!history || !history.length) return false;
const removed = this._loadRemoved();
const have = new Set(this.servers.map(s => this._normalizeUrl(s.url)));
let added = false;
for (const h of history) {
if (!h || !h.url) continue;
const url = this._normalizeUrl(h.url);
if (!url || have.has(url) || removed.has(url) || removed.has(h.url)) continue;
this.servers.push({
name: h.name || url,
url,
icon: h.icon || null,
iconData: h.iconData || null,
addedAt: h.lastConnected || Date.now(),
});
have.add(url);
added = true;
}
if (added) this._save();
return added;
} catch (err) {
console.warn('[Servers] could not read the Desktop server history', err);
return false;
}
}
// ── Desktop shared list ──────────────────────────────
// Each server keeps this list in its own browser storage, so without the
// Desktop app's shared copy the lists drift apart per server. With it, the
// Desktop list decides: removals, the order, and names the user chose.
/** True once this page has handed its own removals to the Desktop list;
* from then on the Desktop list wins over this page's removed set. */
_trustsDesktop() {
try { return localStorage.getItem('haven_servers_desktop_list') === '1'; }
catch { return false; } // storage blocked: keep this page's own removals
}
_isUrlName(entry) {
const name = String(entry && entry.name || '').trim();
return !name || name === entry.url || this._normalizeUrl(name) === this._normalizeUrl(entry.url);
}
/** Bring this page's list in line with the Desktop app's shared list
* ({ servers, removed, order, hasOrder }). Returns counts of what changed. */
applyDesktopList(list) {
const stats = { added: 0, removed: 0, renamed: 0 };
if (!list || !Array.isArray(list.servers)) return stats;
const gone = new Set((Array.isArray(list.removed) ? list.removed : []).map(u => this._normalizeUrl(u)).filter(Boolean));
this.desktopRemoved = gone;
this.desktopListed = new Set(list.servers.map(ds => this._normalizeUrl(ds && ds.url)).filter(Boolean));
const trusted = this._trustsDesktop();
const localRemoved = this._loadRemoved();
let removedChanged = false;
let changed = false;
// Removed on any server: removed here too.
const kept = [];
for (const s of this.servers) {
const url = this._normalizeUrl(s.url);
if (!gone.has(url)) { kept.push(s); continue; }
stats.removed++;
this.statusCache.delete(url);
if (!localRemoved.has(url)) { localRemoved.add(url); removedChanged = true; }
}
if (kept.length !== this.servers.length) { this.servers = kept; changed = true; }
// Known to the app but not to this page, and newer edits from elsewhere.
const byUrl = new Map(this.servers.map(s => [this._normalizeUrl(s.url), s]));
for (const ds of list.servers) {
const url = this._normalizeUrl(ds && ds.url);
if (!url || gone.has(url)) continue;
const local = byUrl.get(url);
if (local) {
const edit = this._adoptDesktopEdit(local, ds);
if (edit.changed) changed = true;
if (edit.renamed) stats.renamed++;
continue;
}
if (localRemoved.has(url)) {
// Until this page has handed its removals over, they still count.
if (!trusted) continue;
localRemoved.delete(url);
removedChanged = true;
}
const entry = {
name: ds.name || url,
url,
icon: ds.customIcon && ds.icon ? ds.icon : null,
addedAt: ds.lastConnected || Date.now(),
};
if (ds.customName) entry.customName = true;
if (ds.customIcon && ds.icon) entry.customIcon = true;
if (ds.editedAt) entry.editedAt = Number(ds.editedAt) || 0;
this.servers.push(entry);
byUrl.set(url, entry);
stats.added++;
changed = true;
}
if (list.hasOrder && Array.isArray(list.order) && this._applyOrder(list.order)) changed = true;
if (removedChanged) this._saveRemoved(localRemoved);
if (changed) this._save();
return stats;
}
/** The newer user edit of a name or icon wins. Without any user edit, a
* real name from the app replaces a bare address. */
_adoptDesktopEdit(local, ds) {
const theirs = Number(ds.editedAt) || 0;
const mine = Number(local.editedAt) || 0;
const before = local.name;
let changed = false;
if (theirs > mine) {
if (ds.name) local.name = ds.name;
if (ds.customName) local.customName = true;
else delete local.customName;
if (ds.customIcon && ds.icon) {
if (local.icon !== ds.icon) { local.icon = ds.icon; local.iconData = null; }
local.customIcon = true;
} else if (local.customIcon) {
delete local.customIcon;
local.icon = null;
local.iconData = null;
}
local.editedAt = theirs;
changed = true;
} else if (theirs === mine && !local.customName && !ds.customName && ds.name
&& this._isUrlName(local) && !this._isUrlName(ds)) {
local.name = ds.name;
changed = true;
}
return { changed, renamed: local.name !== before };
}
/** Sort by a list of addresses; servers it does not list go last, in the
* order they had. Returns true when the order changed. */
_applyOrder(order) {
const rank = new Map();
order.forEach((u, i) => { const n = this._normalizeUrl(u); if (n && !rank.has(n)) rank.set(n, i); });
const before = this.servers.map(s => s.url).join('\n');
const indexed = this.servers.map((s, i) => ({ s, i, r: rank.has(this._normalizeUrl(s.url)) ? rank.get(this._normalizeUrl(s.url)) : Infinity }));
indexed.sort((a, b) => (a.r - b.r) || (a.i - b.i));
this.servers = indexed.map(x => x.s);
return this.servers.map(s => s.url).join('\n') !== before;
}
/** Reconcile with the Desktop app: take its removals, servers, order and
* names, and give it what only this page knows. Throws when the Desktop
* app cannot be reached. Returns counts of what changed here. */
async reconcileWithDesktop() {
const desktop = this._desktop();
if (!desktop) return { added: 0, removed: 0, renamed: 0 };
if (typeof desktop.getServerList !== 'function') return this._legacyDesktopMerge(desktop);
let list = await desktop.getServerList();
if (!list || !Array.isArray(list.servers)) return { added: 0, removed: 0, renamed: 0 };
// A Desktop app that asks before every removal would ask about each of
// these, so there this page's earlier removals stay its own.
if (!this._trustsDesktop() && !this.desktopGated()) {
// First time with the shared list: servers removed on this server are
// removed for every server, then the shared list leads.
const gone = new Set((list.removed || []).map(u => this._normalizeUrl(u)));
const here = this._normalizeUrl(typeof location !== 'undefined' ? location.origin : '');
const mine = [...this._loadRemoved()].map(u => this._normalizeUrl(u)).filter(u => u && u !== here && !gone.has(u));
if (mine.length && typeof desktop.removeServerHistory === 'function') {
for (const url of mine) await desktop.removeServerHistory(url);
list = await desktop.getServerList();
}
try { localStorage.setItem('haven_servers_desktop_list', '1'); }
catch (err) { console.warn('[Servers] could not save the Desktop list flag', err); }
}
const stats = this.applyDesktopList(list);
await this._pushToDesktop(desktop, list);
return stats;
}
/** Give the Desktop app the servers, user edits and first order that only
* this page has. A Desktop app that asks before every change only gets
* the order: servers and edits reach it when the user makes them. */
async _pushToDesktop(desktop, list) {
const known = new Map(list.servers.map(s => [this._normalizeUrl(s.url), s]));
for (const s of (this.desktopGated() ? [] : this.servers)) {
const url = this._normalizeUrl(s.url);
if (this.desktopRemoved.has(url)) continue;
const ds = known.get(url);
if (!ds && typeof desktop.addServerHistory === 'function') await desktop.addServerHistory(url, s.name);
if (s.editedAt && (!ds || (Number(ds.editedAt) || 0) < s.editedAt)) await this._pushEdit(s);
}
if (!list.hasOrder && this.servers.length > 1 && typeof desktop.setServerOrder === 'function') {
await desktop.setServerOrder(this.servers.map(s => s.url));
}
}
/** user: the user made this edit just now. A Desktop app that asks first
* only asks about edits marked this way; it takes unmarked ones for
* syncing and leaves its list as it is. */
_pushEdit(server, { user = false } = {}) {
const desktop = this._desktop();
if (!desktop || typeof desktop.updateServerName !== 'function') return Promise.resolve();
const opts = {
custom: !!server.customName,
icon: server.customIcon ? (server.icon || null) : null,
editedAt: server.editedAt || Date.now(),
};
if (user) opts.user = true;
return Promise.resolve(desktop.updateServerName(server.url, server.name, opts));
}
/** Desktop versions without the shared list: the plain history, merged
* both ways, with this page's removals kept out. */
async _legacyDesktopMerge(desktop) {
const stats = { added: 0, removed: 0, renamed: 0 };
if (typeof desktop.getServerHistory !== 'function') return stats;
const history = await desktop.getServerHistory();
const removed = this._loadRemoved();
const historyUrls = new Set();
for (const h of (history || [])) {
if (!h || !h.url) continue;
const url = this._normalizeUrl(h.url);
historyUrls.add(url);
if (removed.has(url) || removed.has(h.url)) continue;
if (this.add(h.name || url, url)) stats.added++;
}
if (typeof desktop.addServerHistory === 'function') {
for (const s of this.servers) {
if (historyUrls.has(this._normalizeUrl(s.url))) continue;
Promise.resolve(desktop.addServerHistory(s.url, s.name))
.catch((err) => { console.warn('[Desktop] could not add to server history', err); });
}
}
return stats;
}
/** Fetch the current server's fingerprint so we can hide "self" from the sidebar. */
async _fetchSelfFingerprint() {
try {
const res = await fetch('/api/health');
if (res.ok) {
const data = await res.json();
if (data.fingerprint) this.selfFingerprint = data.fingerprint;
}
} catch (err) { console.warn('[Servers] could not read this server fingerprint', err); }
}
_load() {
try {
const raw = JSON.parse(localStorage.getItem('haven_servers') || '[]');
// Normalize URLs on load to dedup legacy entries while preserving
// subpath-hosted servers like https://host/community.
const seen = new Set();
const deduped = [];
for (const s of raw) {
const normalizedUrl = this._normalizeUrl(s?.url || '');
if (!normalizedUrl || seen.has(normalizedUrl)) continue;
s.url = normalizedUrl;
seen.add(normalizedUrl);
deduped.push(s);
}
const marked = this._markOldNamesCustom(deduped);
if (deduped.length !== raw.length || marked) {
localStorage.setItem('haven_servers', JSON.stringify(deduped));
}
return deduped;
} catch { return []; }
}
/** Before 4.19 a stored name never followed the server, so every name a
* list from then holds was typed by the user or kept on purpose. Once,
* mark those (not a bare address, not the default "Haven") as the
* user's own so the server's name does not replace them. editedAt 1 is
* older than any real edit: an edit made in the Desktop app wins, and
* the name is only handed to the app when it has no edit of its own.
* Returns true when an entry changed. */
_markOldNamesCustom(list) {
const KEY = 'haven_servers_names_kept';
if (localStorage.getItem(KEY) === '1') return false;
let changed = false;
for (const s of list) {
const name = String(s.name || '').trim();
if (s.customName || name === 'Haven' || this._isUrlName(s)) continue;
s.customName = true;
if (!s.editedAt) s.editedAt = 1;
changed = true;
}
try { localStorage.setItem(KEY, '1'); }
catch (err) { console.warn('[Servers] could not save the kept-names flag', err); }
return changed;
}
_save() {
localStorage.setItem('haven_servers', JSON.stringify(this.servers));
}
add(name, url, icon = null, opts = {}) {
url = this._normalizeUrl(url);
if (this.servers.find(s => this._normalizeUrl(s.url) === url)) return false;
const removed = this._loadRemoved();
if (opts.userInitiated) {
// User explicitly adding: clear from removed set so sync won't fight it
if (removed.has(url)) {
removed.delete(url);
this._saveRemoved(removed);
}
this.desktopRemoved.delete(url);
} else if (removed.has(url) || this.desktopRemoved.has(url)) {
// Bootstrap / sync path: never resurrect a server the user has removed,
// here or (in the Desktop app) on any other server.
return false;
}
const entry = { name, url, icon, addedAt: Date.now() };
// A name the user typed is theirs: the server's own name does not
// replace it (a bare address or the default "Haven" still follows it).
if (opts.customName && String(name || '').trim() !== 'Haven' && !this._isUrlName(entry)) {
entry.customName = true;
entry.editedAt = Date.now();
}
this.servers.push(entry);
this._save();
this.checkServer(url);
return true;
}
/** Hand a name the user chose to the Desktop app's shared list (call once
* the app knows the server). */
shareName(url) {
const normalizedUrl = this._normalizeUrl(url);
const server = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (!server || !server.customName) return Promise.resolve();
return this._pushEdit(server);
}
update(url, updates) {
const normalizedUrl = this._normalizeUrl(url);
const server = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (!server) return false;
if (updates.name !== undefined) server.name = updates.name;
if (updates.icon !== undefined) {
server.icon = updates.icon;
delete server.customIcon;
}
this._save();
return true;
}
/** The user edited a server's name or icon. A name other than the one the
* server reports is the user's own and is kept (and shared with every
* server in the Desktop app); the server's own name goes back to
* following the server. Returns true when the edit was made; in a
* Desktop app that asks first, a promise of that, false when the user
* said no there. */
editByUser(url, { name, icon = null }) {
const normalizedUrl = this._normalizeUrl(url);
const server = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (!server || !name) return false;
const status = this.statusCache.get(normalizedUrl);
const reported = status && status.online ? status : null;
const next = { ...server };
if (name !== next.name) next.customName = true;
if (reported && name === reported.name) delete next.customName;
next.name = name;
const newIcon = icon || null;
if (newIcon !== (next.icon || null)) {
next.icon = newIcon;
next.iconData = null;
}
if (newIcon && newIcon !== (reported && reported.icon)) next.customIcon = true;
else delete next.customIcon;
next.editedAt = Date.now();
const seen = (e) => [e.name, e.customIcon ? (e.icon || null) : null].join('\n');
if (this.desktopAsksFor(normalizedUrl) && seen(next) !== seen(server)) {
// The app asks the user; the edit is made here once it said yes.
return this._pushEdit(next, { user: true }).then((changed) => (changed === true ? this._replaceServer(server, next) : false));
}
this._replaceServer(server, next);
if (!this.desktopGated()) {
this._pushEdit(next).catch((err) => { console.warn('[Desktop] could not share the server name', err); });
}
return true;
}
_replaceServer(server, next) {
const i = this.servers.indexOf(server);
if (i < 0) return false;
this.servers[i] = next;
this._save();
return true;
}
remove(url) {
const normalizedUrl = this._removeHere(url);
// In the Desktop app the removal applies to every server's list.
const desktop = this._desktop();
if (desktop && typeof desktop.removeServerHistory === 'function') {
Promise.resolve(desktop.removeServerHistory(normalizedUrl))
.catch((err) => { console.warn('[Desktop] could not remove from server history', err); });
}
}
/** In a Desktop app that asks first: the app asks the user, and the
* server is removed here only once it is gone from the app's list.
* Marked as the user's own, since the app takes unmarked removals for
* syncing. Resolves to true when it was removed. */
async removeThroughDesktop(url) {
const normalizedUrl = this._normalizeUrl(url);
const history = await this._desktop().removeServerHistory(normalizedUrl, { user: true });
// Anything but a list without it means the app kept it.
if (!Array.isArray(history) || history.some(h => this._normalizeUrl(h && h.url) === normalizedUrl)) return false;
this._removeHere(normalizedUrl);
return true;
}
/** Remove a server from this page's own list. Returns its address. */
_removeHere(url) {
const normalizedUrl = this._normalizeUrl(url);
this.servers = this.servers.filter(s => this._normalizeUrl(s.url) !== normalizedUrl);
this.statusCache.delete(normalizedUrl);
this._save();
this.markRemoved(normalizedUrl);
if (normalizedUrl && this._desktop()) this.desktopRemoved.add(normalizedUrl);
return normalizedUrl;
}
/** Reorder servers by an array of URLs in the desired order. Servers the
* list leaves out (this server, a second address of one shown) keep
* their places; only the listed ones move among theirs. */
reorder(orderedUrls) {
const want = [...new Set((orderedUrls || []).map(u => this._normalizeUrl(u)))];
const byUrl = new Map(this.servers.map(s => [this._normalizeUrl(s.url), s]));
const moving = want.filter(u => byUrl.has(u));
const movingSet = new Set(moving);
let i = 0;
this.servers = this.servers.map(s => (movingSet.has(this._normalizeUrl(s.url)) ? byUrl.get(moving[i++]) : s));
this._save();
const desktop = this._desktop();
if (desktop && typeof desktop.setServerOrder === 'function') {
Promise.resolve(desktop.setServerOrder(this.servers.map(s => s.url)))
.catch((err) => { console.warn('[Desktop] could not share the server order', err); });
}
}
/** Every server with its last status. A server answering with the same
* fingerprint and the same hostname as one earlier in the list is the
* same server under a second address (another port or scheme) and
* carries duplicateOf: { url, name } of the first. The fingerprint is
* self-reported, so it alone never hides a server: any server could
* repeat another's to take its place on the rail. The one exception is an
* address on this machine or the local network, which a server out on the
* internet cannot answer for: that pairs with any address by fingerprint,
* so a server added both as localhost and by its public name shows once. */
getAll() {
const firstByFingerprint = new Map(); // fp -> [{ server, host }]
return this.servers.map(s => {
const status = this.statusCache.get(s.url) || { online: null, name: s.name };
const out = { ...s, status };
const fp = status.online === true ? status.fingerprint : null;
const host = fp ? this._hostnameOf(s.url) : null;
if (fp && host) {
const seen = firstByFingerprint.get(fp) || [];
const first = seen.find(e => e.host === host || this._isLocalHost(e.host) || this._isLocalHost(host));
if (first) out.duplicateOf = { url: first.server.url, name: first.server.name };
else { seen.push({ server: s, host }); firstByFingerprint.set(fp, seen); }
}
return out;
});
}
/** Loopback, .local and private network addresses. */
_isLocalHost(host) {
if (!host) return false;
const h = host.replace(/^\[|\]$/g, '');
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')
|| h === '::1' || /^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(h) || /^f[cd][0-9a-f]{2}:/i.test(h) || /^fe80:/i.test(h);
}
/** Lowercased hostname of a server address, or null when it does not parse. */
_hostnameOf(url) {
try { return new URL(this._normalizeUrl(url)).hostname.toLowerCase(); }
catch { return null; } // an unparsable address is never matched as a duplicate
}
/** True when url is served from the same origin as the server at serverUrl. */
_sameOrigin(url, serverUrl) {
try { return new URL(url).origin === new URL(this._normalizeUrl(serverUrl)).origin; }
catch { return false; } // an unparsable address never counts as the server's own
}
/** The icon to load for a server: one the user chose, or one the server
* serves itself. An icon a server points at on another host is not
* loaded, so a listed server cannot make the page fetch from anywhere;
* the initial letter shows instead. */
iconFor(server) {
if (!server) return null;
if (server.customIcon && server.icon) return server.icon;
const icon = server.icon || (server.status && server.status.icon) || null;
return icon && this._sameOrigin(icon, server.url) ? icon : null;
}
/** True for this server itself, by address or by fingerprint. */
isSelf(server, currentOrigin) {
if (this.selfFingerprint && server.status && server.status.fingerprint === this.selfFingerprint) return true;
try { return new URL(server.url).origin === currentOrigin; }
catch { return false; } // an unparsable address is never this server
}
/** Servers for Manage Servers: everything but this server. */
otherServers(currentOrigin) {
return this.getAll().filter(s => !this.isSelf(s, currentOrigin));
}
/** Servers for the rail: everything but this server and second addresses
* of a server already shown. */
railServers(currentOrigin) {
return this.otherServers(currentOrigin).filter(s => !s.duplicateOf);
}
/** Follow the name a server reports, unless the user chose one. "Haven"
* is the default of a server that never set a name and is skipped.
* Returns true when the stored name changed. */
_refreshName(normalizedUrl, reportedName) {
const entry = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
const name = String(reportedName || '').trim();
if (!entry || entry.customName || !name || name === 'Haven' || name === entry.name) return false;
entry.name = name;
this._save();
// A Desktop app that asks first takes a server's name only from that
// server's own page.
const desktop = this._desktop();
if (desktop && typeof desktop.updateServerName === 'function' && !this.desktopGated()) {
Promise.resolve(desktop.updateServerName(entry.url, name))
.catch((err) => { console.warn('[Desktop] could not share the server name', err); });
}
return true;
}
/** Check one server. Resolves to { renamed } (true when its stored name
* followed a new name from the server). */
async checkServer(url) {
const normalizedUrl = this._normalizeUrl(url);
let renamed = false;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const healthBase = normalizedUrl;
// The timer is cleared whether the request answers or fails.
const res = await fetch(`${healthBase}/api/health`, {
signal: controller.signal,
mode: 'cors'
}).finally(() => clearTimeout(timeout));
if (res.ok) {
const data = await res.json();
// Only an icon the server serves itself is kept (see iconFor).
let discoveredIcon = null;
if (data.icon) {
try { discoveredIcon = new URL(data.icon, `${healthBase}/`).toString(); }
catch { discoveredIcon = null; } // a malformed icon path just means no icon
if (discoveredIcon && !this._sameOrigin(discoveredIcon, healthBase)) discoveredIcon = null;
}
this.statusCache.set(normalizedUrl, {
online: true,
name: data.name || normalizedUrl,
icon: discoveredIcon,
version: data.version,
fingerprint: data.fingerprint || null,
checkedAt: Date.now()
});
if (data.name) renamed = this._refreshName(normalizedUrl, data.name);
// Persist discovered icon to the server entry so it survives
// across page reloads and offline periods (an icon the user chose
// stays)
if (discoveredIcon) {
const entry = this.servers.find(s => this._normalizeUrl(s.url) === normalizedUrl);
if (entry && !entry.customIcon) {
// Always update the icon URL (server may have changed its icon)
if (entry.icon !== discoveredIcon) {
entry.icon = discoveredIcon;
entry.iconData = null; // clear stale thumbnail
this._save();
}
// Generate a small base64 thumbnail so the icon travels
// with the encrypted sync bundle across servers
if (!entry.iconData) {
this._fetchIconThumbnail(discoveredIcon).then(dataUrl => {
if (dataUrl) { entry.iconData = dataUrl; this._save(); }
});
}
}
}
} else {
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
} catch {
this.statusCache.set(normalizedUrl, { online: false, checkedAt: Date.now() });
}
return { renamed };
}
/** Check every server. Resolves to how many names followed their server. */
async checkAll() {
const results = await Promise.allSettled(this.servers.map(s => this.checkServer(s.url)));
return results.filter(r => r.status === 'fulfilled' && r.value && r.value.renamed).length;
}
startPolling(intervalMs = 30000) {
this.checkAll();
this.checkInterval = setInterval(() => this.checkAll(), intervalMs);
}
stopPolling() {
if (this.checkInterval) clearInterval(this.checkInterval);
}
// ── Encrypted server-side sync ───────────────────────
// Stores the server list as an AES-256-GCM blob on each Haven server.
// wrappingHex: the 64-char hex string from HavenE2E.deriveWrappingKey()
/** Fetch a remote icon and shrink it to a tiny base64 data URL. */
async _fetchIconThumbnail(iconUrl) {
try {
const res = await fetch(iconUrl, { mode: 'cors', signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
const blob = await res.blob();
if (!blob.type.startsWith('image/')) return null;
const bmp = await createImageBitmap(blob);
const size = 48;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.drawImage(bmp, 0, 0, size, size);
bmp.close();
return canvas.toDataURL('image/png');
} catch { return null; }
}
/** Merge with this account's encrypted list on this server. Resolves to
* { ok, added }; ok is false when the server could not be reached. */
async syncWithServer(token, wrappingHex) {
if (!token || !wrappingHex) return { ok: true, added: 0 };
let added = 0;
try {
// 1. Fetch the encrypted blob from the server
const res = await fetch('/api/auth/user-servers', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return { ok: false, added: 0 };
const { blob } = await res.json();
// 2. Decrypt server-side list (if any)
let remoteServers = [];
if (blob) {
try {
const decrypted = await this._decryptBlob(blob, wrappingHex);
remoteServers = JSON.parse(decrypted);
if (!Array.isArray(remoteServers)) remoteServers = [];
} catch {
// Decryption failed: blob was encrypted with a different password
// or is corrupted. Start fresh from localStorage.
console.warn('[ServerSync] Could not decrypt server blob, using local list');
}
}
// 3. Load removed-servers set (removals are local-only, never synced)
const removed = this._loadRemoved();
// 4. Merge: union by URL, filtering out locally-removed servers
const localUrls = new Set(this.servers.map(s => this._normalizeUrl(s.url)));
const remoteUrls = new Set(remoteServers.map(s => this._normalizeUrl(s.url)));
let changed = false;
// Add remote servers we don't have locally (and haven't removed, here
// or in the Desktop app)
for (const rs of remoteServers) {
if (!rs || !rs.url) continue;
const normalizedUrl = this._normalizeUrl(rs.url);
if (!localUrls.has(rs.url) && !localUrls.has(normalizedUrl)
&& !removed.has(rs.url) && !removed.has(normalizedUrl)
&& !this.desktopRemoved.has(normalizedUrl)) {
rs.url = normalizedUrl; // store the normalized form
this.servers.push(rs);
localUrls.add(normalizedUrl); // prevent duplicate adds within same sync
changed = true;
added++;
}
}
// Check if we have servers the remote doesn't
for (const ls of this.servers) {
if (!remoteUrls.has(ls.url)) changed = true;
}
// 5. Save merged list locally
if (changed) this._save();
// 6. Push updated encrypted blob back if our list is longer
if (changed || !blob) {
await this._pushToServer(token, wrappingHex);
}
return { ok: true, added };
} catch (err) {
console.warn('[ServerSync] Sync failed:', err.message);
return { ok: false, added };
}
}
async _pushToServer(token, wrappingHex) {
try {
const payload = JSON.stringify(this.servers.map(s => {
const out = { url: s.url, name: s.name, icon: s.icon, iconData: s.iconData || null, addedAt: s.addedAt };
if (s.customName) out.customName = true;
if (s.customIcon) out.customIcon = true;
if (s.editedAt) out.editedAt = s.editedAt;
return out;
}));
const blob = await this._encryptBlob(payload, wrappingHex);
await fetch('/api/auth/user-servers', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ blob })
});
} catch (err) {
console.warn('[ServerSync] Push failed:', err.message);
}
}
// ── Crypto helpers (AES-256-GCM with PBKDF2) ─────────
async _encryptBlob(plaintext, wrappingHex) {
const keyBytes = this._hexToBytes(wrappingHex);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await this._deriveAESKey(keyBytes, salt);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Format: base64(salt + iv + ciphertext)
const combined = new Uint8Array(16 + 12 + ct.byteLength);
combined.set(salt, 0);
combined.set(iv, 16);
combined.set(new Uint8Array(ct), 28);
return btoa(String.fromCharCode(...combined));
}
async _decryptBlob(blob, wrappingHex) {
const keyBytes = this._hexToBytes(wrappingHex);
const raw = Uint8Array.from(atob(blob), c => c.charCodeAt(0));
const salt = raw.slice(0, 16);
const iv = raw.slice(16, 28);
const ct = raw.slice(28);
const key = await this._deriveAESKey(keyBytes, salt);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(pt);
}
async _deriveAESKey(keyBytes, salt) {
const raw = await crypto.subtle.importKey('raw', keyBytes, 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100_000 },
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
_hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/** Normalize a Haven server URL to its base path (strips /app(.html), query, hash, trailing slash). */
_normalizeUrl(url) {
url = String(url || '').trim();
if (!url) return '';
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
try {
const parsed = new URL(url);
parsed.hash = '';
parsed.search = '';
let pathname = parsed.pathname || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
pathname = pathname.replace(/\/app(?:\.html)?$/i, '') || '/';
pathname = pathname.replace(/\/+$/, '') || '/';
return pathname === '/' ? parsed.origin : parsed.origin + pathname;
} catch {
return url.replace(/\/+$/, '');
}
}
// ── Removed-servers tracking (local-only) ─────────────
_loadRemoved() {
try {
return new Set(JSON.parse(localStorage.getItem('haven_servers_removed') || '[]'));
} catch { return new Set(); }
}
_saveRemoved(set) {
localStorage.setItem('haven_servers_removed', JSON.stringify([...set]));
}
markRemoved(url) {
const normalizedUrl = this._normalizeUrl(url);
const removed = this._loadRemoved();
if (normalizedUrl) removed.add(normalizedUrl);
this._saveRemoved(removed);
}
}