- Remove dead precache manifest from SW (no fetch handler to consume it) - Set empty globPatterns in vite-plugin-pwa config - Fix bottom chin bar padding: use scaled safe-area token instead of raw inset - Add 'New Session' PWA shortcut (client + server manifest) - Add PWA meta tags to mobile.html (manifest, Apple icons, theme-color) - Fix static site.webmanifest: add id/scope, correct description - Remove duplicate apple-mobile-web-app-title from index.html - Move SW registration to module scope (earlier, no window.load delay) - Update manifest route tests for new shortcut order
265 lines
8.9 KiB
JavaScript
265 lines
8.9 KiB
JavaScript
const DEFAULT_PWA_APP_NAME = 'OpenChamber';
|
|
const mapPwaOrientationToManifest = (value) => {
|
|
if (value === 'portrait') {
|
|
return 'portrait-primary';
|
|
}
|
|
if (value === 'landscape') {
|
|
return 'landscape-primary';
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
export const registerPwaManifestRoute = (app, dependencies) => {
|
|
const {
|
|
process,
|
|
resolveProjectDirectory,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
readSettingsFromDiskMigrated,
|
|
normalizePwaAppName,
|
|
normalizePwaOrientation,
|
|
} = dependencies;
|
|
|
|
const recentPwaSessionsCache = new Map();
|
|
|
|
const getRecentPwaSessionShortcuts = async (req) => {
|
|
const now = Date.now();
|
|
|
|
const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null }));
|
|
const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string'
|
|
? resolvedDirectoryResult.directory
|
|
: null;
|
|
|
|
const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global';
|
|
const cached = recentPwaSessionsCache.get(cacheKey);
|
|
if (cached && now - cached.at < 5000) {
|
|
return cached.data;
|
|
}
|
|
|
|
const normalizeShortcutTitle = (value, fallback) => {
|
|
const normalized = normalizePwaAppName(value, fallback);
|
|
return normalized.length > 48 ? normalized.slice(0, 48) : normalized;
|
|
};
|
|
|
|
const toFiniteNumber = (value) => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const normalizeDirectory = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return '';
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
const normalized = trimmed.replace(/\\/g, '/');
|
|
if (normalized === '/') {
|
|
return '/';
|
|
}
|
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
};
|
|
|
|
const sessionUpdatedAt = (session) => {
|
|
const time = session && typeof session.time === 'object' ? session.time : null;
|
|
return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0;
|
|
};
|
|
|
|
const filterSessionsByDirectory = (sessions, directory) => {
|
|
const normalizedDirectory = normalizeDirectory(directory);
|
|
if (!normalizedDirectory) {
|
|
return sessions;
|
|
}
|
|
|
|
const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`;
|
|
return sessions.filter((session) => {
|
|
const sessionDirectory = normalizeDirectory(session?.directory);
|
|
if (!sessionDirectory) {
|
|
return false;
|
|
}
|
|
return sessionDirectory === normalizedDirectory || sessionDirectory.startsWith(prefix);
|
|
});
|
|
};
|
|
|
|
const listSessions = async (directory) => {
|
|
const query = (() => {
|
|
if (typeof directory !== 'string' || directory.length === 0) {
|
|
return '';
|
|
}
|
|
const preparedDirectory = process.platform === 'win32'
|
|
? directory.replace(/\//g, '\\\\')
|
|
: directory;
|
|
return `?directory=${encodeURIComponent(preparedDirectory)}`;
|
|
})();
|
|
|
|
const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...getOpenCodeAuthHeaders(),
|
|
},
|
|
signal: AbortSignal.timeout(2500),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return [];
|
|
}
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
return Array.isArray(payload) ? payload : [];
|
|
};
|
|
|
|
try {
|
|
let payload = [];
|
|
|
|
if (preferredDirectory) {
|
|
const scopedPayload = await listSessions(preferredDirectory);
|
|
const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory);
|
|
|
|
if (filteredScopedPayload.length > 0) {
|
|
payload = filteredScopedPayload;
|
|
} else {
|
|
const globalPayload = await listSessions(null);
|
|
const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory);
|
|
payload = filteredGlobalPayload;
|
|
}
|
|
} else {
|
|
payload = await listSessions(null);
|
|
}
|
|
|
|
const seen = new Set();
|
|
const rows = [];
|
|
|
|
for (const item of payload) {
|
|
if (!item || typeof item !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : '';
|
|
if (!id || seen.has(id)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(id);
|
|
const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`);
|
|
const updatedAt = sessionUpdatedAt(item);
|
|
|
|
rows.push({ id, title, updatedAt });
|
|
}
|
|
|
|
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
|
|
const shortcuts = rows.slice(0, 3).map((session) => ({
|
|
name: session.title,
|
|
short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title,
|
|
description: 'Open recent session',
|
|
url: `/?session=${encodeURIComponent(session.id)}`,
|
|
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
|
}));
|
|
|
|
recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts });
|
|
return shortcuts;
|
|
} catch {
|
|
recentPwaSessionsCache.set(cacheKey, { at: now, data: [] });
|
|
return [];
|
|
}
|
|
};
|
|
|
|
app.get('/manifest.webmanifest', async (req, res) => {
|
|
const hasQueryOverride =
|
|
typeof req.query?.pwa_name === 'string'
|
|
|| typeof req.query?.app_name === 'string'
|
|
|| typeof req.query?.appName === 'string';
|
|
|
|
let queryValueRaw = '';
|
|
if (typeof req.query?.pwa_name === 'string') {
|
|
queryValueRaw = req.query.pwa_name;
|
|
} else if (typeof req.query?.app_name === 'string') {
|
|
queryValueRaw = req.query.app_name;
|
|
} else if (typeof req.query?.appName === 'string') {
|
|
queryValueRaw = req.query.appName;
|
|
}
|
|
|
|
const queryOverrideName = normalizePwaAppName(queryValueRaw, '');
|
|
const hasOrientationOverride = typeof req.query?.orientation === 'string';
|
|
const queryOverrideOrientation = normalizePwaOrientation(req.query?.orientation, 'system');
|
|
|
|
let storedName = '';
|
|
let storedOrientation = 'system';
|
|
try {
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
storedName = normalizePwaAppName(settings?.pwaAppName, '');
|
|
storedOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
|
|
} catch {
|
|
storedName = '';
|
|
storedOrientation = 'system';
|
|
}
|
|
|
|
const appName = hasQueryOverride
|
|
? (queryOverrideName || DEFAULT_PWA_APP_NAME)
|
|
: (storedName || DEFAULT_PWA_APP_NAME);
|
|
const manifestOrientation = mapPwaOrientationToManifest(
|
|
hasOrientationOverride ? queryOverrideOrientation : storedOrientation
|
|
);
|
|
|
|
const shortName = appName.length > 30 ? appName.slice(0, 30) : appName;
|
|
const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req);
|
|
|
|
const manifest = {
|
|
name: appName,
|
|
short_name: shortName,
|
|
description: 'Web interface companion for OpenCode AI coding agent',
|
|
id: '/',
|
|
start_url: '/',
|
|
scope: '/',
|
|
display: 'standalone',
|
|
display_override: ['window-controls-overlay'],
|
|
background_color: '#151313',
|
|
theme_color: '#edb449',
|
|
...(manifestOrientation ? { orientation: manifestOrientation } : {}),
|
|
icons: [
|
|
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
{ src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
|
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
|
{ src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
|
|
{ src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' },
|
|
{ src: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
|
|
{ src: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
|
|
],
|
|
shortcuts: [
|
|
{
|
|
name: 'New Session',
|
|
short_name: 'New',
|
|
description: 'Start a new coding session',
|
|
url: '/',
|
|
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
|
},
|
|
{
|
|
name: 'Appearance Settings',
|
|
short_name: 'Settings',
|
|
description: 'Open appearance settings',
|
|
url: '/?settings=appearance',
|
|
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
|
},
|
|
...recentSessionShortcuts,
|
|
],
|
|
categories: ['developer', 'tools', 'productivity'],
|
|
lang: 'en',
|
|
};
|
|
|
|
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
|
res.type('application/manifest+json');
|
|
res.send(JSON.stringify(manifest));
|
|
});
|
|
};
|