feat(pwa): pre-install naming, install UX, and manifest shortcuts (#554)
* feat(web-pwa): add dynamic manifest endpoint with blob fallback * feat(ui-pwa): add install prompt and manifest sync hooks * feat(settings): add web-only preinstall app name preference * fix(web-pwa): scope recent shortcuts to active project sessions --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
575cfa2604
commit
ca18b8be0f
+264
-28
@@ -21,39 +21,275 @@
|
||||
<link rel="preload" href="https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2"
|
||||
as="font" type="font/woff2" crossorigin="anonymous">
|
||||
|
||||
<!-- Web app manifest (data URL to avoid nginx auth issues) -->
|
||||
<!-- Web app manifest (endpoint-first with data URL fallback) -->
|
||||
<script>
|
||||
const baseUrl = location.origin;
|
||||
const manifest = {
|
||||
"name": "OpenChamber - AI Coding Assistant",
|
||||
"short_name": "OpenChamber",
|
||||
"description": "Web interface companion for OpenCode AI coding agent",
|
||||
"start_url": baseUrl + "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#151313",
|
||||
"theme_color": "#edb449",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{ "src": baseUrl + "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": baseUrl + "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": baseUrl + "/pwa-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": baseUrl + "/pwa-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": baseUrl + "/apple-touch-icon-180x180.png", "sizes": "180x180", "type": "image/png", "purpose": "any" },
|
||||
{ "src": baseUrl + "/apple-touch-icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
|
||||
{ "src": baseUrl + "/favicon-32.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": baseUrl + "/favicon-16.png", "sizes": "16x16", "type": "image/png" }
|
||||
],
|
||||
"categories": ["developer", "tools", "productivity"],
|
||||
"lang": "en"
|
||||
const defaultAppName = 'OpenChamber - AI Coding Assistant';
|
||||
const defaultShortName = 'OpenChamber';
|
||||
const pwaNameStorageKey = 'openchamber.pwaName';
|
||||
const pwaRecentSessionsStorageKey = 'openchamber.pwaRecentSessions';
|
||||
|
||||
const normalizePwaName = (value, fallback) => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized.slice(0, 64);
|
||||
};
|
||||
|
||||
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'});
|
||||
const manifestURL = URL.createObjectURL(manifestBlob);
|
||||
const truncate = (value, maxLength) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
};
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'manifest';
|
||||
link.href = manifestURL;
|
||||
document.head.appendChild(link);
|
||||
const getStoredInstallName = () => {
|
||||
try {
|
||||
const storedName = localStorage.getItem(pwaNameStorageKey);
|
||||
return normalizePwaName(storedName, defaultAppName);
|
||||
} catch {
|
||||
return defaultAppName;
|
||||
}
|
||||
};
|
||||
|
||||
const setStoredInstallName = (value) => {
|
||||
const normalizedName = normalizePwaName(value, '');
|
||||
try {
|
||||
if (normalizedName) {
|
||||
localStorage.setItem(pwaNameStorageKey, normalizedName);
|
||||
} else {
|
||||
localStorage.removeItem(pwaNameStorageKey);
|
||||
}
|
||||
} catch {
|
||||
return defaultAppName;
|
||||
}
|
||||
return normalizedName || defaultAppName;
|
||||
};
|
||||
|
||||
const getQueryInstallNameOverride = () => {
|
||||
try {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const queryName = params.get('pwa_name') ?? params.get('app_name') ?? params.get('appName');
|
||||
if (queryName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedQueryName = normalizePwaName(queryName, '');
|
||||
if (normalizedQueryName) {
|
||||
localStorage.setItem(pwaNameStorageKey, normalizedQueryName);
|
||||
return normalizedQueryName;
|
||||
}
|
||||
|
||||
localStorage.removeItem(pwaNameStorageKey);
|
||||
return defaultAppName;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseRecentSessionShortcuts = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(pwaRecentSessionsStorageKey);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const recentSessions = [];
|
||||
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = typeof item.sessionId === 'string' ? item.sessionId.trim().slice(0, 160) : '';
|
||||
if (!sessionId || seen.has(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackTitle = `Session ${recentSessions.length + 1}`;
|
||||
const title = truncate(normalizePwaName(item.title, fallbackTitle), 48);
|
||||
|
||||
seen.add(sessionId);
|
||||
recentSessions.push({ sessionId, title });
|
||||
|
||||
if (recentSessions.length >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recentSessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const buildShortcuts = (recentSessions) => {
|
||||
const shortcuts = [
|
||||
{
|
||||
name: 'Appearance Settings',
|
||||
short_name: 'Settings',
|
||||
description: 'Open appearance settings',
|
||||
url: `${baseUrl}/?settings=appearance`,
|
||||
icons: [{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const session of recentSessions) {
|
||||
const sessionTitle = truncate(session.title, 32);
|
||||
shortcuts.push({
|
||||
name: sessionTitle,
|
||||
short_name: sessionTitle,
|
||||
description: 'Open recent session',
|
||||
url: `${baseUrl}/?session=${encodeURIComponent(session.sessionId)}`,
|
||||
icons: [{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png' }],
|
||||
});
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
};
|
||||
|
||||
const buildManifest = (appName, recentSessions) => {
|
||||
const shortName = appName === defaultAppName ? defaultShortName : truncate(appName, 30);
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
description: 'Web interface companion for OpenCode AI coding agent',
|
||||
id: `${baseUrl}/`,
|
||||
start_url: `${baseUrl}/`,
|
||||
scope: `${baseUrl}/`,
|
||||
display: 'standalone',
|
||||
background_color: '#151313',
|
||||
theme_color: '#edb449',
|
||||
orientation: 'any',
|
||||
icons: [
|
||||
{ src: `${baseUrl}/pwa-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: `${baseUrl}/pwa-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: `${baseUrl}/pwa-maskable-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: `${baseUrl}/pwa-maskable-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: `${baseUrl}/apple-touch-icon-180x180.png`, sizes: '180x180', type: 'image/png', purpose: 'any' },
|
||||
{ src: `${baseUrl}/apple-touch-icon-152x152.png`, sizes: '152x152', type: 'image/png', purpose: 'any' },
|
||||
{ src: `${baseUrl}/favicon-32.png`, sizes: '32x32', type: 'image/png' },
|
||||
{ src: `${baseUrl}/favicon-16.png`, sizes: '16x16', type: 'image/png' },
|
||||
],
|
||||
shortcuts: buildShortcuts(recentSessions),
|
||||
categories: ['developer', 'tools', 'productivity'],
|
||||
lang: 'en',
|
||||
};
|
||||
};
|
||||
|
||||
const buildManifestEndpointUrl = (installNameOverride = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (typeof installNameOverride === 'string') {
|
||||
params.set('appName', installNameOverride);
|
||||
}
|
||||
const search = params.toString();
|
||||
return `${baseUrl}/manifest.webmanifest${search ? `?${search}` : ''}`;
|
||||
};
|
||||
|
||||
const manifestLink = document.createElement('link');
|
||||
manifestLink.rel = 'manifest';
|
||||
document.head.appendChild(manifestLink);
|
||||
|
||||
let activeManifestBlobUrl = null;
|
||||
let manifestRequestVersion = 0;
|
||||
|
||||
const setManifestFromBlob = (manifest) => {
|
||||
if (activeManifestBlobUrl) {
|
||||
URL.revokeObjectURL(activeManifestBlobUrl);
|
||||
}
|
||||
|
||||
const manifestBlob = new Blob([JSON.stringify(manifest)], { type: 'application/manifest+json' });
|
||||
activeManifestBlobUrl = URL.createObjectURL(manifestBlob);
|
||||
manifestLink.href = activeManifestBlobUrl;
|
||||
};
|
||||
|
||||
const setManifestFromEndpoint = (manifestUrl) => {
|
||||
if (activeManifestBlobUrl) {
|
||||
URL.revokeObjectURL(activeManifestBlobUrl);
|
||||
activeManifestBlobUrl = null;
|
||||
}
|
||||
manifestLink.href = manifestUrl;
|
||||
};
|
||||
|
||||
const canUseManifestEndpoint = async (manifestUrl, requestVersion) => {
|
||||
if (typeof fetch !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller?.abort();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
const response = await fetch(manifestUrl, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/manifest+json, application/json;q=0.9, */*;q=0.1',
|
||||
},
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
|
||||
if (requestVersion !== manifestRequestVersion || !response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
return /manifest|json/i.test(contentType);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const updateManifest = async (installNameOverride = null) => {
|
||||
const resolvedFallbackName = typeof installNameOverride === 'string' ? installNameOverride : getStoredInstallName();
|
||||
const recentSessions = parseRecentSessionShortcuts();
|
||||
const manifest = buildManifest(resolvedFallbackName, recentSessions);
|
||||
const manifestUrl = buildManifestEndpointUrl(installNameOverride);
|
||||
const requestVersion = ++manifestRequestVersion;
|
||||
|
||||
const useEndpoint = await canUseManifestEndpoint(manifestUrl, requestVersion);
|
||||
if (requestVersion !== manifestRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useEndpoint) {
|
||||
setManifestFromEndpoint(manifestUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setManifestFromBlob(manifest);
|
||||
};
|
||||
|
||||
const refreshManifestFromStorage = () => {
|
||||
void updateManifest();
|
||||
};
|
||||
|
||||
const initialInstallNameOverride = getQueryInstallNameOverride();
|
||||
void updateManifest(initialInstallNameOverride);
|
||||
|
||||
window.__OPENCHAMBER_GET_PWA_INSTALL_NAME__ = () => getStoredInstallName();
|
||||
window.__OPENCHAMBER_SET_PWA_INSTALL_NAME__ = (value) => {
|
||||
const resolvedName = setStoredInstallName(value);
|
||||
void updateManifest(resolvedName);
|
||||
return resolvedName;
|
||||
};
|
||||
window.__OPENCHAMBER_UPDATE_PWA_MANIFEST__ = () => {
|
||||
refreshManifestFromStorage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1760,6 +1760,20 @@ const sanitizeProjects = (input) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant';
|
||||
const PWA_APP_NAME_MAX_LENGTH = 64;
|
||||
|
||||
const normalizePwaAppName = (value, fallback = '') => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH);
|
||||
};
|
||||
|
||||
const sanitizeSettingsUpdate = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
@@ -1978,6 +1992,9 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
const trimmed = candidate.gitModelId.trim();
|
||||
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.pwaAppName === 'string') {
|
||||
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
||||
}
|
||||
if (typeof candidate.toolCallExpansion === 'string') {
|
||||
const mode = candidate.toolCallExpansion.trim();
|
||||
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
|
||||
@@ -2228,10 +2245,12 @@ const formatSettingsResponse = (settings) => {
|
||||
const approved = normalizeStringArray(settings.approvedDirectories);
|
||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||
const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
|
||||
return {
|
||||
...sanitized,
|
||||
hasNamedTunnelToken,
|
||||
...(pwaAppName ? { pwaAppName } : {}),
|
||||
approvedDirectories: approved,
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
@@ -13119,9 +13138,230 @@ async function main(options = {}) {
|
||||
},
|
||||
}));
|
||||
|
||||
// Alias for PWA manifest (.webmanifest redirect → /site.webmanifest)
|
||||
app.get('/manifest.webmanifest', (req, res) => {
|
||||
res.redirect(301, '/site.webmanifest');
|
||||
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 || (prefix !== '/' && 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.length > 0 ? filteredGlobalPayload : globalPayload;
|
||||
}
|
||||
} 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, '');
|
||||
|
||||
let storedName = '';
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
storedName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
} catch {
|
||||
storedName = '';
|
||||
}
|
||||
|
||||
const appName = hasQueryOverride
|
||||
? (queryOverrideName || DEFAULT_PWA_APP_NAME)
|
||||
: (storedName || DEFAULT_PWA_APP_NAME);
|
||||
|
||||
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',
|
||||
background_color: '#151313',
|
||||
theme_color: '#edb449',
|
||||
orientation: 'any',
|
||||
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: '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));
|
||||
});
|
||||
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user