Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ PASSKEY_RP_ID=localhost
PASSKEY_RP_NAME=OpenCode Manager
PASSKEY_ORIGIN=http://localhost:5003

# ============================================
# Push Notifications (VAPID)
# Enables push notifications for the PWA (background alerts for agent
# questions, permission requests, errors, and session completions).
#
# Setup:
# 1. Generate keys: npx web-push generate-vapid-keys
# 2. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY from the output
# 3. Set VAPID_SUBJECT to a mailto: address (REQUIRED for iOS/Safari)
#
# IMPORTANT: VAPID_SUBJECT MUST use the mailto: format for iOS/Safari
# push notifications to work. Apple's push service rejects https:// subjects.
# Example: mailto:you@yourdomain.com
#
# Browser requirements:
# - HTTPS required (except localhost)
# - Safari/iOS: Requires mailto: VAPID_SUBJECT and HTTPS
# ============================================
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:you@yourdomain.com

# ============================================
# Frontend Configuration (Vite)
# These are optional - frontend uses defaults if not set
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ coverage/
.vscode/
.idea/
*.test.js
*.tsbuildinfo

data/
workspace/
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ On first launch, you'll be prompted to create an admin account. That's it!
- **Mobile-First Design** - Responsive UI optimized for mobile
- **PWA Installable** - Add to home screen on any device
- **iOS Optimized** - Proper keyboard handling and swipe navigation
- **Push Notifications** - Background alerts for agent events when app is closed

## Installation

Expand Down Expand Up @@ -163,6 +164,19 @@ PASSKEY_RP_NAME=OpenCode Manager
PASSKEY_ORIGIN=https://yourdomain.com
```

### Push Notifications (VAPID)

Enable push notifications for the PWA (background alerts for agent questions, permission requests, errors, and session completions):

```bash
# Generate keys: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=your-public-key
VAPID_PRIVATE_KEY=your-private-key
VAPID_SUBJECT=mailto:you@yourdomain.com
```

**Important**: `VAPID_SUBJECT` MUST use `mailto:` format for iOS/Safari push notifications to work. Apple's push service rejects `https://` subjects.

### Dev Server Ports

The Docker container exposes ports `5100-5103` for running dev servers inside repositories:
Expand Down
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"eventsource": "^4.1.0",
"hono": "^4.11.7",
"strip-json-comments": "^3.1.1",
"web-push": "^3.6.7",
"zod": "^4.1.12"
},
"devDependencies": {
Expand All @@ -32,6 +33,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest",
"@types/eventsource": "^3.0.0",
"@types/web-push": "^3.6.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"typescript-eslint": "^8.45.0",
Expand Down
24 changes: 24 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createProvidersRoutes } from './routes/providers'
import { createOAuthRoutes } from './routes/oauth'
import { createTitleRoutes } from './routes/title'
import { createSSERoutes } from './routes/sse'
import { createNotificationRoutes } from './routes/notifications'
import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth'
import { createAuth } from './auth'
import { createAuthMiddleware } from './auth/middleware'
Expand All @@ -26,6 +27,7 @@ import { SettingsService } from './services/settings'
import { opencodeServerManager } from './services/opencode-single-server'
import { cleanupOrphanedDirectories } from './services/repo'
import { proxyRequest } from './services/proxy'
import { NotificationService } from './services/notification'
import { logger } from './utils/logger'
import {
getWorkspacePath,
Expand Down Expand Up @@ -201,6 +203,27 @@ try {
logger.error('Failed to initialize workspace:', error)
}

const notificationService = new NotificationService(db)

if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) {
if (!ENV.VAPID.SUBJECT) {
logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)')
} else if (!ENV.VAPID.SUBJECT.startsWith('mailto:')) {
logger.warn(`VAPID_SUBJECT="${ENV.VAPID.SUBJECT}" does not use mailto: format — iOS/Safari push notifications will fail`)
}

notificationService.configureVapid({
publicKey: ENV.VAPID.PUBLIC_KEY,
privateKey: ENV.VAPID.PRIVATE_KEY,
subject: ENV.VAPID.SUBJECT || 'mailto:push@localhost',
})
sseAggregator.onEvent((directory, event) => {
notificationService.handleSSEEvent(directory, event).catch((err) => {
logger.error('Push notification dispatch error:', err)
})
})
}

app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))

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

app.route('/api', protectedApi)

Expand Down
99 changes: 99 additions & 0 deletions backend/src/routes/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Hono } from "hono";
import { z } from "zod";
import { PushSubscriptionRequestSchema } from "@opencode-manager/shared/schemas";
import type { NotificationService } from "../services/notification";

