Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/ui": "^4.1.8",
"chai": "^6.0.1",
"eslint": "^9.39.2",
Expand Down
387 changes: 354 additions & 33 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

21 changes: 16 additions & 5 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
{
"id": "/?source=pwa",
"name": "QryptChat - Quantum-Resistant Messaging",
"short_name": "QryptChat",
"description": "Secure, quantum-resistant end-to-end encrypted messaging",
"version": "1.0",
"lang": "en",
"dir": "ltr",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "portrait",
"theme_color": "#6366f1",
"background_color": "#ffffff",
"categories": ["social", "communication", "productivity"],
"icons": [
{
"src": "/icons/android-chrome-36x36.png",
Expand Down Expand Up @@ -59,8 +64,14 @@
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"splash_pages": null
}
]
}
71 changes: 63 additions & 8 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@
* Basic offline functionality without ES module imports
*/

const CACHE_NAME = 'qryptchat-v1';
// Bumped to v1 -> v2 so clients running the old cache-first worker drop the
// stale app shell they precached on install.
const CACHE_NAME = 'qryptchat-v2';
const OFFLINE_SHELL = '/';
const STATIC_CACHE_URLS = [
'/',
OFFLINE_SHELL,
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
'/favicon.svg'
];

/**
* Content-addressed or rarely-changing assets that are safe to serve from cache.
* Everything else (API routes, SSE streams, auth) must always hit the network —
* encrypted message payloads must never come back from a cache.
* @param {URL} url
* @returns {boolean}
*/
function isCacheableAsset(url) {
if (url.pathname.startsWith('/api/')) return false;
return (
url.pathname.startsWith('/_next/static/') ||
url.pathname.startsWith('/icons/') ||
url.pathname.startsWith('/fonts/') ||
url.pathname === '/manifest.json' ||
/\.(?:png|jpe?g|svg|webp|ico|woff2?|css)$/.test(url.pathname)
);
}

// Install event - cache static resources
self.addEventListener('install', (event) => {
console.log('Service Worker installing...');
Expand Down Expand Up @@ -48,14 +69,48 @@ self.addEventListener('activate', (event) => {
);
});

// Fetch event - serve from cache, fallback to network
// Fetch event
self.addEventListener('fetch', (event) => {
const { request } = event;

// Never intercept uploads/mutations or third-party origins (Supabase, analytics).
if (request.method !== 'GET') return;

let url;
try {
url = new URL(request.url);
} catch {
return;
}
if (url.origin !== self.location.origin) return;

// Page loads are network-first with the cached shell as an offline fallback.
// Cache-first here pinned the installed app to the HTML captured at install
// time, whose hashed Next.js chunks stop existing after the next deploy.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok && url.pathname === OFFLINE_SHELL) {
const copy = response.clone();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.put(OFFLINE_SHELL, copy))
);
}
return response;
})
.catch(async () => {
const cached = await caches.match(OFFLINE_SHELL);
return cached || Response.error();
})
);
return;
}

if (!isCacheableAsset(url)) return;

event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request);
})
caches.match(request).then((cached) => cached || fetch(request))
);
});

