Skip to content

Commit ae644e8

Browse files
feat: add push notifications with VAPID (#110)
* Add OAuth authentication support for MCP servers * Improve mobile text sizing with responsive text classes * refactor: improve virtualized text viewer with React Query infinite scroll and simplified buffering * Add push notifications with VAPID and PWA support * feat: add VAPID environment variables for push notifications * Replace SquareFill with MicOff icon for stop recording buttons * Send push notifications when app is not focused * Fix stop generation button icons to use X instead of MicOff * feat: enhance push notification logging and debugging - Add structured logging throughout push notification flow - Improve error handling with detailed diagnostics - Add /api/notifications/debug endpoint for troubleshooting - Enhanced test notification response with per-subscription results - Add service worker logging and better install/activate handling * Improve VAPID push notification configuration with validation and better defaults - Add validation warnings for VAPID_SUBJECT format (requires mailto: prefix for iOS/Safari) - Expose VAPID details via notifications API endpoint - Update default VAPID_SUBJECT from mailto:admin@opencode.local to empty string - Enhance VAPID logging with key lengths and subject info - Improve documentation in .env.example with setup instructions * Fix notification URL to use repo ID instead of filesystem path * Clean up push notification logging and debug endpoints * Add push notifications documentation
1 parent 3d9e9b8 commit ae644e8

47 files changed

Lines changed: 7278 additions & 2839 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ PASSKEY_RP_ID=localhost
8383
PASSKEY_RP_NAME=OpenCode Manager
8484
PASSKEY_ORIGIN=http://localhost:5003
8585

86+
# ============================================
87+
# Push Notifications (VAPID)
88+
# Enables push notifications for the PWA (background alerts for agent
89+
# questions, permission requests, errors, and session completions).
90+
#
91+
# Setup:
92+
# 1. Generate keys: npx web-push generate-vapid-keys
93+
# 2. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY from the output
94+
# 3. Set VAPID_SUBJECT to a mailto: address (REQUIRED for iOS/Safari)
95+
#
96+
# IMPORTANT: VAPID_SUBJECT MUST use the mailto: format for iOS/Safari
97+
# push notifications to work. Apple's push service rejects https:// subjects.
98+
# Example: mailto:you@yourdomain.com
99+
#
100+
# Browser requirements:
101+
# - HTTPS required (except localhost)
102+
# - Safari/iOS: Requires mailto: VAPID_SUBJECT and HTTPS
103+
# ============================================
104+
# VAPID_PUBLIC_KEY=
105+
# VAPID_PRIVATE_KEY=
106+
# VAPID_SUBJECT=mailto:you@yourdomain.com
107+
86108
# ============================================
87109
# Frontend Configuration (Vite)
88110
# These are optional - frontend uses defaults if not set

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ coverage/
1212
.vscode/
1313
.idea/
1414
*.test.js
15+
*.tsbuildinfo
1516

1617
data/
1718
workspace/

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ On first launch, you'll be prompted to create an admin account. That's it!
7676
- **Mobile-First Design** - Responsive UI optimized for mobile
7777
- **PWA Installable** - Add to home screen on any device
7878
- **iOS Optimized** - Proper keyboard handling and swipe navigation
79+
- **Push Notifications** - Background alerts for agent events when app is closed
7980

8081
## Installation
8182

@@ -163,6 +164,19 @@ PASSKEY_RP_NAME=OpenCode Manager
163164
PASSKEY_ORIGIN=https://yourdomain.com
164165
```
165166

167+
### Push Notifications (VAPID)
168+
169+
Enable push notifications for the PWA (background alerts for agent questions, permission requests, errors, and session completions):
170+
171+
```bash
172+
# Generate keys: npx web-push generate-vapid-keys
173+
VAPID_PUBLIC_KEY=your-public-key
174+
VAPID_PRIVATE_KEY=your-private-key
175+
VAPID_SUBJECT=mailto:you@yourdomain.com
176+
```
177+
178+
**Important**: `VAPID_SUBJECT` MUST use `mailto:` format for iOS/Safari push notifications to work. Apple's push service rejects `https://` subjects.
179+
166180
### Dev Server Ports
167181

168182
The Docker container exposes ports `5100-5103` for running dev servers inside repositories:

‎backend/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"eventsource": "^4.1.0",
2525
"hono": "^4.11.7",
2626
"strip-json-comments": "^3.1.1",
27+
"web-push": "^3.6.7",
2728
"zod": "^4.1.12"
2829
},
2930
"devDependencies": {
@@ -32,6 +33,7 @@
3233
"@types/better-sqlite3": "^7.6.13",
3334
"@types/bun": "latest",
3435
"@types/eventsource": "^3.0.0",
36+
"@types/web-push": "^3.6.4",
3537
"@vitest/ui": "^3.2.4",
3638
"eslint": "^9.39.1",
3739
"typescript-eslint": "^8.45.0",

‎backend/src/index.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createProvidersRoutes } from './routes/providers'
1717
import { createOAuthRoutes } from './routes/oauth'
1818
import { createTitleRoutes } from './routes/title'
1919
import { createSSERoutes } from './routes/sse'
20+
import { createNotificationRoutes } from './routes/notifications'
2021
import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth'
2122
import { createAuth } from './auth'
2223
import { createAuthMiddleware } from './auth/middleware'
@@ -26,6 +27,7 @@ import { SettingsService } from './services/settings'
2627
import { opencodeServerManager } from './services/opencode-single-server'
2728
import { cleanupOrphanedDirectories } from './services/repo'
2829
import { proxyRequest } from './services/proxy'
30+
import { NotificationService } from './services/notification'
2931
import { logger } from './utils/logger'
3032
import {
3133
getWorkspacePath,
@@ -201,6 +203,27 @@ try {
201203
logger.error('Failed to initialize workspace:', error)
202204
}
203205

206+
const notificationService = new NotificationService(db)
207+
208+
if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) {
209+
if (!ENV.VAPID.SUBJECT) {
210+
logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)')
211+
} else if (!ENV.VAPID.SUBJECT.startsWith('mailto:')) {
212+
logger.warn(`VAPID_SUBJECT="${ENV.VAPID.SUBJECT}" does not use mailto: format — iOS/Safari push notifications will fail`)
213+
}
214+
215+
notificationService.configureVapid({
216+
publicKey: ENV.VAPID.PUBLIC_KEY,
217+
privateKey: ENV.VAPID.PRIVATE_KEY,
218+
subject: ENV.VAPID.SUBJECT || 'mailto:push@localhost',
219+
})
220+
sseAggregator.onEvent((directory, event) => {
221+
notificationService.handleSSEEvent(directory, event).catch((err) => {
222+
logger.error('Push notification dispatch error:', err)
223+
})
224+
})
225+
}
226+
204227
app.route('/api/auth', createAuthRoutes(auth))
205228
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
206229

