v2.9.7 — Remove Google STUN, add STUN_URLS env var

- Replace Google STUN servers with open-source defaults (stunprotocol.org, nextcloud.com)
- Add STUN_URLS env var for custom STUN server configuration
- Update .env.example with STUN_URLS documentation
This commit is contained in:
ancsemi 2026-04-09 00:04:49 -04:00
parent 4b9e16d7b6
commit 6e756b6939
18 changed files with 188 additions and 45 deletions

View file

@ -26,6 +26,12 @@ ADMIN_USERNAME=admin
# Optional: Override the data directory location
# HAVEN_DATA_DIR=
# Optional: Override STUN servers for voice/WebRTC.
# Default: stun.stunprotocol.org + stun.nextcloud.com
# Comma-separated list of STUN URIs. Set this to point at your own coturn
# or any STUN server for fully self-hosted voice with no external dependencies.
# STUN_URLS=stun:your-server.com:3478,stun:backup-server.com:3478
# Optional: TURN server for voice/screen sharing across the internet.
# Without TURN, voice only works on the same network (LAN).
# Recommended: run coturn on the same box or a cheap VPS.

View file

@ -11,6 +11,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
---
## [2.9.7] — 2026-04-09
### Changed
- **Removed Google STUN dependency** — voice/WebRTC now defaults to open-source public STUN servers (`stun.stunprotocol.org` and `stun.nextcloud.com`) instead of Google's. No functional change for end users, just removes the Google dependency for a project built around self-hosting.
### Added
- **`STUN_URLS` environment variable** — server admins can now override the default STUN servers with their own (e.g., a self-hosted coturn instance) for fully self-contained voice with zero external dependencies. Comma-separated list of STUN URIs.
---
## [2.9.6] — 2026-04-07
### Added

View file

@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v2.9.6</h2>
<h2>&#x2B21; Haven Server &mdash; v2.9.7</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.6 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.7 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.9.6</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.6</span> &mdash; Custom Terms of Service, unpin fix</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip">Download &rarr;</a>
</div>
<div class="version-item">

View file

@ -1,6 +1,6 @@
{
"name": "haven",
"version": "2.9.6",
"version": "2.9.7",
"description": "Haven — self-hosted private chat for your server, your rules",
"license": "AGPL-3.0",
"main": "server.js",

View file

@ -1033,6 +1033,14 @@
<p class="settings-hint" data-i18n="settings.desktop_app_section.minimize_to_tray_hint">Closing the window hides Haven to the system tray instead of quitting.</p>
</div>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-hide-menu-bar">
<span class="toggle-label" data-i18n="settings.desktop_app_section.hide_menu_bar">Hide Menu Bar</span>
</label>
<p class="settings-hint" data-i18n="settings.desktop_app_section.hide_menu_bar_hint">Hide the File / Edit / View / Window / Help toolbar. Press Alt to temporarily show it.</p>
</div>
<div class="desktop-pref-row">
<label class="toggle-row">
<input type="checkbox" id="pref-force-sdr">

View file

@ -12922,7 +12922,7 @@ ul.chat-list { list-style-type: disc; }
}
.rac-perm-item.disabled {
opacity: 0.4;
pointer-events: none;
cursor: not-allowed;
}
/* Current role display in config pane */

View file