export function createNotificationRoutes(
notificationService: NotificationService
) {
const app = new Hono();

app.get("/vapid-public-key", (c) => {
const publicKey = notificationService.getVapidPublicKey();
if (!publicKey) {
return c.json(
{ error: "Push notifications are not configured" },
503
);
}
return c.json({ publicKey });
});

app.post("/subscribe", async (c) => {
const userId = c.req.query('userId') || 'default'
const body = await c.req.json();
const parsed = PushSubscriptionRequestSchema.safeParse(body);

if (!parsed.success) {
return c.json({ error: "Invalid subscription data", details: parsed.error.issues }, 400);
}

const { endpoint, keys, deviceName } = parsed.data;

const subscription = notificationService.saveSubscription(
userId,
endpoint,
keys.p256dh,
keys.auth,
deviceName
);

return c.json({ subscription });
});

app.delete("/subscribe", async (c) => {
const userId = c.req.query('userId') || 'default'
const body = await c.req.json();
const parsed = z.object({ endpoint: z.string().url() }).safeParse(body);

if (!parsed.success) {
return c.json({ error: "Valid endpoint URL is required", details: parsed.error.issues }, 400);
}

const removed = notificationService.removeSubscription(parsed.data.endpoint, userId);
return c.json({ success: removed });
});

app.get("/subscriptions", (c) => {
const userId = c.req.query('userId') || 'default'
const subscriptions = notificationService.getSubscriptions(userId);
return c.json({ subscriptions });
});

app.delete("/subscriptions/:id", (c) => {
const userId = c.req.query('userId') || 'default'
const id = parseInt(c.req.param("id"), 10);

if (isNaN(id)) {
return c.json({ error: "Invalid subscription ID" }, 400);
}

const removed = notificationService.removeSubscriptionById(id, userId);
return c.json({ success: removed });
});

app.post("/test", async (c) => {
const userId = c.req.query('userId') || 'default'

if (!notificationService.isConfigured()) {
return c.json(
{ error: "Push notifications are not configured" },
503
);
}

const subscriptions = notificationService.getSubscriptions(userId);
if (subscriptions.length === 0) {
return c.json(
{ error: "No push subscriptions registered" },
404
);
}

await notificationService.sendTestNotification(userId);

return c.json({ success: true, devicesNotified: subscriptions.length });
});

return app;
}
15 changes: 14 additions & 1 deletion backend/src/routes/sse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { sseAggregator } from '../services/sse-aggregator'
import { SSESubscribeSchema } from '@opencode-manager/shared/schemas'
import { SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas'
import { logger } from '../utils/logger'
import { DEFAULTS } from '@opencode-manager/shared/config'

Expand Down Expand Up @@ -88,6 +88,19 @@ export function createSSERoutes() {
return c.json({ success: true })
})

app.post('/visibility', async (c) => {
const body = await c.req.json()
const result = SSEVisibilitySchema.safeParse(body)
if (!result.success) {
return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400)
}
const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible)
if (!success) {
return c.json({ success: false, error: 'Client not found' }, 404)
}
return c.json({ success: true })
})

app.get('/status', (c) => {
return c.json({
...sseAggregator.getConnectionStatus(),
Expand Down
29 changes: 8 additions & 21 deletions backend/src/services/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,36 +274,24 @@ function getMimeType(filePath: string): AllowedMimeType {
return mimeTypes[ext] || 'text/plain'
}

async function countFileLines(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
let lineCount = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })

rl.on('line', () => { lineCount++ })
rl.on('close', () => resolve(lineCount))
rl.on('error', reject)
})
}

async function readFileLines(filePath: string, startLine: number, endLine: number): Promise<string[]> {
async function readFileLinesAndCount(
filePath: string,
startLine: number,
endLine: number
): Promise<{ lines: string[]; totalLines: number }> {
return new Promise((resolve, reject) => {
const lines: string[] = []
let currentLine = 0
const stream = createReadStream(filePath, { encoding: 'utf8' })
const rl = createInterface({ input: stream, crlfDelay: Infinity })

rl.on('line', (line) => {
if (currentLine >= startLine && currentLine < endLine) {
lines.push(line)
}
currentLine++
if (currentLine >= endLine) {
rl.close()
stream.destroy()
}
})
rl.on('close', () => resolve(lines))
rl.on('close', () => resolve({ lines, totalLines: currentLine }))
rl.on('error', reject)
})
}
Expand All @@ -322,9 +310,8 @@ export async function getFileRange(userPath: string, startLine: number, endLine:
throw { message: 'Path is a directory', statusCode: 400 }
}

const totalLines = await countFileLines(validatedPath)
const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine)
const clampedEnd = Math.min(endLine, totalLines)
const lines = await readFileLines(validatedPath, startLine, clampedEnd)
const mimeType = getMimeType(validatedPath)

return {
Expand Down
Loading