Expand Down
56 changes: 51 additions & 5 deletions src/app/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,47 @@ export const metadata = {
},
description: 'End-to-end encrypted messaging with post-quantum cryptography (ML-KEM-1024 / CRYSTALS-Kyber and CRYSTALS-Dilithium). Open source, self-hostable, and accessible over Tor.',
applicationName: 'QryptChat',
// Chromium refuses to offer "Install app" without a linked manifest, and iOS
// needs the apple-* tags below for "Add to Home Screen" to open standalone.
// Both were lost when src/app.html went away in the Next.js migration.
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'QryptChat',
},
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/icons/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{ url: '/icons/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
// Desktop environments (KDE/Plasma, GNOME) pick the largest declared icon
// for the installed app's launcher entry.
{ url: '/icons/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icons/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
shortcut: ['/favicon.ico'],
apple: [
{ url: '/icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
{ url: '/icons/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png' },
{ url: '/icons/apple-touch-icon-144x144.png', sizes: '144x144', type: 'image/png' },
{ url: '/icons/apple-touch-icon-120x120.png', sizes: '120x120', type: 'image/png' },
{ url: '/icons/apple-touch-icon-114x114.png', sizes: '114x114', type: 'image/png' },
{ url: '/icons/apple-touch-icon-76x76.png', sizes: '76x76', type: 'image/png' },
{ url: '/icons/apple-touch-icon-72x72.png', sizes: '72x72', type: 'image/png' },
{ url: '/icons/apple-touch-icon-60x60.png', sizes: '60x60', type: 'image/png' },
{ url: '/icons/apple-touch-icon-57x57.png', sizes: '57x57', type: 'image/png' },
],
},
other: {
// `appleWebApp.capable` above emits the modern `mobile-web-app-capable`.
// iOS before 15.4 ignores the manifest's `display` and only goes standalone
// on the apple-prefixed tag, which Next no longer emits — declare it here.
'apple-mobile-web-app-capable': 'yes',
'msapplication-TileColor': '#6366f1',
'msapplication-TileImage': '/icons/mstile-144x144.png',
'msapplication-config': '/icons/browserconfig.xml',
},
keywords: [
'quantum-resistant messaging',
'post-quantum cryptography',
Expand Down Expand Up @@ -47,6 +88,13 @@ export const metadata = {
},
};

export const viewport = {
themeColor: '#ffffff',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};

const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
Expand Down Expand Up @@ -82,11 +130,9 @@ export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
{/* Icons, viewport, theme-color and the apple-mobile-web-app-* tags are
emitted from the `metadata` / `viewport` exports above — declaring
them here too produced duplicate tags. */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
Expand Down
21 changes: 16 additions & 5 deletions static/manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
{
"id": "/?source=pwa",
"name": "QryptChat - Quantum-Resistant Messaging",
"short_name": "QryptChat",
"description": "Secure, quantum-resistant end-to-end encrypted messaging",
"version": "1.0",
"lang": "en",
"dir": "ltr",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "portrait",
"theme_color": "#6366f1",
"background_color": "#ffffff",
"categories": ["social", "communication", "productivity"],
"icons": [
{
"src": "/icons/android-chrome-36x36.png",
Expand Down Expand Up @@ -59,8 +64,14 @@
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"splash_pages": null
}
]
}
111 changes: 111 additions & 0 deletions tests/pwa-installability.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* @fileoverview Guards the PWA install criteria.
*
* The SvelteKit -> Next.js migration dropped src/app.html, which carried the
* `<link rel="manifest">` tag. Nothing in the Next layout replaced it, so
* Chromium stopped offering "Install app" on desktop and Android, and iOS
* "Add to Home Screen" stopped opening standalone. These tests fail if any of
* that wiring is removed again.
*/

import { describe, it, expect } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const ROOT = resolve(process.cwd());
const readText = (p) => readFileSync(resolve(ROOT, p), 'utf8');

describe('PWA installability', () => {
describe('public/manifest.json', () => {
const manifest = JSON.parse(readText('public/manifest.json'));

it('declares the identity fields Chromium requires to install', () => {
expect(manifest.name).toBeTruthy();
expect(manifest.short_name).toBeTruthy();
expect(manifest.start_url).toBeTruthy();
expect(manifest.scope).toBeTruthy();
});

it('requests a standalone window rather than a browser tab', () => {
expect(manifest.display).toBe('standalone');
});

it('sets the colors used for the installed window and splash screen', () => {
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{3,8}$/i);
expect(manifest.background_color).toMatch(/^#[0-9a-f]{3,8}$/i);
});

it('ships both icon sizes Chromium requires (192px and 512px PNG)', () => {
const pngSizes = manifest.icons
.filter((icon) => icon.type === 'image/png')
.map((icon) => Number.parseInt(icon.sizes.split('x')[0], 10));

expect(pngSizes).toContain(192);
expect(pngSizes).toContain(512);
});

it('offers a maskable icon so Android does not letterbox the launcher icon', () => {
const maskable = manifest.icons.filter((icon) =>
String(icon.purpose ?? '').split(/\s+/).includes('maskable')
);
expect(maskable.length).toBeGreaterThan(0);
});

it('points every icon at a file that exists', () => {
for (const icon of manifest.icons) {
expect(existsSync(resolve(ROOT, 'public', icon.src.replace(/^\//, '')))).toBe(true);
}
});

it('keeps the app identity stable for already-installed clients', () => {
// With `id` absent, Chromium derives it from start_url. Declaring a
// different id would register as a brand new app and orphan existing
// installs, so it has to keep matching start_url.
expect(manifest.id).toBe(manifest.start_url);
});
});

describe('src/app/layout.jsx', () => {
const layout = readText('src/app/layout.jsx');

it('links the manifest from the root layout metadata', () => {
expect(layout).toMatch(/manifest:\s*['"]\/manifest\.json['"]/);
});

it('declares apple-mobile-web-app-capable for iOS Add to Home Screen', () => {
expect(layout).toMatch(/appleWebApp:\s*\{[^}]*capable:\s*true/s);
});

it('exports a viewport with a theme color', () => {
expect(layout).toMatch(/export const viewport\s*=/);
expect(layout).toMatch(/themeColor:/);
});

it('does not hand-roll head tags that the metadata exports already emit', () => {
expect(layout).not.toMatch(/<meta\s+name="viewport"/);
expect(layout).not.toMatch(/<meta\s+name="theme-color"/);
expect(layout).not.toMatch(/<meta\s+name="apple-mobile-web-app-capable"/);
});
});

describe('public/sw.js', () => {
const sw = readText('public/sw.js');

it('registers a fetch handler', () => {
expect(sw).toMatch(/addEventListener\(\s*['"]fetch['"]/);
});

it('serves navigations network-first so the app shell cannot go stale', () => {
expect(sw).toMatch(/request\.mode\s*===\s*['"]navigate['"]/);
});

it('never serves API responses from cache', () => {
expect(sw).toMatch(/pathname\.startsWith\(\s*['"]\/api\/['"]\s*\)/);
});

it('leaves non-GET and cross-origin requests alone', () => {
expect(sw).toMatch(/request\.method\s*!==\s*['"]GET['"]/);
expect(sw).toMatch(/url\.origin\s*!==\s*self\.location\.origin/);
});
});
});
Loading