@@ -218,6 +241,7 @@ protectedApi.route('/tts', createTTSRoutes(db))
218241
protectedApi.route('/stt', createSTTRoutes(db))
219242
protectedApi.route('/generate-title', createTitleRoutes())
220243
protectedApi.route('/sse', createSSERoutes())
244+
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
221245

222246
app.route('/api', protectedApi)
223247

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Hono } from "hono";
2+
import { z } from "zod";
3+
import { PushSubscriptionRequestSchema } from "@opencode-manager/shared/schemas";
4+
import type { NotificationService } from "../services/notification";
5+
6+
export function createNotificationRoutes(
7+
notificationService: NotificationService
8+
) {
9+
const app = new Hono();
10+
11+
app.get("/vapid-public-key", (c) => {
12+
const publicKey = notificationService.getVapidPublicKey();
13+
if (!publicKey) {
14+
return c.json(
15+
{ error: "Push notifications are not configured" },
16+
503
17+
);
18+
}
19+
return c.json({ publicKey });
20+
});
21+
22+
app.post("/subscribe", async (c) => {
23+
const userId = c.req.query('userId') || 'default'
24+
const body = await c.req.json();
25+
const parsed = PushSubscriptionRequestSchema.safeParse(body);
26+
27+
if (!parsed.success) {
28+
return c.json({ error: "Invalid subscription data", details: parsed.error.issues }, 400);
29+
}
30+
31+
const { endpoint, keys, deviceName } = parsed.data;
32+
33+
const subscription = notificationService.saveSubscription(
34+
userId,
35+
endpoint,
36+
keys.p256dh,
37+
keys.auth,
38+
deviceName
39+
);
40+
41+
return c.json({ subscription });
42+
});
43+
44+
app.delete("/subscribe", async (c) => {
45+
const userId = c.req.query('userId') || 'default'
46+
const body = await c.req.json();
47+
const parsed = z.object({ endpoint: z.string().url() }).safeParse(body);
48+
49+
if (!parsed.success) {
50+
return c.json({ error: "Valid endpoint URL is required", details: parsed.error.issues }, 400);
51+
}
52+
53+
const removed = notificationService.removeSubscription(parsed.data.endpoint, userId);
54+
return c.json({ success: removed });
55+
});
56+
57+
app.get("/subscriptions", (c) => {
58+
const userId = c.req.query('userId') || 'default'
59+
const subscriptions = notificationService.getSubscriptions(userId);
60+
return c.json({ subscriptions });
61+
});
62+
63+
app.delete("/subscriptions/:id", (c) => {
64+
const userId = c.req.query('userId') || 'default'
65+
const id = parseInt(c.req.param("id"), 10);
66+
67+
if (isNaN(id)) {
68+
return c.json({ error: "Invalid subscription ID" }, 400);
69+
}
70+
71+
const removed = notificationService.removeSubscriptionById(id, userId);
72+
return c.json({ success: removed });
73+
});
74+
75+
app.post("/test", async (c) => {
76+
const userId = c.req.query('userId') || 'default'
77+
78+
if (!notificationService.isConfigured()) {
79+
return c.json(
80+
{ error: "Push notifications are not configured" },
81+
503
82+
);
83+
}
84+
85+
const subscriptions = notificationService.getSubscriptions(userId);
86+
if (subscriptions.length === 0) {
87+
return c.json(
88+
{ error: "No push subscriptions registered" },
89+
404
90+
);
91+
}
92+
93+
await notificationService.sendTestNotification(userId);
94+
95+
return c.json({ success: true, devicesNotified: subscriptions.length });
96+
});
97+
98+
return app;
99+
}

‎backend/src/routes/sse.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from 'hono'
22
import { stream } from 'hono/streaming'
33
import { sseAggregator } from '../services/sse-aggregator'
4-
import { SSESubscribeSchema } from '@opencode-manager/shared/schemas'
4+
import { SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas'
55
import { logger } from '../utils/logger'
66
import { DEFAULTS } from '@opencode-manager/shared/config'
77

@@ -88,6 +88,19 @@ export function createSSERoutes() {
8888
return c.json({ success: true })
8989
})
9090

91+
app.post('/visibility', async (c) => {
92+
const body = await c.req.json()
93+
const result = SSEVisibilitySchema.safeParse(body)
94+
if (!result.success) {
95+
return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400)
96+
}
97+
const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible)
98+
if (!success) {
99+
return c.json({ success: false, error: 'Client not found' }, 404)
100+
}
101+
return c.json({ success: true })
102+
})
103+
91104
app.get('/status', (c) => {
92105
return c.json({
93106
...sseAggregator.getConnectionStatus(),

0 commit comments

Comments
 (0)