feat: add Web Push API support and PWA integration (#189)
* feat: add Web Push API support and PWA integration Add web Push API with subscribe/unsubscribe and visibility endpoints Introduce usePushVisibilityBeacon and useSessionDeepLink hooks Integrate PWA with service worker, registerSW, and VAPID key persistence * feat: add heartbeat visibility beacon for web runtime Add a 10s heartbeat to ping visibility while visible Subscribe to visibilitychange, focus, blur, pageshow, and pagehide events to report state Clear heartbeat interval on unmount to avoid leaks
This commit is contained in:
committed by
GitHub
parent
06c5e821a4
commit
1f23b63c0b
@@ -255,6 +255,9 @@ async fn handle_event(
|
||||
"question.asked" => {
|
||||
handle_question_asked(app, &event.properties, notified_questions).await;
|
||||
}
|
||||
"permission.asked" => {
|
||||
handle_permission_asked(app, &event.properties, notified_questions).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +267,7 @@ async fn handle_question_asked(
|
||||
properties: &Value,
|
||||
notified_questions: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
|
||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||
let question_id = properties.get("id").and_then(Value::as_str);
|
||||
|
||||
@@ -301,6 +305,54 @@ async fn handle_question_asked(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_permission_asked(
|
||||
app: &AppHandle,
|
||||
properties: &Value,
|
||||
notified_requests: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||
let request_id = properties.get("id").and_then(Value::as_str);
|
||||
|
||||
let (session_id, request_id) = match (session_id, request_id) {
|
||||
(Some(s), Some(r)) => (s, r),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let key = format!("{}:{}", session_id, request_id);
|
||||
{
|
||||
let mut notified = notified_requests.lock().await;
|
||||
if notified.contains(&key) {
|
||||
return;
|
||||
}
|
||||
notified.insert(key);
|
||||
}
|
||||
|
||||
let permission = properties
|
||||
.get("permission")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("Agent requested permission");
|
||||
|
||||
let should_notify = app
|
||||
.get_webview_window("main")
|
||||
.map(|window| {
|
||||
let focused = window.is_focused().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
!focused || minimized
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_notify {
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Permission required")
|
||||
.body(permission)
|
||||
.sound("Glass")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message_updated(
|
||||
app: &AppHandle,
|
||||
properties: &Value,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useMenuActions } from '@/hooks/useMenuActions';
|
||||
import { useMessageSync } from '@/hooks/useMessageSync';
|
||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useSessionDeepLink } from '@/hooks/useSessionDeepLink';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
@@ -156,6 +158,10 @@ function App({ apis }: AppProps) {
|
||||
|
||||
useEventStream();
|
||||
|
||||
usePushVisibilityBeacon();
|
||||
|
||||
useSessionDeepLink();
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleToggleMemoryDebug = React.useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
|
||||
export const NotificationSettings: React.FC = () => {
|
||||
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
|
||||
@@ -11,11 +14,41 @@ export const NotificationSettings: React.FC = () => {
|
||||
const setNotificationMode = useUIStore(state => state.setNotificationMode);
|
||||
|
||||
const [notificationPermission, setNotificationPermission] = React.useState<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = React.useState(false);
|
||||
const [pushSubscribed, setPushSubscribed] = React.useState(false);
|
||||
const [pushBusy, setPushBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof Notification !== 'undefined') {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
|
||||
const supported = typeof window !== 'undefined'
|
||||
&& 'serviceWorker' in navigator
|
||||
&& 'PushManager' in window
|
||||
&& 'Notification' in window;
|
||||
setPushSupported(supported);
|
||||
|
||||
const refresh = async () => {
|
||||
if (!supported) {
|
||||
setPushSubscribed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) {
|
||||
setPushSubscribed(false);
|
||||
return;
|
||||
}
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setPushSubscribed(Boolean(subscription));
|
||||
} catch {
|
||||
setPushSubscribed(false);
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
const handleToggleChange = async (checked: boolean) => {
|
||||
@@ -43,24 +76,299 @@ export const NotificationSettings: React.FC = () => {
|
||||
|
||||
const canShowNotifications = typeof Notification !== 'undefined' && Notification.permission === 'granted';
|
||||
|
||||
const base64UrlToUint8Array = (base64Url: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(label));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const waitForSwActive = async (registration: ServiceWorkerRegistration): Promise<void> => {
|
||||
if (registration.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = registration.installing || registration.waiting;
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidate.state === 'activated') {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
const onStateChange = () => {
|
||||
if (candidate.state === 'activated') {
|
||||
candidate.removeEventListener('statechange', onStateChange);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
candidate.addEventListener('statechange', onStateChange);
|
||||
onStateChange();
|
||||
}),
|
||||
15000,
|
||||
'Service worker activation timed out'
|
||||
);
|
||||
};
|
||||
|
||||
type RegistrationOptions = {
|
||||
scope?: string;
|
||||
type?: 'classic' | 'module';
|
||||
updateViaCache?: 'imports' | 'all' | 'none';
|
||||
};
|
||||
|
||||
const registerServiceWorker = async (): Promise<ServiceWorkerRegistration> => {
|
||||
if (typeof navigator.serviceWorker.register !== 'function') {
|
||||
throw new Error('navigator.serviceWorker.register unavailable');
|
||||
}
|
||||
|
||||
// iOS Safari can throw non-sensical internal errors when unsupported options
|
||||
// are passed. Try no-options first, then add options progressively.
|
||||
const attempts: Array<{ label: string; opts: RegistrationOptions | null }> = [
|
||||
{ label: 'no-options', opts: null },
|
||||
{ label: 'scope-root', opts: { scope: '/' } },
|
||||
{ label: 'type-classic', opts: { type: 'classic' } },
|
||||
{ label: 'type-classic-scope', opts: { type: 'classic', scope: '/' } },
|
||||
{ label: 'updateViaCache-none', opts: { type: 'classic', updateViaCache: 'none', scope: '/' } },
|
||||
];
|
||||
|
||||
let lastError: unknown = null;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const promise = attempt.opts
|
||||
? navigator.serviceWorker.register('/sw.js', attempt.opts)
|
||||
: navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
return await withTimeout(promise, 10000, `Service worker registration timed out (${attempt.label})`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('Service worker registration failed');
|
||||
};
|
||||
|
||||
const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration> => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service worker not supported');
|
||||
}
|
||||
|
||||
const existing = await navigator.serviceWorker.getRegistration();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const registered = await registerServiceWorker();
|
||||
|
||||
try {
|
||||
await registered.update();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await waitForSwActive(registered);
|
||||
return registered;
|
||||
};
|
||||
|
||||
|
||||
const formatUnknownError = (error: unknown) => {
|
||||
const anyError = error as { name?: unknown; message?: unknown; stack?: unknown } | null;
|
||||
const parts = [
|
||||
`type=${typeof error}`,
|
||||
`toString=${String(error)}`,
|
||||
`name=${String(anyError?.name ?? '')}`,
|
||||
`message=${String(anyError?.message ?? '')}`,
|
||||
];
|
||||
|
||||
let json = '';
|
||||
try {
|
||||
json = JSON.stringify(error);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
summary: parts.filter(Boolean).join(' | '),
|
||||
json,
|
||||
stack: typeof anyError?.stack === 'string' ? anyError.stack : '',
|
||||
};
|
||||
};
|
||||
|
||||
const handleEnableBackgroundNotifications = async () => {
|
||||
if (!pushSupported) {
|
||||
toast.error('Push notifications not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
setPushBusy(true);
|
||||
try {
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
|
||||
toast.error('Notification permission denied', {
|
||||
description: 'Enable notifications in your browser settings.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await apis.push.getVapidPublicKey();
|
||||
if (!key?.publicKey) {
|
||||
toast.error('Failed to load push key');
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
await waitForSwActive(registration);
|
||||
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!('pushManager' in registration) || !registration.pushManager) {
|
||||
throw new Error('PushManager unavailable (requires installed PWA + iOS 16.4+)');
|
||||
}
|
||||
|
||||
|
||||
const subscription = existing ?? await withTimeout(
|
||||
registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
// iOS Safari is picky here; pass Uint8Array (not ArrayBuffer).
|
||||
applicationServerKey: base64UrlToUint8Array(key.publicKey),
|
||||
}),
|
||||
15000,
|
||||
'Push subscription timed out'
|
||||
);
|
||||
|
||||
|
||||
const json = subscription.toJSON();
|
||||
const keys = json.keys;
|
||||
if (!json.endpoint || !keys?.p256dh || !keys.auth) {
|
||||
throw new Error('Push subscription missing keys');
|
||||
}
|
||||
|
||||
|
||||
const ok = await withTimeout(
|
||||
apis.push.subscribe({
|
||||
endpoint: json.endpoint,
|
||||
keys: {
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
},
|
||||
origin: typeof window !== 'undefined' ? window.location.origin : undefined,
|
||||
}),
|
||||
15000,
|
||||
'Push subscribe request timed out'
|
||||
);
|
||||
|
||||
|
||||
if (!ok?.ok) {
|
||||
toast.error('Failed to enable background notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
setPushSubscribed(true);
|
||||
toast.success('Background notifications enabled');
|
||||
} catch (error) {
|
||||
console.error('[Push] Enable failed:', error);
|
||||
const formatted = formatUnknownError(error);
|
||||
toast.error('Failed to enable background notifications', {
|
||||
description: formatted.summary,
|
||||
});
|
||||
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableBackgroundNotifications = async () => {
|
||||
if (!pushSupported) {
|
||||
setPushSubscribed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push) {
|
||||
toast.error('Push API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
setPushBusy(true);
|
||||
try {
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
setPushSubscribed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
await subscription.unsubscribe();
|
||||
await apis.push.unsubscribe({ endpoint });
|
||||
setPushSubscribed(false);
|
||||
toast.success('Background notifications disabled');
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWebRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Native Notifications
|
||||
Foreground Notifications
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Show browser notifications when an assistant completes a task.
|
||||
Uses the browser Notification API while OpenChamber is open.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable native notifications
|
||||
Enable foreground notifications
|
||||
</span>
|
||||
<Switch
|
||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||
@@ -73,10 +381,10 @@ export const NotificationSettings: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Always notify
|
||||
Notify even when visible
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
When off, only notifies if the window is out of focus.
|
||||
When off, only notifies when the tab is hidden or the window is not focused.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -93,10 +401,63 @@ export const NotificationSettings: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Notifications are enabled in your browser. Toggle the switch above to activate them.
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Permission granted, but foreground notifications are disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Background Notifications (Push)
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Uses push notifications; works when OpenChamber is closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!pushSupported ? (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Push not supported in this browser.
|
||||
</p>
|
||||
) : (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pushSupported && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable background notifications
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Opens chat with /?session=<id> deep link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{pushBusy && (
|
||||
<div className="text-muted-foreground">
|
||||
<GridLoader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={pushSubscribed}
|
||||
disabled={pushBusy}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1202,18 +1202,32 @@ export const useEventStream = () => {
|
||||
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
||||
'Session';
|
||||
|
||||
import('sonner').then(({ toast }) => {
|
||||
toast.warning('Permission required', {
|
||||
description: sessionTitle,
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: () => {
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
||||
import('sonner').then(({ toast }) => {
|
||||
toast.warning('Permission required', {
|
||||
description: sessionTitle,
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: () => {
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (isWebRuntime() && nativeNotificationsEnabled) {
|
||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
||||
if (shouldNotify) {
|
||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||
if (runtimeAPIs?.notifications) {
|
||||
void runtimeAPIs.notifications.notifyAgentCompletion({
|
||||
title: 'Permission required',
|
||||
body: sessionTitle,
|
||||
tag: `permission-${toastKey}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
const HEARTBEAT_MS = 10000;
|
||||
|
||||
const sendVisibility = (visible: boolean) => {
|
||||
if (!isWebRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
if (!apis?.push?.setVisibility) {
|
||||
return;
|
||||
}
|
||||
|
||||
void apis.push.setVisibility({ visible });
|
||||
};
|
||||
|
||||
export const usePushVisibilityBeacon = () => {
|
||||
React.useEffect(() => {
|
||||
if (!isWebRuntime() || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = () => {
|
||||
sendVisibility(document.visibilityState === 'visible');
|
||||
};
|
||||
|
||||
const reportVisibleOnly = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
sendVisibility(true);
|
||||
}
|
||||
};
|
||||
|
||||
report();
|
||||
|
||||
// Heartbeat while visible so server TTL (30s) never expires.
|
||||
const interval = window.setInterval(reportVisibleOnly, HEARTBEAT_MS);
|
||||
|
||||
document.addEventListener('visibilitychange', report);
|
||||
window.addEventListener('pagehide', report);
|
||||
window.addEventListener('pageshow', report);
|
||||
window.addEventListener('focus', report);
|
||||
window.addEventListener('blur', report);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', report);
|
||||
window.removeEventListener('pagehide', report);
|
||||
window.removeEventListener('pageshow', report);
|
||||
window.removeEventListener('focus', report);
|
||||
window.removeEventListener('blur', report);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export const useSessionDeepLink = () => {
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId: string | null = null;
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
sessionId = params.get('session');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId || sessionId.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
await setCurrentSession(sessionId as string);
|
||||
} finally {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('session');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
}, [setCurrentSession]);
|
||||
};
|
||||
@@ -456,6 +456,26 @@ export interface VSCodeAPI {
|
||||
openAgentManager(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PushSubscribePayload {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface PushUnsubscribePayload {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface PushAPI {
|
||||
getVapidPublicKey(): Promise<{ publicKey: string } | null>;
|
||||
subscribe(payload: PushSubscribePayload): Promise<{ ok: true } | null>;
|
||||
unsubscribe(payload: PushUnsubscribePayload): Promise<{ ok: true } | null>;
|
||||
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPIs {
|
||||
runtime: RuntimeDescriptor;
|
||||
terminal: TerminalAPI;
|
||||
@@ -464,6 +484,7 @@ export interface RuntimeAPIs {
|
||||
settings: SettingsAPI;
|
||||
permissions: PermissionsAPI;
|
||||
notifications: NotificationsAPI;
|
||||
push?: PushAPI;
|
||||
diagnostics?: DiagnosticsAPI;
|
||||
tools: ToolsAPI;
|
||||
editor?: EditorAPI;
|
||||
|
||||
@@ -536,6 +536,18 @@ const commands = {
|
||||
});
|
||||
});
|
||||
|
||||
// Important: in daemon mode we must close the IPC channel, otherwise the CLI
|
||||
// process can hang around as the parent of the detached server.
|
||||
try {
|
||||
child.removeAllListeners('message');
|
||||
child.removeAllListeners('exit');
|
||||
if (typeof child.disconnect === 'function' && child.connected) {
|
||||
child.disconnect();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (isProcessRunning(child.pid)) {
|
||||
const pidFilePathResolved = await getPidFilePath(resolvedPort);
|
||||
const instanceFilePathResolved = await getInstanceFilePath(resolvedPort);
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"strip-json-comments": "^5.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -83,7 +84,8 @@
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-pwa": "^1.0.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -10,6 +10,7 @@ import crypto from 'crypto';
|
||||
import { createUiAuth } from './lib/ui-auth.js';
|
||||
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
||||
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
||||
import webPush from 'web-push';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -357,6 +358,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||
|
||||
const readSettingsFromDisk = async () => {
|
||||
try {
|
||||
@@ -385,6 +387,55 @@ const writeSettingsToDisk = async (settings) => {
|
||||
}
|
||||
};
|
||||
|
||||
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
||||
let persistPushSubscriptionsLock = Promise.resolve();
|
||||
|
||||
const readPushSubscriptionsFromDisk = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
||||
}
|
||||
if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) {
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
||||
}
|
||||
|
||||
const subscriptionsBySession =
|
||||
parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object'
|
||||
? parsed.subscriptionsBySession
|
||||
: {};
|
||||
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession };
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
||||
}
|
||||
console.warn('Failed to read push subscriptions file:', error);
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
||||
}
|
||||
};
|
||||
|
||||
const writePushSubscriptionsToDisk = async (data) => {
|
||||
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const persistPushSubscriptionUpdate = async (mutate) => {
|
||||
persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => {
|
||||
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
||||
const current = await readPushSubscriptionsFromDisk();
|
||||
const next = mutate({
|
||||
version: PUSH_SUBSCRIPTIONS_VERSION,
|
||||
subscriptionsBySession: current.subscriptionsBySession || {},
|
||||
});
|
||||
await writePushSubscriptionsToDisk(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
return persistPushSubscriptionsLock;
|
||||
};
|
||||
|
||||
const resolveDirectoryCandidate = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -890,6 +941,248 @@ const readSettingsFromDiskMigrated = async () => {
|
||||
return settings;
|
||||
};
|
||||
|
||||
const getOrCreateVapidKeys = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.vapidKeys;
|
||||
if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') {
|
||||
return { publicKey: existing.publicKey, privateKey: existing.privateKey };
|
||||
}
|
||||
|
||||
const generated = webPush.generateVAPIDKeys();
|
||||
const next = {
|
||||
...settings,
|
||||
vapidKeys: {
|
||||
publicKey: generated.publicKey,
|
||||
privateKey: generated.privateKey,
|
||||
},
|
||||
};
|
||||
|
||||
await writeSettingsToDisk(next);
|
||||
return { publicKey: generated.publicKey, privateKey: generated.privateKey };
|
||||
};
|
||||
|
||||
const getUiSessionTokenFromRequest = (req) => {
|
||||
const cookieHeader = req?.headers?.cookie;
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const segments = cookieHeader.split(';');
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.split('=');
|
||||
const name = rawName?.trim();
|
||||
if (!name) continue;
|
||||
if (name !== 'oc_ui_session') continue;
|
||||
const value = rest.join('=').trim();
|
||||
try {
|
||||
return decodeURIComponent(value || '');
|
||||
} catch {
|
||||
return value || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
|
||||
if (!uiSessionToken) return [];
|
||||
const store = await readPushSubscriptionsFromDisk();
|
||||
const record = store.subscriptionsBySession?.[uiSessionToken];
|
||||
if (!Array.isArray(record)) return [];
|
||||
return record
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const endpoint = entry.endpoint;
|
||||
const p256dh = entry.p256dh;
|
||||
const auth = entry.auth;
|
||||
if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
|
||||
if (!uiSessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensurePushInitialized();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
await persistPushSubscriptionUpdate((current) => {
|
||||
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
||||
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
||||
|
||||
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
|
||||
|
||||
filtered.unshift({
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
||||
});
|
||||
|
||||
subsBySession[uiSessionToken] = filtered.slice(0, 10);
|
||||
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
||||
});
|
||||
};
|
||||
|
||||
const removePushSubscription = async (uiSessionToken, endpoint) => {
|
||||
if (!uiSessionToken || !endpoint) return;
|
||||
|
||||
await ensurePushInitialized();
|
||||
|
||||
await persistPushSubscriptionUpdate((current) => {
|
||||
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
||||
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
||||
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
||||
if (filtered.length === 0) {
|
||||
delete subsBySession[uiSessionToken];
|
||||
} else {
|
||||
subsBySession[uiSessionToken] = filtered;
|
||||
}
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
||||
});
|
||||
};
|
||||
|
||||
const removePushSubscriptionFromAllSessions = async (endpoint) => {
|
||||
if (!endpoint) return;
|
||||
|
||||
await ensurePushInitialized();
|
||||
|
||||
await persistPushSubscriptionUpdate((current) => {
|
||||
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
||||
for (const [token, entries] of Object.entries(subsBySession)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
||||
if (filtered.length === 0) {
|
||||
delete subsBySession[token];
|
||||
} else {
|
||||
subsBySession[token] = filtered;
|
||||
}
|
||||
}
|
||||
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
||||
});
|
||||
};
|
||||
|
||||
const buildSessionDeepLinkUrl = (sessionId) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return '/';
|
||||
}
|
||||
return `/?session=${encodeURIComponent(sessionId)}`;
|
||||
};
|
||||
|
||||
const sendPushToUiSession = async (uiSessionToken, payload) => {
|
||||
await ensurePushInitialized();
|
||||
|
||||
const subscriptions = await getPushSubscriptionsForUiSession(uiSessionToken);
|
||||
if (subscriptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
await Promise.all(subscriptions.map(async (sub) => {
|
||||
const pushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await webPush.sendNotification(pushSubscription, body);
|
||||
} catch (error) {
|
||||
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await removePushSubscriptionFromAllSessions(sub.endpoint);
|
||||
return;
|
||||
}
|
||||
console.warn('[Push] Failed to send notification:', error);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const sendPushToAllUiSessions = async (payload, options = {}) => {
|
||||
const requireNoSse = options.requireNoSse === true;
|
||||
const store = await readPushSubscriptionsFromDisk();
|
||||
const tokens = Object.keys(store.subscriptionsBySession || {});
|
||||
|
||||
await Promise.all(tokens.map(async (token) => {
|
||||
if (requireNoSse && isUiVisible(token)) {
|
||||
return;
|
||||
}
|
||||
await sendPushToUiSession(token, payload);
|
||||
}));
|
||||
};
|
||||
|
||||
let pushInitialized = false;
|
||||
const activeUiSseConnections = new Set();
|
||||
|
||||
|
||||
|
||||
const VISIBILITY_TTL_MS = 30000;
|
||||
const uiVisibilityByToken = new Map();
|
||||
|
||||
const updateUiVisibility = (token, visible) => {
|
||||
if (!token) return;
|
||||
uiVisibilityByToken.set(token, { visible: Boolean(visible), updatedAt: Date.now() });
|
||||
};
|
||||
|
||||
const isUiVisible = (token) => {
|
||||
const entry = uiVisibilityByToken.get(token);
|
||||
if (!entry) return false;
|
||||
if (Date.now() - entry.updatedAt > VISIBILITY_TTL_MS) return false;
|
||||
return entry.visible === true;
|
||||
};
|
||||
|
||||
const resolveVapidSubject = async () => {
|
||||
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
||||
if (typeof configured === 'string' && configured.trim().length > 0) {
|
||||
return configured.trim();
|
||||
}
|
||||
|
||||
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
|
||||
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
|
||||
return originEnv.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const stored = settings?.publicOrigin;
|
||||
if (typeof stored === 'string' && stored.trim().length > 0) {
|
||||
return stored.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return 'mailto:openchamber@localhost';
|
||||
};
|
||||
|
||||
const ensurePushInitialized = async () => {
|
||||
if (pushInitialized) return;
|
||||
const keys = await getOrCreateVapidKeys();
|
||||
const subject = await resolveVapidSubject();
|
||||
|
||||
if (subject === 'mailto:openchamber@localhost') {
|
||||
console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.');
|
||||
}
|
||||
|
||||
webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
|
||||
pushInitialized = true;
|
||||
};
|
||||
|
||||
const persistSettings = async (changes) => {
|
||||
// Serialize concurrent calls using lock
|
||||
persistSettingsLock = persistSettingsLock.then(async () => {
|
||||
@@ -1024,10 +1317,99 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
||||
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
||||
);
|
||||
|
||||
if (ENV_CONFIGURED_API_PREFIX && ENV_CONFIGURED_API_PREFIX !== '') {
|
||||
if (ENV_CONFIGURED_API_PREFIX && ENV_CONFIGURED_API_PREFIX !== '') {
|
||||
console.warn('Ignoring configured OpenCode API prefix; API runs at root.');
|
||||
}
|
||||
|
||||
let globalEventWatcherAbortController = null;
|
||||
|
||||
const startGlobalEventWatcher = async () => {
|
||||
if (globalEventWatcherAbortController) {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForOpenCodePort();
|
||||
|
||||
globalEventWatcherAbortController = new AbortController();
|
||||
const signal = globalEventWatcherAbortController.signal;
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
const run = async () => {
|
||||
while (!signal.aborted) {
|
||||
attempt += 1;
|
||||
let upstream;
|
||||
try {
|
||||
const url = buildOpenCodeUrl('/global/event', '');
|
||||
upstream = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
throw new Error(`bad status ${upstream.status}`);
|
||||
}
|
||||
|
||||
console.log('[PushWatcher] connected');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = upstream.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||
|
||||
let separatorIndex;
|
||||
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('[PushWatcher] disconnected', error?.message ?? error);
|
||||
} finally {
|
||||
try {
|
||||
upstream?.body?.cancel?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
|
||||
await new Promise((r) => setTimeout(r, backoffMs));
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
};
|
||||
|
||||
const stopGlobalEventWatcher = () => {
|
||||
if (!globalEventWatcherAbortController) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
globalEventWatcherAbortController.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
globalEventWatcherAbortController = null;
|
||||
};
|
||||
|
||||
|
||||
function setOpenCodePort(port) {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return;
|
||||
@@ -1272,6 +1654,166 @@ function deriveSessionActivity(payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const PUSH_READY_COOLDOWN_MS = 5000;
|
||||
const PUSH_QUESTION_DEBOUNCE_MS = 500;
|
||||
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
|
||||
const pushQuestionDebounceTimers = new Map();
|
||||
const pushPermissionDebounceTimers = new Map();
|
||||
const notifiedPermissionRequests = new Set();
|
||||
const lastReadyNotificationAt = new Map();
|
||||
|
||||
const extractSessionIdFromPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const props = payload.properties;
|
||||
const info = props?.info;
|
||||
const sessionId =
|
||||
info?.sessionID ??
|
||||
info?.sessionId ??
|
||||
props?.sessionID ??
|
||||
props?.sessionId ??
|
||||
props?.session ??
|
||||
null;
|
||||
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
||||
};
|
||||
|
||||
const maybeSendPushForTrigger = async (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = extractSessionIdFromPayload(payload);
|
||||
|
||||
const formatMode = (raw) => {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
const normalized = value.length > 0 ? value : 'agent';
|
||||
return normalized
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatModelId = (raw) => {
|
||||
const value = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!value) {
|
||||
return 'Assistant';
|
||||
}
|
||||
|
||||
const tokens = value.split(/[-_]+/).filter(Boolean);
|
||||
const result = [];
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const current = tokens[i];
|
||||
const next = tokens[i + 1];
|
||||
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
|
||||
result.push(`${current}.${next}`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
return result
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
if (payload.type === 'message.updated') {
|
||||
const info = payload.properties?.info;
|
||||
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
||||
const now = Date.now();
|
||||
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
|
||||
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
lastReadyNotificationAt.set(sessionId, now);
|
||||
|
||||
const title = `${formatMode(info?.mode)} agent is ready`;
|
||||
const body = `${formatModelId(info?.modelID)} completed the task`;
|
||||
|
||||
await sendPushToAllUiSessions(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
tag: `ready-${sessionId}`,
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
type: 'ready',
|
||||
}
|
||||
},
|
||||
{ requireNoSse: true }
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (payload.type === 'question.asked' && sessionId) {
|
||||
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pushQuestionDebounceTimers.delete(sessionId);
|
||||
void sendPushToAllUiSessions(
|
||||
{
|
||||
title: 'Input needed',
|
||||
body: 'Agent is waiting for your response',
|
||||
tag: `question-${sessionId}`,
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
type: 'question',
|
||||
}
|
||||
},
|
||||
{ requireNoSse: true }
|
||||
);
|
||||
}, PUSH_QUESTION_DEBOUNCE_MS);
|
||||
|
||||
pushQuestionDebounceTimers.set(sessionId, timer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'permission.asked' && sessionId) {
|
||||
const requestId = payload.properties?.id;
|
||||
const permission = payload.properties?.permission;
|
||||
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
||||
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pushPermissionDebounceTimers.delete(sessionId);
|
||||
if (requestKey) {
|
||||
notifiedPermissionRequests.add(requestKey);
|
||||
}
|
||||
|
||||
void sendPushToAllUiSessions(
|
||||
{
|
||||
title: 'Permission required',
|
||||
body: typeof permission === 'string' && permission.length > 0 ? permission : 'Agent requested permission',
|
||||
tag: `permission-${sessionId}`,
|
||||
data: {
|
||||
url: buildSessionDeepLinkUrl(sessionId),
|
||||
sessionId,
|
||||
type: 'permission',
|
||||
}
|
||||
},
|
||||
{ requireNoSse: true }
|
||||
);
|
||||
}, PUSH_PERMISSION_DEBOUNCE_MS);
|
||||
|
||||
pushPermissionDebounceTimers.set(sessionId, timer);
|
||||
}
|
||||
};
|
||||
|
||||
function writeSseEvent(res, payload) {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
@@ -1701,6 +2243,7 @@ function setupProxy(app) {
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (
|
||||
req.path.startsWith('/themes/custom') ||
|
||||
req.path.startsWith('/push') ||
|
||||
req.path.startsWith('/config/agents') ||
|
||||
req.path.startsWith('/config/settings') ||
|
||||
req.path === '/config/reload' ||
|
||||
@@ -1826,6 +2369,8 @@ async function gracefulShutdown(options = {}) {
|
||||
console.log('Starting graceful shutdown...');
|
||||
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
|
||||
|
||||
stopGlobalEventWatcher();
|
||||
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
}
|
||||
@@ -1916,7 +2461,8 @@ async function main(options = {}) {
|
||||
req.path.startsWith('/api/git') ||
|
||||
req.path.startsWith('/api/prompts') ||
|
||||
req.path.startsWith('/api/terminal') ||
|
||||
req.path.startsWith('/api/opencode')
|
||||
req.path.startsWith('/api/opencode') ||
|
||||
req.path.startsWith('/api/push')
|
||||
) {
|
||||
|
||||
express.json({ limit: '50mb' })(req, res, next);
|
||||
@@ -1946,6 +2492,127 @@ async function main(options = {}) {
|
||||
|
||||
app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next));
|
||||
|
||||
const parsePushSubscribeBody = (body) => {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const endpoint = body.endpoint;
|
||||
const keys = body.keys;
|
||||
const p256dh = keys?.p256dh;
|
||||
const auth = keys?.auth;
|
||||
|
||||
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
||||
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
|
||||
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
|
||||
|
||||
return {
|
||||
endpoint: endpoint.trim(),
|
||||
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
|
||||
};
|
||||
};
|
||||
|
||||
const parsePushUnsubscribeBody = (body) => {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const endpoint = body.endpoint;
|
||||
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
||||
return { endpoint: endpoint.trim() };
|
||||
};
|
||||
|
||||
app.get('/api/push/vapid-public-key', async (req, res) => {
|
||||
try {
|
||||
await ensurePushInitialized();
|
||||
const keys = await getOrCreateVapidKeys();
|
||||
res.json({ publicKey: keys.publicKey });
|
||||
} catch (error) {
|
||||
console.warn('[Push] Failed to load VAPID key:', error);
|
||||
res.status(500).json({ error: 'Failed to load push key' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', async (req, res) => {
|
||||
await ensurePushInitialized();
|
||||
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const parsed = parsePushSubscribeBody(req.body);
|
||||
if (!parsed) {
|
||||
return res.status(400).json({ error: 'Invalid body' });
|
||||
}
|
||||
|
||||
const { endpoint, keys } = parsed;
|
||||
|
||||
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
|
||||
if (origin.startsWith('http://') || origin.startsWith('https://')) {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
|
||||
await writeSettingsToDisk({
|
||||
...settings,
|
||||
publicOrigin: origin,
|
||||
});
|
||||
// allow next sends to pick it up
|
||||
pushInitialized = false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
await addOrUpdatePushSubscription(
|
||||
uiToken,
|
||||
{
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
},
|
||||
req.headers['user-agent']
|
||||
);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
app.delete('/api/push/subscribe', async (req, res) => {
|
||||
await ensurePushInitialized();
|
||||
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const parsed = parsePushUnsubscribeBody(req.body);
|
||||
if (!parsed) {
|
||||
return res.status(400).json({ error: 'Invalid body' });
|
||||
}
|
||||
|
||||
await removePushSubscription(uiToken, parsed.endpoint);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/push/visibility', (req, res) => {
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
|
||||
updateUiVisibility(uiToken, visible === true);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/push/visibility', (req, res) => {
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (!uiToken) {
|
||||
return res.status(401).json({ error: 'UI session missing' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
visible: isUiVisible(uiToken),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/update-check', async (_req, res) => {
|
||||
try {
|
||||
const { checkForUpdates } = await import('./lib/package-manager.js');
|
||||
@@ -2099,6 +2766,15 @@ async function main(options = {}) {
|
||||
});
|
||||
|
||||
app.get('/api/global/event', async (req, res) => {
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (uiToken) {
|
||||
activeUiSseConnections.add(uiToken);
|
||||
const cleanupUiToken = () => {
|
||||
activeUiSseConnections.delete(uiToken);
|
||||
};
|
||||
req.on('close', cleanupUiToken);
|
||||
req.on('error', cleanupUiToken);
|
||||
}
|
||||
let targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
|
||||
@@ -2160,8 +2836,11 @@ async function main(options = {}) {
|
||||
|
||||
const forwardBlock = (block) => {
|
||||
if (!block) return;
|
||||
res.write(`${block}\n\n`);
|
||||
res.write(`${block}
|
||||
|
||||
`);
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
writeSseEvent(res, {
|
||||
@@ -2207,6 +2886,15 @@ async function main(options = {}) {
|
||||
});
|
||||
|
||||
app.get('/api/event', async (req, res) => {
|
||||
const uiToken = getUiSessionTokenFromRequest(req);
|
||||
if (uiToken) {
|
||||
activeUiSseConnections.add(uiToken);
|
||||
const cleanupUiToken = () => {
|
||||
activeUiSseConnections.delete(uiToken);
|
||||
};
|
||||
req.on('close', cleanupUiToken);
|
||||
req.on('error', cleanupUiToken);
|
||||
}
|
||||
let targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
|
||||
@@ -2277,8 +2965,11 @@ async function main(options = {}) {
|
||||
|
||||
const forwardBlock = (block) => {
|
||||
if (!block) return;
|
||||
res.write(`${block}\n\n`);
|
||||
res.write(`${block}
|
||||
|
||||
`);
|
||||
const payload = parseSseDataPayload(block);
|
||||
void maybeSendPushForTrigger(payload);
|
||||
const activity = deriveSessionActivity(payload);
|
||||
if (activity) {
|
||||
writeSseEvent(res, {
|
||||
@@ -4820,6 +5511,7 @@ async function main(options = {}) {
|
||||
setupProxy(app);
|
||||
scheduleOpenCodeApiDetection();
|
||||
startHealthMonitoring();
|
||||
void startGlobalEventWatcher();
|
||||
} catch (error) {
|
||||
console.error(`Failed to start OpenCode: ${error.message}`);
|
||||
console.log('Continuing without OpenCode integration...');
|
||||
@@ -4829,9 +5521,16 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
const distPath = path.join(__dirname, '..', 'dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
console.log(`Serving static files from ${distPath}`);
|
||||
app.use(express.static(distPath));
|
||||
if (fs.existsSync(distPath)) {
|
||||
console.log(`Serving static files from ${distPath}`);
|
||||
app.use(express.static(distPath, {
|
||||
setHeaders(res, filePath) {
|
||||
// Service workers should never be long-cached; iOS is especially sensitive.
|
||||
if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createWebSettingsAPI } from './settings';
|
||||
import { createWebPermissionsAPI } from './permissions';
|
||||
import { createWebNotificationsAPI } from './notifications';
|
||||
import { createWebToolsAPI } from './tools';
|
||||
import { createWebPushAPI } from './push';
|
||||
|
||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
||||
@@ -15,5 +16,6 @@ export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
settings: createWebSettingsAPI(),
|
||||
permissions: createWebPermissionsAPI(),
|
||||
notifications: createWebNotificationsAPI(),
|
||||
push: createWebPushAPI(),
|
||||
tools: createWebToolsAPI(),
|
||||
});
|
||||
|
||||
@@ -6,8 +6,15 @@ const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T | null> => {
|
||||
try {
|
||||
const res = await fetch(input, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebPushAPI = (): PushAPI => ({
|
||||
async getVapidPublicKey() {
|
||||
return fetchJson<{ publicKey: string }>('/api/push/vapid-public-key');
|
||||
},
|
||||
|
||||
async subscribe(payload: PushSubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unsubscribe(payload: PushUnsubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async setVisibility(payload: { visible: boolean }) {
|
||||
return fetchJson<{ ok: true }>('/api/push/visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createWebAPIs } from './api';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import '@openchamber/ui/index.css';
|
||||
import '@openchamber/ui/styles/fonts';
|
||||
@@ -10,4 +12,21 @@ declare global {
|
||||
}
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||
|
||||
registerSW({
|
||||
onRegistered(registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Periodic update check (best-effort)
|
||||
setInterval(() => {
|
||||
void registration.update();
|
||||
}, 60 * 60 * 1000);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.warn('[PWA] service worker registration failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
import('@openchamber/ui/main');
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
|
||||
onRegisterError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// NOTE: keep the Workbox injection point so vite-plugin-pwa can build.
|
||||
// We intentionally do not use Workbox runtime helpers here: iOS Safari can be
|
||||
// fragile with more complex SW bundles. For push notifications we only need a
|
||||
// minimal SW.
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<string | { url: string; revision?: string }>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const __precacheManifest = self.__WB_MANIFEST;
|
||||
|
||||
type PushPayload = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
data?: {
|
||||
url?: string;
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
};
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const payload = (event.data?.json() ?? null) as PushPayload | null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = payload.title || 'OpenChamber';
|
||||
const body = payload.body ?? '';
|
||||
const icon = payload.icon ?? '/apple-touch-icon-180x180.png';
|
||||
const badge = payload.badge ?? '/favicon-32.png';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge,
|
||||
tag: payload.tag,
|
||||
data: payload.data,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const data = (event.notification.data ?? null) as { url?: string } | null;
|
||||
const url = data?.url ?? '/';
|
||||
|
||||
event.waitUntil(self.clients.openWindow(url));
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { themeStoragePlugin } from '../../vite-theme-plugin';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -13,6 +14,25 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
themeStoragePlugin(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,otf,eot}'],
|
||||
// iOS Safari/PWA is much more reliable with a classic (non-module) SW bundle.
|
||||
rollupFormat: 'iife',
|
||||
// We already keep a custom manifest in index.html
|
||||
injectionPoint: undefined,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
|
||||
+1133
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user