Add multiple-session support: run several independent encrypted chats at once
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:
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#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
File diff suppressed because one or more lines are too long
Vendored
+1320
-497
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+24
-20
@@ -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>
|
||||
@@ -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
@@ -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": "./",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user