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:
shekohex
2026-03-04 00:32:23 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 575cfa2604
commit ca18b8be0f
13 changed files with 926 additions and 36 deletions
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import { getPWADisplayMode, type PWADisplayMode } from '@/lib/pwa';
type PwaDetectionState = {
displayMode: PWADisplayMode;
installed: boolean;
browserTab: boolean;
};
const getState = (): PwaDetectionState => {
const displayMode = getPWADisplayMode();
return {
displayMode,
installed: displayMode !== 'browser',
browserTab: displayMode === 'browser',
};
};
export const usePwaDetection = (): PwaDetectionState => {
const [state, setState] = React.useState<PwaDetectionState>(() => getState());
React.useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
}
const queries = [
window.matchMedia('(display-mode: standalone)'),
window.matchMedia('(display-mode: minimal-ui)'),
window.matchMedia('(display-mode: fullscreen)'),
window.matchMedia('(display-mode: window-controls-overlay)'),
];
const onChange = () => {
setState(getState());
};
onChange();
for (const query of queries) {
query.addEventListener('change', onChange);
}
window.addEventListener('appinstalled', onChange);
window.addEventListener('focus', onChange);
return () => {
for (const query of queries) {
query.removeEventListener('change', onChange);
}
window.removeEventListener('appinstalled', onChange);
window.removeEventListener('focus', onChange);
};
}, []);
return state;
};
@@ -0,0 +1,87 @@
import React from 'react';
import { toast } from '@/components/ui';
import { isWebRuntime } from '@/lib/desktop';
import { usePwaDetection } from '@/hooks/usePwaDetection';
type InstallPromptOutcome = 'accepted' | 'dismissed';
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: InstallPromptOutcome }>;
};
export const usePwaInstallPrompt = () => {
const { browserTab } = usePwaDetection();
React.useEffect(() => {
if (typeof window === 'undefined' || !isWebRuntime() || !browserTab) {
return;
}
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let installToastId: string | number | null = null;
const dismissInstallToast = () => {
if (installToastId === null) {
return;
}
toast.dismiss(installToastId);
installToastId = null;
};
const triggerInstall = async () => {
if (!deferredPrompt) {
return;
}
const promptEvent = deferredPrompt;
deferredPrompt = null;
dismissInstallToast();
await promptEvent.prompt();
const { outcome } = await promptEvent.userChoice;
if (outcome === 'accepted') {
toast.success('Install started');
}
};
const onBeforeInstallPrompt = (event: Event) => {
const installEvent = event as BeforeInstallPromptEvent;
if (typeof installEvent.prompt !== 'function') {
return;
}
installEvent.preventDefault();
deferredPrompt = installEvent;
if (installToastId !== null) {
return;
}
installToastId = toast.info('Install OpenChamber for quicker access', {
duration: Infinity,
action: {
label: 'Install',
onClick: () => {
void triggerInstall();
},
},
});
};
const onAppInstalled = () => {
deferredPrompt = null;
dismissInstallToast();
toast.success('OpenChamber installed');
};
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
window.addEventListener('appinstalled', onAppInstalled);
return () => {
dismissInstallToast();
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
window.removeEventListener('appinstalled', onAppInstalled);
};
}, [browserTab]);
};
@@ -0,0 +1,90 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { isWebRuntime } from '@/lib/desktop';
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
type RecentSessionShortcut = {
sessionId: string;
title: string;
};
type ManifestSyncWindow = Window & {
__OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void;
};
const MAX_RECENT_SHORTCUTS = 3;
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return fallback;
}
return normalized.slice(0, 48);
};
const buildRecentShortcuts = (
sessions: Array<{ id: string; title?: string }>,
currentSessionId: string | null,
): RecentSessionShortcut[] => {
const ordered = currentSessionId
? [
...sessions.filter((session) => session.id === currentSessionId),
...sessions.filter((session) => session.id !== currentSessionId),
]
: sessions;
const shortcuts: RecentSessionShortcut[] = [];
const seen = new Set<string>();
for (const session of ordered) {
const sessionId = typeof session.id === 'string' ? session.id.trim() : '';
if (!sessionId || seen.has(sessionId)) {
continue;
}
seen.add(sessionId);
shortcuts.push({
sessionId,
title: normalizeRecentTitle(session.title, `Session ${shortcuts.length + 1}`),
});
if (shortcuts.length >= MAX_RECENT_SHORTCUTS) {
break;
}
}
return shortcuts;
};
export const usePwaManifestSync = () => {
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const recentShortcuts = React.useMemo(() => {
return buildRecentShortcuts(sessions, currentSessionId);
}, [currentSessionId, sessions]);
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
React.useEffect(() => {
if (typeof window === 'undefined' || !isWebRuntime()) {
return;
}
try {
if (recentShortcuts.length === 0) {
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
} else {
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
}
} catch {
return;
}
const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
}, [recentShortcuts, signature]);
};