@ -4,7 +4,7 @@ const ALL_PERMS = [
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
'create_channel', 'create_temp_channel', 'upload_files', 'use_voice', 'use_tts', 'manage_webhooks', 'mention_everyone', 'view_history',
'view_all_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue', 'promote_user', 'transfer_admin',
'view_all_members', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue', 'promote_user', 'transfer_admin',
'manage_roles', 'manage_server', 'delete_channel'
];
//Similarly flavored solution to perm labels
@ -31,6 +31,7 @@ const PERM_LABELS = {
get mention_everyone() { return t('permissions.mention_everyone'); },
get view_history() { return t('permissions.view_history'); },
get view_all_members() { return t('permissions.view_all_members'); },
get view_channel_members() { return t('permissions.view_channel_members'); },
get manage_emojis() { return t('permissions.manage_emojis'); },
get manage_soundboard() { return t('permissions.manage_soundboard'); },
get manage_music_queue() { return t('permissions.manage_music_queue'); },
@ -797,7 +798,9 @@ _openAllMembersModal() {
document.getElementById('all-members-count').textContent = '';
modal.style.display = 'flex';
this.socket.emit('get-all-members', {}, (res) => {
// Pass current channel so the server can fall back to view_channel_members
const payload = this.currentChannel ? { channelCode: this.currentChannel } : {};
this.socket.emit('get-all-members', payload, (res) => {
if (res.error) {
list.innerHTML = `<p class="muted-text" style="text-align:center;padding:20px">${this._escapeHtml(res.error)}</p>`;
return;
@ -805,6 +808,11 @@ _openAllMembersModal() {
this._allMembersData = res.members || [];
this._allMembersChannels = res.allChannels || [];
this._allMembersPerms = res.callerPerms || {};
// Update title to reflect channel-only vs all members
const titleEl = document.querySelector('#all-members-modal [data-i18n="modals.all_members.title"]');
if (titleEl) {
titleEl.textContent = res.channelOnly ? t('modals.all_members.channel_title') : t('modals.all_members.title');
}
document.getElementById('all-members-count').textContent = `(${res.total})`;
this._renderAllMembers(this._allMembersData);
});
@ -3287,7 +3295,10 @@ _renderRacConfig() {
const callerHasPerm = callerIsAdmin || callerPerms.includes('*') || callerPerms.includes(p);
const isAdminOnly = adminOnlyPerms.includes(p) && !callerIsAdmin;
const isReadOnly = !selectedRoleId || isAdminOnly || !callerHasPerm;
return `<label class="rac-perm-item${isReadOnly ? ' disabled' : ''}${checked ? ' checked' : ''}">
const tooltip = isReadOnly && selectedRoleId
? (isAdminOnly ? 'Owner only' : 'You don\'t have this permission')
: '';
return `<label class="rac-perm-item${isReadOnly ? ' disabled' : ''}${checked ? ' checked' : ''}"${tooltip ? ` title="${tooltip}"` : ''}>
<input type="checkbox" data-perm="${p}" ${checked ? 'checked' : ''} ${isReadOnly ? 'disabled' : ''}>
${permLabels[p] || p}
</label>`;

View file

@ -238,6 +238,13 @@ _openChannelCtxMenu(code, btnEl) {
if (deleteBtn && !canManageChannels && this._hasPerm('delete_channel')) {
deleteBtn.style.display = '';
}
// Also show delete for users who created a temp channel
if (deleteBtn && !canManageChannels && !this._hasPerm('delete_channel')) {
const ch = this.channels.find(c => c.code === code);
if (ch && ch.is_temp_voice && ch.created_by === this.user?.id) {
deleteBtn.style.display = '';
}
}
menu.querySelectorAll('.mod-only').forEach(el => {
el.style.display = isMod ? '' : 'none';
});

View file

@ -869,8 +869,11 @@ _startStatusBar() {
// Belt-and-suspenders: ensure the CSS attribute is present (preload
// sets this on DOMContentLoaded, but reinforce here in case of timing)
document.documentElement.setAttribute('data-desktop-app', '1');
// If the Desktop preload already injected its own fixed footer bar,
// don't force the original status bar visible (that causes duplicates)
const hasDesktopFooter = !!document.getElementById('haven-desktop-footer');
const sb = document.getElementById('status-bar');
if (sb) {
if (sb && !hasDesktopFooter) {
sb.style.setProperty('display', 'flex', 'important');
// Safety net: after one frame, verify the bar is actually inside the
// visible viewport. If Electron's BrowserView clips it (100dvh

View file

@ -1628,6 +1628,10 @@ _setupModalExpand() {
// Auto-inject an expand/maximize toggle button into every modal's header
document.querySelectorAll('.modal').forEach(modal => {
// Skip promo/centered popups — they're not regular modals
if (modal.classList.contains('android-beta-promo') ||
modal.classList.contains('desktop-promo')) return;
// Find the header container — either .settings-header / .activities-header or the first h3
let headerContainer = modal.querySelector('.settings-header, .activities-header');
let header = modal.querySelector('h3');
@ -1645,20 +1649,43 @@ _setupModalExpand() {
btn.title = isMax ? 'Restore size' : 'Expand';
});
if (headerContainer) {
// Insert before the close button
const closeBtn = headerContainer.querySelector('.settings-close-btn');
if (closeBtn) {
headerContainer.insertBefore(btn, closeBtn);
} else {
headerContainer.appendChild(btn);
// Create X close button
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-expand-btn';
closeBtn.title = 'Close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
const overlay = modal.closest('.modal-overlay');
if (overlay) overlay.style.display = 'none';
if (modal.classList.contains('modal-maximized')) {
modal.classList.remove('modal-maximized');
btn.textContent = '⛶';
btn.title = 'Expand / Restore';
}
});
if (headerContainer) {
// Settings/activities modal: wrap buttons in a group to avoid space-between spreading
const existingClose = headerContainer.querySelector('.settings-close-btn');
const btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;margin-left:auto;';
btnGroup.appendChild(btn);
if (existingClose) {
// Replace the existing close button with our grouped version
existingClose.remove();
btnGroup.appendChild(closeBtn);
}
headerContainer.appendChild(btnGroup);
} else {
// Standard modal: make h3 flex and append button
// Standard modal: make h3 flex and append buttons
header.style.display = 'flex';
header.style.alignItems = 'center';
btn.style.marginLeft = 'auto';
header.appendChild(btn);
const btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex;align-items:center;gap:4px;margin-left:auto;';
btnGroup.appendChild(btn);
btnGroup.appendChild(closeBtn);
header.appendChild(btnGroup);
}
});
},

View file

@ -362,6 +362,7 @@ async _setupDesktopAppPrefs() {
const hiddenRow = document.getElementById('pref-start-hidden-row');
const trayEl = document.getElementById('pref-minimize-to-tray');
const sdrEl = document.getElementById('pref-force-sdr');
const menuBarEl = document.getElementById('pref-hide-menu-bar');
const versionEl = document.getElementById('desktop-version-info');
if (startEl) { startEl.checked = !!prefs.startOnLogin; }
@ -369,6 +370,7 @@ async _setupDesktopAppPrefs() {
if (hiddenRow) { hiddenRow.style.display = prefs.startOnLogin ? '' : 'none'; }
if (trayEl) { trayEl.checked = !!prefs.minimizeToTray; }
if (sdrEl) { sdrEl.checked = !!prefs.forceSDR; }
if (menuBarEl) { menuBarEl.checked = !!prefs.hideMenuBar; }
// Show desktop version
if (versionEl && window.havenDesktop.getVersion) {
@ -403,6 +405,11 @@ async _setupDesktopAppPrefs() {
}
} catch { sdrEl.checked = !sdrEl.checked; }
});
menuBarEl?.addEventListener('change', async () => {
try { await window.havenDesktop.prefs.setHideMenuBar(menuBarEl.checked); }
catch { menuBarEl.checked = !menuBarEl.checked; }
});
},
/* ── E2E Encryption Helpers ──────────────────────────── */

View file

@ -56,7 +56,7 @@ _setupSocketListeners() {
} else {
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasPerm('manage_emojis') || this._hasPerm('manage_soundboard')) ? 'block' : 'none';
}
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members')) ? '' : 'none';
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
});
// Roles updated (from admin assigning/revoking)
@ -70,7 +70,7 @@ _setupSocketListeners() {
const canCreateChannel = this.user.isAdmin || this._hasPerm('create_channel');
document.getElementById('admin-controls').style.display = canCreateChannel ? 'block' : 'none';
document.getElementById('admin-mod-panel').style.display = (canModerate || this._hasPerm('manage_emojis') || this._hasPerm('manage_soundboard')) ? 'block' : 'none';
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members')) ? '' : 'none';
document.getElementById('sidebar-members-btn').style.display = (this.user.isAdmin || canModerate || this._hasPerm('view_all_members') || this._hasPerm('view_channel_members')) ? '' : 'none';
this._showToast(t('toasts.roles_updated'), 'info');
});
@ -152,6 +152,11 @@ _setupSocketListeners() {
setTimeout(() => {
if (this.socket && !this.socket.connected) this.socket.connect();
}, 2500);
// Browsers don't compute layout accurately while a tab is hidden, so
// scrollToBottom during a background reconnect often undershoots.
// Re-scroll now that the tab is visible and layout is correct.
if (this._coupledToBottom) this._scrollToBottom(true);
// Skip heavy refresh if we just handled a 'connect' event (avoids doubled emits)
const sinceLast = Date.now() - (this._lastConnectTime || 0);
if (sinceLast < 3000) return;

View file

@ -67,8 +67,8 @@ class VoiceManager {
this.rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
{ urls: 'stun:stun.stunprotocol.org:3478' },
{ urls: 'stun:stun.nextcloud.com:3478' }
]
};

View file

@ -370,6 +370,7 @@
"mention_everyone": "Mention @everyone",
"view_history": "View Message History",
"view_all_members": "View All Server Members",
"view_channel_members": "View Channel Members",
"manage_emojis": "Manage Custom Emojis",
"manage_soundboard": "Manage Soundboard",
"promote_user": "Promote Users",
@ -1358,6 +1359,7 @@
},
"all_members": {
"title": "All Members",
"channel_title": "Channel Members",
"search_placeholder": "Search users...",
"filter_all": "All",
"filter_online": "Online",

View file

@ -309,10 +309,11 @@ app.get('/api/ice-servers', (req, res) => {
const user = token ? verifyToken(token) : null;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
];
// STUN_URLS env var: comma-separated list of STUN URIs to override defaults
const stunUrls = process.env.STUN_URLS
? process.env.STUN_URLS.split(',').map(u => u.trim()).filter(Boolean)
: ['stun:stun.stunprotocol.org:3478', 'stun:stun.nextcloud.com:3478'];
const iceServers = stunUrls.map(urls => ({ urls }));
const turnUrl = process.env.TURN_URL;
if (turnUrl) {

View file

@ -437,7 +437,7 @@ function initDatabase() {
const channelModPerms = [
'kick_user', 'mute_user', 'delete_message', 'pin_message',
'manage_sub_channels', 'rename_sub_channel', 'delete_lower_messages',
'upload_files', 'use_voice', 'view_history', 'manage_music_queue',
'upload_files', 'use_voice', 'view_history', 'view_channel_members', 'manage_music_queue',
'delete_own_messages', 'edit_own_messages'
];
channelModPerms.forEach(p => insertPerm.run(channelMod.lastInsertRowid, p));
@ -480,6 +480,9 @@ function initDatabase() {
)
`);
// ── Prevent future NULL-duplicate inserts with a functional unique index ──
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_user_roles_no_dupes ON user_roles(user_id, role_id, COALESCE(channel_id, -1))');
// ── Migration: custom_level column on user_roles for per-assignment level overrides ──
try {
db.prepare('SELECT custom_level FROM user_roles LIMIT 0').get();

View file

@ -343,7 +343,7 @@ const VALID_ROLE_PERMS = [
'pin_message', 'archive_messages', 'kick_user', 'mute_user', 'ban_user',
'rename_channel', 'rename_sub_channel', 'set_channel_topic', 'manage_sub_channels',
'create_channel', 'create_temp_channel', 'upload_files', 'use_voice', 'use_tts', 'manage_webhooks', 'mention_everyone', 'view_history',
'view_all_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue',
'view_all_members', 'view_channel_members', 'manage_emojis', 'manage_soundboard', 'manage_music_queue',
'promote_user', 'transfer_admin', 'manage_roles', 'manage_server', 'delete_channel'
];
@ -1317,6 +1317,20 @@ function setupSocketHandlers(io, db) {
voiceUsers.delete(code);
activeMusic.delete(code);
musicQueues.delete(code);
// Auto-delete temporary voice channels when pruning empties the room
try {
const ch = db.prepare('SELECT id, is_temp_voice FROM channels WHERE code = ?').get(code);
if (ch && ch.is_temp_voice) {
db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(ch.id);
db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(ch.id);
db.prepare('DELETE FROM messages WHERE channel_id = ?').run(ch.id);
db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(ch.id);
db.prepare('DELETE FROM channels WHERE id = ?').run(ch.id);
io.emit('channel-deleted', { code, reason: 'temp-empty' });
channelUsers.delete(code);
console.log(`[Temporary] Temp voice channel "${code}" deleted (pruned empty)`);
}
} catch { /* column may not exist yet */ }
}
}
@ -3401,11 +3415,15 @@ function setupSocketHandlers(io, db) {
socket.on('delete-channel', (data) => {
if (!data || typeof data !== 'object') return;
if (!socket.user.isAdmin && !userHasPermission(socket.user.id, 'delete_channel')) {
const delCode = typeof data.code === 'string' ? data.code.trim() : '';
// Allow temp channel creators to delete their own temp channels
const delCh = delCode ? db.prepare('SELECT created_by, is_temp_voice FROM channels WHERE code = ?').get(delCode) : null;
const isOwnTemp = delCh && delCh.is_temp_voice && delCh.created_by === socket.user.id;
if (!socket.user.isAdmin && !isOwnTemp && !userHasPermission(socket.user.id, 'delete_channel')) {
return socket.emit('error-msg', 'Only admins can delete channels');
}
const code = typeof data.code === 'string' ? data.code.trim() : '';
const code = delCode;
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
const channel = db.prepare('SELECT * FROM channels WHERE code = ?').get(code);
if (!channel) return;
@ -5776,16 +5794,41 @@ function setupSocketHandlers(io, db) {
const canMod = isAdmin || userHasPermission(socket.user.id, 'kick_user') || userHasPermission(socket.user.id, 'ban_user');
const canSeeAll = canMod || userHasPermission(socket.user.id, 'view_all_members');
if (!canSeeAll) return cb({ error: 'Permission denied' });
// Channel-scoped member viewing: if the caller can't see ALL members,
// check if they have view_channel_members for the requested channel.
let channelOnly = null; // null = all members, otherwise restrict to this channel's members
if (!canSeeAll) {
const channelCode = data && typeof data.channelCode === 'string' ? data.channelCode : null;
if (channelCode) {
const ch = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(channelCode);
if (ch && userHasPermission(socket.user.id, 'view_channel_members', ch.id)) {
channelOnly = ch.id;
}
}
if (channelOnly === null) return cb({ error: 'Permission denied' });
}
try {
const users = db.prepare(`
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
FROM users u
LEFT JOIN bans b ON u.id = b.user_id
ORDER BY u.created_at DESC
`).all();
let users;
if (channelOnly) {
// Only fetch members of the specific channel
users = db.prepare(`
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
FROM users u
JOIN channel_members cm ON u.id = cm.user_id
WHERE cm.channel_id = ?
ORDER BY u.created_at DESC
`).all(channelOnly);
} else {
users = db.prepare(`
SELECT u.id, u.username, COALESCE(u.display_name, u.username) as displayName,
u.is_admin, u.created_at, u.avatar, u.avatar_shape, u.status, u.status_text
FROM users u
LEFT JOIN bans b ON u.id = b.user_id
ORDER BY u.created_at DESC
`).all();
}
// Build online set from connected sockets
const onlineIds = new Set();
@ -5858,6 +5901,7 @@ function setupSocketHandlers(io, db) {
cb({
members,
total: members.length,
channelOnly: !!channelOnly,
allChannels: canMod ? allChannels : undefined,
callerPerms: {
isAdmin,
@ -6104,7 +6148,7 @@ function setupSocketHandlers(io, db) {
.forEach(p => insertPerm.run(serverMod.lastInsertRowid, p));
const channelMod = insertRole.run('Channel Mod', 25, 'channel', '#2ecc71');
['kick_user','mute_user','delete_message','pin_message','manage_sub_channels','rename_sub_channel','delete_lower_messages','upload_files','use_voice','view_history','manage_music_queue','delete_own_messages','edit_own_messages']
['kick_user','mute_user','delete_message','pin_message','manage_sub_channels','rename_sub_channel','delete_lower_messages','upload_files','use_voice','view_history','view_channel_members','manage_music_queue','delete_own_messages','edit_own_messages']
.forEach(p => insertPerm.run(channelMod.lastInsertRowid, p));
const userRole = insertRole.run('User', 1, 'server', '#95a5a6');
@ -6205,6 +6249,7 @@ function setupSocketHandlers(io, db) {
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = ?
GROUP BY ur.role_id, COALESCE(ur.channel_id, -1)
`).all(m.id);
users.push({

View file

@ -1415,12 +1415,12 @@
</div>
<div class="download-card fade-in">
<h2>&#x2B21; Haven Server &mdash; v2.9.6</h2>
<h2>&#x2B21; Haven Server &mdash; v2.9.7</h2>
<p class="download-version">Latest stable release &middot; Windows, macOS &amp; Linux &middot; ~5 MB</p>
<div class="download-btn-group">
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.6 (.zip)
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip" class="btn btn-primary download-main">
<span class="icon">&#x2B07;</span> Download v2.9.7 (.zip)
</a>
<div class="download-alt-links">
<a href="https://github.com/ancsemi/Haven" target="_blank">&#9965; View on GitHub</a>
@ -1437,7 +1437,11 @@
<div class="version-list">
<div class="version-list-inner">
<div class="version-item">
<div><span class="v-name">v2.9.6</span><span class="v-tag latest">Latest</span></div>
<div><span class="v-name">v2.9.7</span><span class="v-tag latest">Latest</span></div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.7.zip">Download &rarr;</a>
</div>
<div class="version-item">
<div><span class="v-name">v2.9.6</span> &mdash; Custom Terms of Service, unpin fix</div>
<a href="https://github.com/ancsemi/Haven/archive/refs/tags/v2.9.6.zip">Download &rarr;</a>
</div>
<div class="version-item">