Add multiple-session support: run several independent encrypted chats at once
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

Each conversation now runs its own WebRTC session with separate keys and SAS verification, so chats never mix. Adds a side panel to switch between open chats with unread badges, a New chat action that leaves existing chats connected, per-chat local labels stored only on this device, and an availability status (Available, Away, Busy, Invisible) shared end-to-end with connected peers. Also includes vendored Prism syntax highlighting, more reliable PWA update handling, and offline send queueing fixes. Version 4.10.0.
This commit is contained in:
lockbitchat
2026-06-26 00:00:13 -04:00
parent db5d6e481d
commit 0e3e3a2974
16 changed files with 2828 additions and 924 deletions
+6 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-4.9.1-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-4.10.0-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
@@ -48,6 +48,11 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
- Unsend (delete for everyone) over the authenticated control channel.
- WhatsApp-style delivery status (sending → sent → delivered) with offline store-and-forward.
**Multiple conversations**
- Run several independent chats at the same time. Every conversation gets its own encrypted session, keys and verification, so two chats can never mix.
- A side panel lists your open chats with unread badges. Switching is instant, and starting a new chat leaves the others connected.
- Set your availability (Available, Away, Busy or Invisible) and connected peers can see it. You can also give each chat a private label that is stored only on your device and is never sent to the other side.
** File transfer**
- Consent-gated, end-to-end encrypted transfers with resumable, per-chunk progress.
- Strict file-type allowlist; executable and scriptable formats are rejected.
+1 -1
View File
File diff suppressed because one or more lines are too long
Vendored
+1320 -497
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
File diff suppressed because one or more lines are too long
+24 -20
View File
@@ -23,7 +23,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1782332255248">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1782446255208">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -89,7 +89,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1782332255248">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1782446255208">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -98,7 +98,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1782332255248">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1782446255208">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -182,32 +182,36 @@
<script src="config/ice-servers.js"></script>
<script src="libs/react/react.production.min.js"></script>
<script src="libs/react-dom/react-dom.production.min.js"></script>
<link rel="stylesheet" href="assets/tailwind.css?v=1782332255248">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1782332255248">
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
it never executes the snippet. Loaded in manual mode (no auto-highlight). -->
<link rel="stylesheet" href="libs/prism/prism.css">
<script src="libs/prism/prism.js"></script>
<link rel="stylesheet" href="assets/tailwind.css?v=1782446255208">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1782446255208">
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="preload" href="/assets/fontawesome/webfonts/fa-solid-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fontawesome/webfonts/fa-regular-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fontawesome/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1782332255248">
<link rel="stylesheet" href="src/styles/animations.css?v=1782332255248">
<link rel="stylesheet" href="src/styles/components.css?v=1782332255248">
<script src="src/scripts/fa-check.js?v=1782332255248"></script>
<link rel="stylesheet" href="src/styles/main.css?v=1782446255208">
<link rel="stylesheet" href="src/styles/animations.css?v=1782446255208">
<link rel="stylesheet" href="src/styles/components.css?v=1782446255208">
<script src="src/scripts/fa-check.js?v=1782446255208"></script>
<!-- Update Manager - система принудительного обновления -->
<script src="src/utils/updateManager.js?v=1782332255248"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1782332255248"></script>
<script type="module" src="dist/qr-local.js?v=1782332255248"></script>
<script type="module" src="src/components/QRScanner.js?v=1782332255248"></script>
<script src="src/utils/updateManager.js?v=1782446255208"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1782446255208"></script>
<script type="module" src="dist/qr-local.js?v=1782446255208"></script>
<script type="module" src="src/components/QRScanner.js?v=1782446255208"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1782332255248"></script>
<script type="module" src="dist/app.js?v=1782332255248"></script>
<script type="module" src="dist/app-boot.js?v=1782446255208"></script>
<script type="module" src="dist/app.js?v=1782446255208"></script>
<script src="src/scripts/pwa-register.js?v=1782332255248"></script>
<script src="./src/pwa/install-prompt.js?v=1782332255248" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1782332255248" type="module"></script>
<script src="./src/scripts/pwa-offline-test.js?v=1782332255248"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1782332255248">
<script src="src/scripts/pwa-register.js?v=1782446255208"></script>
<script src="./src/pwa/install-prompt.js?v=1782446255208" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1782446255208" type="module"></script>
<script src="./src/scripts/pwa-offline-test.js?v=1782446255208"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1782446255208">
</body>
</html>
+1
View File
@@ -0,0 +1 @@
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "SecureBit.chat v4.9.1 - ECDH + DTLS + SAS",
"name": "SecureBit.chat — Private, Encrypted Messenger",
"short_name": "SecureBit",
"description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments",
"start_url": "./",
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1782332255248",
"buildVersion": "1782332255248",
"appVersion": "4.9.1",
"buildTime": "2026-06-24T20:17:35.295Z",
"buildId": "1782332255248-ef2f13d",
"gitHash": "ef2f13d",
"version": "1782446255208",
"buildVersion": "1782446255208",
"appVersion": "4.10.0",
"buildTime": "2026-06-26T03:57:35.595Z",
"buildId": "1782446255208-db5d6e4",
"gitHash": "db5d6e4",
"generated": true,
"generatedAt": "2026-06-24T20:17:35.303Z"
"generatedAt": "2026-06-26T03:57:35.602Z"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "4.9.1",
"version": "4.10.0",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
@@ -11,7 +11,7 @@
"dev": "npm run build && python -m http.server 8000",
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
"serve": "npx http-server -p 8000",
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs"
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs"
},
"keywords": [
"p2p",
+30 -2
View File
@@ -141,12 +141,37 @@ function updateIndexHtmlVersions(buildVersion) {
fs.writeFileSync(indexHtmlPath, indexHtml, 'utf-8');
console.log('✅ index.html versions updated');
} catch (error) {
console.warn('⚠️ Failed to update index.html versions:', error.message);
}
}
/**
* Stamp the build version into sw.js so the file's bytes change every deploy. The browser
* only reinstalls a Service Worker when sw.js differs byte-for-byte; without this the SW
* (and its caches) stay frozen and clients never pick up new releases automatically.
*/
function updateServiceWorkerVersion(buildVersion) {
try {
const swPath = path.join(CONFIG.publicDir, 'sw.js');
if (!fs.existsSync(swPath)) {
console.warn('⚠️ sw.js not found, skipping SW version stamp');
return;
}
let sw = fs.readFileSync(swPath, 'utf-8');
if (/const SW_BUILD_VERSION = '[^']*';/.test(sw)) {
sw = sw.replace(/const SW_BUILD_VERSION = '[^']*';/, `const SW_BUILD_VERSION = '${buildVersion}';`);
fs.writeFileSync(swPath, sw, 'utf-8');
console.log(`✅ sw.js build stamp updated → ${buildVersion}`);
} else {
console.warn('⚠️ SW_BUILD_VERSION marker not found in sw.js');
}
} catch (error) {
console.warn('⚠️ Failed to stamp sw.js version:', error.message);
}
}
/**
* Validate generated meta.json
*/
@@ -184,7 +209,10 @@ function main() {
// Update versions in index.html
updateIndexHtmlVersions(meta.version);
// Stamp the build version into sw.js so the Service Worker reinstalls each deploy.
updateServiceWorkerVersion(meta.version);
console.log('✅ Build metadata generation completed');
}
+887 -362
View File
File diff suppressed because it is too large Load Diff
+336
View File
@@ -0,0 +1,336 @@
// Sessions registry for SecureBit.chat multi-session support.
//
// Pure, framework-free reducer + helpers. The root React component drives it via
// React.useReducer; non-serializable per-session objects (the EnhancedSecureWebRTCManager
// instance, its NotificationIntegration, offline queues, QR-animation timers) live OUTSIDE
// this state in ref-held Maps keyed by sessionId — never in here, never shared between
// sessions. Every reducer case returns fresh objects for the touched session only, so a
// change to one session can never mutate another (full isolation).
//
// sessionId is LOCAL ONLY (crypto.randomUUID). It is never sent to the peer as identity.
export const SESSION_ACTIONS = Object.freeze({
CREATE_SESSION: 'CREATE_SESSION',
REMOVE_SESSION: 'REMOVE_SESSION',
SET_ACTIVE: 'SET_ACTIVE',
SET_STATUS: 'SET_STATUS',
SET_FINGERPRINT: 'SET_FINGERPRINT',
SET_VERIFICATION: 'SET_VERIFICATION',
SET_SAS: 'SET_SAS',
ADD_MESSAGE: 'ADD_MESSAGE',
SET_MESSAGES: 'SET_MESSAGES',
UPDATE_MESSAGE_STATUS: 'UPDATE_MESSAGE_STATUS',
DELETE_MESSAGE: 'DELETE_MESSAGE',
EXPIRE_MESSAGE: 'EXPIRE_MESSAGE',
INCREMENT_UNREAD: 'INCREMENT_UNREAD',
CLEAR_UNREAD: 'CLEAR_UNREAD',
SET_PENDING_FILES: 'SET_PENDING_FILES',
PATCH_SETUP: 'PATCH_SETUP',
RENAME: 'RENAME',
SET_PEER_PRESENCE: 'SET_PEER_PRESENCE'
});
// Availability presence the PEER advertises to us (sent E2E over the data channel, never
// stored on a server). 'invisible' is sent on the wire as 'offline' so peers can't tell.
export const PRESENCE_DOT = { available: '#3ecf8e', away: '#e3b341', busy: '#e5727a', offline: '#6b6b73' };
export const PRESENCE_WORD = { available: 'Available', away: 'Away', busy: 'Busy', offline: 'Offline' };
// The statuses the local user can pick for themselves (design: Set your status).
export const MY_STATUS_OPTIONS = [
{ key: 'available', word: 'Available', desc: 'Online and reachable', dot: '#3ecf8e' },
{ key: 'away', word: 'Away', desc: 'Idle · stepped away', dot: '#e3b341' },
{ key: 'busy', word: 'Busy', desc: 'Do not disturb', dot: '#e5727a' },
{ key: 'invisible', word: 'Invisible', desc: 'Appear offline to peers', dot: '#6b6b73' }
];
// Short, human-friendly default label derived from the local sessionId. Never the peer's
// identity — just something stable to show before the SAS-derived label is available.
export function shortLabelFromId(id) {
const hex = String(id || '').replace(/[^a-z0-9]/gi, '');
return 'Chat ' + (hex.slice(0, 4) || '0000').toUpperCase();
}
// Two-letter monogram for the avatar tile (mirrors the design's `mono()` helper).
export function monoInitials(label) {
const words = String(label || '').trim().split(/\s+/).filter(Boolean);
const a = words[0]?.[0] || '';
const b = words[1]?.[0] || words[0]?.[1] || '';
return (a + b).toUpperCase() || '··';
}
// Status → dot colour (mirrors the design's DOT map).
export function statusDot(status) {
switch (status) {
case 'connected':
case 'verified':
return '#3ecf8e';
case 'connecting':
case 'verifying':
case 'new':
return '#e3b341';
default:
return '#e5727a'; // disconnected / peer_disconnected / lost
}
}
// Status → header sub-text (mirrors the design's SUB map).
export function statusSub(status) {
switch (status) {
case 'connected':
case 'verified':
return 'P2P · connected';
case 'verifying':
return 'Verifying…';
case 'connecting':
case 'new':
return 'Connecting…';
case 'peer_disconnected':
return 'Peer disconnected';
default:
return 'Disconnected';
}
}
function emptySetup() {
return {
offerData: '',
answerData: '',
offerInput: '',
answerInput: '',
showOfferStep: false,
showAnswerStep: false,
showVerification: false,
showQRCode: false,
qrCodeUrl: '',
isGeneratingKeys: false,
qrFramesTotal: 0,
qrFrameIndex: 0,
qrManualMode: false
};
}
export function createSessionEntry(opts = {}) {
const id = opts.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random());
return {
id,
peerLabel: opts.peerLabel || shortLabelFromId(id),
labelIsCustom: false, // becomes true once the user renames; blocks SAS auto-relabel
createdAt: opts.createdAt || Date.now(),
role: opts.role || 'offer', // 'offer' | 'answer'
status: opts.status || 'new',
keyFingerprint: '',
verificationCode: '',
sas: { localConfirmed: false, remoteConfirmed: false, bothConfirmed: false, isVerified: false },
messages: [],
unreadCount: 0,
pendingIncomingFiles: [],
peerPresence: null, // peer's advertised availability ('available'|'away'|'busy'|'offline'); null = unknown
setup: emptySetup()
};
}
export function createInitialState() {
return { sessions: {}, order: [], activeSessionId: null };
}
// Apply a partial patch to one session, returning a new state with only that session's
// object replaced. Other sessions keep their identity (referential isolation).
function patchSession(state, id, patch) {
const session = state.sessions[id];
if (!session) return state;
return {
...state,
sessions: { ...state.sessions, [id]: { ...session, ...patch } }
};
}
export function sessionsReducer(state, action) {
const A = SESSION_ACTIONS;
switch (action.type) {
case A.CREATE_SESSION: {
const entry = action.entry || createSessionEntry(action);
if (state.sessions[entry.id]) return state;
return {
sessions: { ...state.sessions, [entry.id]: entry },
order: [...state.order, entry.id],
activeSessionId: action.activate === false ? state.activeSessionId : entry.id
};
}
case A.REMOVE_SESSION: {
const { id } = action;
if (!state.sessions[id]) return state;
const sessions = { ...state.sessions };
delete sessions[id];
const order = state.order.filter((x) => x !== id);
let activeSessionId = state.activeSessionId;
if (activeSessionId === id) {
// Re-point to the previous sibling in display order, else the first remaining.
const removedIdx = state.order.indexOf(id);
activeSessionId = order[Math.max(0, removedIdx - 1)] || order[0] || null;
}
return { sessions, order, activeSessionId };
}
case A.SET_ACTIVE: {
if (!state.sessions[action.id]) return state;
if (state.activeSessionId === action.id) return state;
return { ...state, activeSessionId: action.id };
}
case A.SET_STATUS: {
const session = state.sessions[action.id];
if (!session || session.status === action.status) return state; // no-op if unchanged
return patchSession(state, action.id, { status: action.status });
}
case A.SET_FINGERPRINT:
return patchSession(state, action.id, { keyFingerprint: action.fingerprint });
case A.SET_VERIFICATION:
return patchSession(state, action.id, { verificationCode: action.code });
case A.SET_SAS: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { sas: { ...session.sas, ...action.sas } });
}
case A.ADD_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { messages: [...session.messages, action.message] });
}
case A.SET_MESSAGES: {
const session = state.sessions[action.id];
if (!session) return state;
const next = typeof action.updater === 'function'
? action.updater(session.messages)
: action.messages;
return patchSession(state, action.id, { messages: Array.isArray(next) ? next : [] });
}
case A.UPDATE_MESSAGE_STATUS: {
const session = state.sessions[action.id];
if (!session) return state;
let changed = false;
const messages = session.messages.map((m) => {
if (String(m.mid) === String(action.mid) && m.status !== action.status) {
changed = true;
return { ...m, status: action.status };
}
return m;
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.DELETE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
const messages = session.messages.filter((m) => String(m.mid) !== String(action.mid));
if (messages.length === session.messages.length) return state;
return patchSession(state, action.id, { messages });
}
case A.EXPIRE_MESSAGE: {
const session = state.sessions[action.id];
if (!session) return state;
let changed = false;
const messages = session.messages.map((m) => {
if (String(m.id) === String(action.messageId) && !m.expired) {
changed = true;
return { ...m, expired: true, message: '', expiresAt: undefined };
}
return m;
});
return changed ? patchSession(state, action.id, { messages }) : state;
}
case A.INCREMENT_UNREAD: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { unreadCount: session.unreadCount + 1 });
}
case A.CLEAR_UNREAD: {
const session = state.sessions[action.id];
if (!session || session.unreadCount === 0) return state;
return patchSession(state, action.id, { unreadCount: 0 });
}
case A.SET_PENDING_FILES: {
const session = state.sessions[action.id];
if (!session) return state;
const next = typeof action.updater === 'function'
? action.updater(session.pendingIncomingFiles)
: action.files;
return patchSession(state, action.id, { pendingIncomingFiles: Array.isArray(next) ? next : [] });
}
case A.PATCH_SETUP: {
const session = state.sessions[action.id];
if (!session) return state;
return patchSession(state, action.id, { setup: { ...session.setup, ...action.patch } });
}
case A.RENAME: {
const session = state.sessions[action.id];
if (!session) return state;
const label = String(action.label || '').trim() || session.peerLabel;
return patchSession(state, action.id, { peerLabel: label, labelIsCustom: true });
}
case A.SET_PEER_PRESENCE: {
const session = state.sessions[action.id];
if (!session || session.peerPresence === action.presence) return state;
return patchSession(state, action.id, { peerPresence: action.presence });
}
default:
return state;
}
}
// Decorate a session into the shape the sidebar/header rendering consumes (avatar monogram,
// status dot, sub-text, last-message preview, unread badge). Pure derivation — no state.
export function decorateSession(session, activeSessionId) {
const lastMessage = [...session.messages].reverse().find((m) => !m.expired && typeof m.message === 'string' && m.message.trim());
const s = session.status;
const isUp = s === 'connected' || s === 'verified';
const isPending = s === 'connecting' || s === 'verifying' || s === 'new';
// Avatar dot + sub-text: while a session is up, reflect the PEER's advertised presence;
// otherwise reflect the connection state (amber = connecting, red = dropped).
let dot, headerSub;
if (isPending) {
dot = '#e3b341';
headerSub = statusSub(s);
} else if (isUp) {
dot = session.peerPresence ? (PRESENCE_DOT[session.peerPresence] || '#6b6b73') : '#3ecf8e';
headerSub = session.peerPresence ? (PRESENCE_WORD[session.peerPresence] || 'Online') : 'P2P · connected';
} else {
dot = '#e5727a';
headerSub = statusSub(s);
}
const preview = lastMessage ? lastMessage.message : headerSub;
return {
id: session.id,
name: session.peerLabel,
mono: monoInitials(session.peerLabel),
dot,
headerSub,
status: session.status,
peerPresence: session.peerPresence,
preview,
unread: session.unreadCount > 0 ? (session.unreadCount > 99 ? '99+' : String(session.unreadCount)) : null,
verified: !!session.sas.isVerified,
active: session.id === activeSessionId,
inactive: session.id !== activeSessionId
};
}
export function decorateSessions(state) {
return state.order
.map((id) => state.sessions[id])
.filter(Boolean)
.map((s) => decorateSession(s, state.activeSessionId));
}
+47 -27
View File
@@ -155,8 +155,20 @@ class UpdateManager {
}
const meta = await response.json();
// The service worker returns a fallback meta { error: 'Network unavailable',
// version: <default> } when it can't reach the network. That default ("v4.7.56")
// must NOT be treated as a real server version — otherwise a transient drop pops a
// bogus "Update available → v4.7.56". Ignore any error-tagged response.
if (meta && meta.error) {
if (this.options.debug) {
console.warn('⚠️ meta.json came from offline fallback — skipping update check:', meta.error);
}
return { hasUpdate: false, error: meta.error };
}
this.serverVersion = meta.version || meta.buildVersion || null;
if (!this.serverVersion) {
throw new Error('Version not found in meta.json');
}
@@ -249,47 +261,55 @@ class UpdateManager {
}
this.isUpdating = true;
// Step logging (always on) so we can see exactly where an update stalls.
const log = (m) => { try { console.log('🔧 [update] ' + m); } catch (_) {} };
// Run a cleanup step but never let it block the reload: it still executes fully, we just
// stop AWAITING it past `ms`. This keeps all the security cleanup (SW caches, SW
// unregister, storage wipe) while guaranteeing the page reloads.
const capped = (label, promise, ms) => Promise.race([
Promise.resolve(promise).then(() => log(label + ' done')).catch((e) => log(label + ' error: ' + (e && e.message))),
new Promise((resolve) => setTimeout(() => { log(label + ' still running after ' + ms + 'ms — continuing'); resolve(); }, ms))
]);
const navigate = () => {
log('navigating to new version…');
try { window.location.href = `${window.location.pathname}?v=${Date.now()}&_update=true`; }
catch (_) { try { window.location.reload(); } catch (__) {} }
};
try {
if (this.options.debug) {
console.log('🚀 Starting force update...');
}
log('start (online=' + (typeof navigator !== 'undefined' ? navigator.onLine : 'n/a') + ')');
// Step 1: Preserve critical data
const preservedData = this.preserveCriticalData();
// Step 2: Clear Service Worker caches
await this.clearServiceWorkerCaches();
// Step 3: Unregister Service Workers
await this.unregisterServiceWorkers();
// Step 2: Clear Service Worker caches (time-boxed, still runs fully)
await capped('clearServiceWorkerCaches', this.clearServiceWorkerCaches(), 3000);
// Step 3: Unregister Service Workers (time-boxed, still runs fully)
await capped('unregisterServiceWorkers', this.unregisterServiceWorkers(), 3000);
// Step 4: Clear browser cache (localStorage, sessionStorage)
this.clearBrowserCaches();
log('browser caches cleared');
// Step 5: Update version
if (this.serverVersion) {
this.setLocalVersion(this.serverVersion);
}
// Step 6: Restore critical data
this.restoreCriticalData(preservedData);
// Step 7: Force reload with cache-busting
if (this.options.debug) {
console.log('🔄 Reloading page with new version...');
}
// Small delay to complete operations
await new Promise(resolve => setTimeout(resolve, 500));
// Reload with full cache bypass
window.location.href = `${window.location.pathname}?v=${Date.now()}&_update=true`;
await new Promise(resolve => setTimeout(resolve, 300));
navigate();
} catch (error) {
this.handleError('Force update failed', error);
this.isUpdating = false;
throw error;
// The new build loads from the network regardless — never leave the user stuck.
navigate();
}
}
+5
View File
@@ -8,6 +8,11 @@ let CACHE_NAME = 'securebit-pwa-v4.7.56';
let STATIC_CACHE = 'securebit-pwa-static-v4.7.56';
let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1782446255208';
// Load version from meta.json on install
async function getAppVersion() {
try {
+139
View File
@@ -0,0 +1,139 @@
// Verifies the multi-session reducer keeps sessions fully isolated: a change to one
// session never mutates another, unread only grows for non-active received traffic, and
// removing a session re-points the active pointer without disturbing siblings.
import assert from 'node:assert/strict';
const {
sessionsReducer,
createInitialState,
createSessionEntry,
SESSION_ACTIONS: A,
decorateSession,
monoInitials,
statusDot
} = await import('../src/state/sessionsStore.js');
function withTwoSessions() {
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'atlas repo' }) });
return state;
}
// CREATE_SESSION activates the new session and preserves order.
{
const state = withTwoSessions();
assert.deepEqual(state.order, ['a', 'b']);
assert.equal(state.activeSessionId, 'b', 'newest session becomes active');
assert.equal(Object.keys(state.sessions).length, 2);
}
// Isolation: mutating session B leaves session A's object referentially untouched.
{
const before = withTwoSessions();
const aRef = before.sessions.a;
const after = sessionsReducer(before, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'hi', type: 'sent' } });
assert.equal(after.sessions.a, aRef, 'session A object must be the same reference after editing B');
assert.equal(after.sessions.b.messages.length, 1);
assert.equal(after.sessions.a.messages.length, 0, 'A transcript untouched');
// And the original state object was not mutated in place.
assert.equal(before.sessions.b.messages.length, 0, 'reducer is immutable');
}
// SET_STATUS / SET_FINGERPRINT / SET_SAS are scoped to one session.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
state = sessionsReducer(state, { type: A.SET_SAS, id: 'a', sas: { isVerified: true, bothConfirmed: true } });
state = sessionsReducer(state, { type: A.SET_FINGERPRINT, id: 'a', fingerprint: 'AB:CD' });
assert.equal(state.sessions.a.status, 'verified');
assert.equal(state.sessions.a.sas.isVerified, true);
assert.equal(state.sessions.a.keyFingerprint, 'AB:CD');
assert.equal(state.sessions.b.status, 'new', 'sibling status untouched');
assert.equal(state.sessions.b.sas.isVerified, false, 'sibling SAS untouched');
assert.equal(state.sessions.b.keyFingerprint, '', 'sibling fingerprint untouched');
}
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, mid: 'm1', message: 'x', type: 'sent', status: 'sending' } });
state = sessionsReducer(state, { type: A.UPDATE_MESSAGE_STATUS, id: 'a', mid: 'm1', status: 'delivered' });
assert.equal(state.sessions.a.messages[0].status, 'delivered');
state = sessionsReducer(state, { type: A.DELETE_MESSAGE, id: 'a', mid: 'm1' });
assert.equal(state.sessions.a.messages.length, 0);
assert.equal(state.sessions.b.messages.length, 0);
}
// Unread bookkeeping.
{
let state = withTwoSessions(); // active = b
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
assert.equal(state.sessions.a.unreadCount, 2);
assert.equal(state.sessions.b.unreadCount, 0);
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
state = sessionsReducer(state, { type: A.CLEAR_UNREAD, id: 'a' });
assert.equal(state.sessions.a.unreadCount, 0);
assert.equal(state.activeSessionId, 'a');
}
// PATCH_SETUP merges, scoped per session.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.PATCH_SETUP, id: 'a', patch: { offerData: 'OFFER', showOfferStep: true } });
assert.equal(state.sessions.a.setup.offerData, 'OFFER');
assert.equal(state.sessions.a.setup.showOfferStep, true);
assert.equal(state.sessions.a.setup.answerData, '', 'untouched setup field keeps default');
assert.equal(state.sessions.b.setup.offerData, '', 'sibling setup untouched');
}
// RENAME marks the label custom.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.RENAME, id: 'a', label: 'Alice' });
assert.equal(state.sessions.a.peerLabel, 'Alice');
assert.equal(state.sessions.a.labelIsCustom, true);
assert.equal(state.sessions.b.labelIsCustom, false);
}
// REMOVE_SESSION re-points active to the previous sibling and leaves the rest intact.
{
let state = withTwoSessions(); // order [a,b], active b
const bRef = state.sessions.b;
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'a' });
assert.equal(state.sessions.a, undefined, 'a removed');
assert.equal(state.sessions.b, bRef, 'sibling b object untouched');
assert.deepEqual(state.order, ['b']);
assert.equal(state.activeSessionId, 'b', 'active re-pointed to remaining session');
}
// REMOVE_SESSION on the last session leaves no active.
{
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'solo' }) });
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'solo' });
assert.equal(state.activeSessionId, null);
assert.deepEqual(state.order, []);
}
// Decorators mirror the design helpers.
{
assert.equal(monoInitials('work laptop'), 'WL');
assert.equal(monoInitials('atlas'), 'AT');
assert.equal(statusDot('verified'), '#3ecf8e');
assert.equal(statusDot('connecting'), '#e3b341');
assert.equal(statusDot('disconnected'), '#e5727a');
const entry = createSessionEntry({ id: 'a', peerLabel: 'work laptop' });
entry.unreadCount = 3;
entry.status = 'connecting';
const d = decorateSession(entry, 'b');
assert.equal(d.mono, 'WL');
assert.equal(d.unread, '3');
assert.equal(d.active, false);
assert.equal(d.inactive, true);
}
console.log('sessions-reducer.test.mjs: all assertions passed');