Skip to content

Commit d95ce8e

Browse files
committed
feat: add client management page and enhance server configuration with URL support for SSE and HTTP transport types
1 parent fa55d36 commit d95ce8e

25 files changed

Lines changed: 593 additions & 123 deletions

resources/icon.ico

40.1 KB
Binary file not shown.

resources/tray-icon.png

231 Bytes
Loading

src/main/clients/registry.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
* @copyright 2026
99
*
1010
* @description Central registry of all client adapters. IPC handlers and
11-
* services look up adapters here rather than importing them directly. The map
12-
* is populated with the three Phase 1 adapters; Phase 2 (Step 18) adds the
13-
* remaining five (Windsurf, Claude Code, Zed, JetBrains, Codex CLI).
11+
* services look up adapters here rather than importing them directly.
1412
*/
1513

1614
import type { ClientId } from '@shared/types'

src/main/db/__tests__/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*/
1515

1616
import Database from 'better-sqlite3'
17-
import { MIGRATION_001 } from '../migrations/index'
17+
import { MIGRATION_001, MIGRATION_002 } from '../migrations/index'
1818

1919
/**
2020
* Creates a fresh in-memory SQLite database with the full schema applied.
@@ -29,5 +29,6 @@ export const createTestDb = (): Database.Database => {
2929
const db = new Database(':memory:')
3030
db.pragma('foreign_keys = ON')
3131
db.exec(MIGRATION_001)
32+
db.exec(MIGRATION_002)
3233
return db
3334
}

src/main/db/connection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import { join } from 'path'
1717
import Database from 'better-sqlite3'
1818
import { app } from 'electron'
1919
import log from 'electron-log'
20-
import { MIGRATION_001 } from './migrations/index'
20+
import { MIGRATION_001, MIGRATION_002 } from './migrations/index'
2121

2222
/** All migration scripts in order. Index = version - 1. */
23-
const MIGRATIONS: readonly string[] = [MIGRATION_001]
23+
const MIGRATIONS: readonly string[] = [MIGRATION_001, MIGRATION_002]
2424

2525
/** Lazily-created singleton instance. */
2626
let instance: Database.Database | null = null

src/main/db/migrations/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
* @description Exports all SQLite migration scripts as typed string constants.
1111
* Keeping them here avoids file-path resolution issues across dev, test, and
1212
* packaged Electron builds — no readFileSync or path gymnastics needed.
13+
*
14+
* Migration history:
15+
* 001 — Initial schema (servers, rules, profiles, backups, activity_log, settings)
16+
* 002 — Add `url` column to servers (for SSE and HTTP transport types)
1317
*/
1418

1519
/**
@@ -102,3 +106,12 @@ CREATE INDEX idx_activity_log_timestamp ON activity_log(timestamp);
102106
CREATE INDEX idx_activity_log_action ON activity_log(action);
103107
CREATE INDEX idx_backups_client ON backups(client_id);
104108
`
109+
110+
/**
111+
* Migration 002 — Adds the `url` column to the `servers` table.
112+
* Required for SSE and HTTP transport types where the server is reached via
113+
* a network endpoint rather than a spawned process.
114+
*/
115+
export const MIGRATION_002 = /* sql */ `
116+
ALTER TABLE servers ADD COLUMN url TEXT NOT NULL DEFAULT '';
117+
`

src/main/db/servers.repo.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface ServerRow {
2727
id: string
2828
name: string
2929
type: string
30+
url: string
3031
command: string
3132
args: string
3233
env: string
@@ -51,6 +52,7 @@ const rowToServer = (row: ServerRow): McpServer => ({
5152
id: row.id,
5253
name: row.name,
5354
type: row.type as McpServer['type'],
55+
...(row.url ? { url: row.url } : {}),
5456
command: row.command,
5557
args: JSON.parse(row.args) as string[],
5658
env: JSON.parse(row.env) as Record<string, string>,
@@ -109,15 +111,16 @@ export class ServersRepo {
109111
this.db
110112
.prepare(
111113
`INSERT INTO servers
112-
(id, name, type, command, args, env, secret_env_keys, enabled,
114+
(id, name, type, url, command, args, env, secret_env_keys, enabled,
113115
client_overrides, tags, notes, created_at, updated_at)
114116
VALUES
115-
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
117+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
116118
)
117119
.run(
118120
id,
119121
input.name,
120122
input.type,
123+
input.url ?? '',
121124
input.command,
122125
JSON.stringify(input.args ?? []),
123126
JSON.stringify(input.env ?? {}),
@@ -157,14 +160,15 @@ export class ServersRepo {
157160
this.db
158161
.prepare(
159162
`UPDATE servers SET
160-
name = ?, type = ?, command = ?, args = ?, env = ?,
163+
name = ?, type = ?, url = ?, command = ?, args = ?, env = ?,
161164
secret_env_keys = ?, enabled = ?, client_overrides = ?,
162165
tags = ?, notes = ?, updated_at = ?
163166
WHERE id = ?`,
164167
)
165168
.run(
166169
updates.name ?? existing.name,
167170
updates.type ?? existing.type,
171+
updates.url ?? existing.url ?? '',
168172
updates.command ?? existing.command,
169173
JSON.stringify(updates.args ?? existing.args),
170174
JSON.stringify(updates.env ?? existing.env),

src/main/ipc/clients.ipc.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414

1515
import { ipcMain } from 'electron'
1616
import log from 'electron-log'
17-
import type { ClientId, ClientStatus, McpServerMap, SyncResult } from '@shared/types'
17+
import type {
18+
ClientId,
19+
ClientStatus,
20+
McpServerMap,
21+
SyncResult,
22+
ValidationResult,
23+
} from '@shared/types'
1824
import { ADAPTERS, ADAPTER_IDS } from '@main/clients/registry'
1925
import { getDatabase } from '@main/db/connection'
2026
import { ServersRepo } from '@main/db/servers.repo'
@@ -147,5 +153,28 @@ export const registerClientsIpc = (): void => {
147153
return results
148154
})
149155

156+
// ── clients:validate-config ───────────────────────────────────────────────
157+
ipcMain.handle(
158+
'clients:validate-config',
159+
async (_event, clientId: ClientId): Promise<ValidationResult> => {
160+
log.debug(`[ipc] clients:validate-config ${clientId}`)
161+
162+
const adapter = ADAPTERS.get(clientId)
163+
if (!adapter) {
164+
return { valid: false, errors: [`Unknown client: ${clientId}`] }
165+
}
166+
167+
const detection = await adapter.detect()
168+
if (!detection.installed || detection.configPaths.length === 0) {
169+
return {
170+
valid: false,
171+
errors: [`${adapter.displayName} is not installed or has no config file`],
172+
}
173+
}
174+
175+
return adapter.validate(detection.configPaths[0]!)
176+
},
177+
)
178+
150179
log.info('[ipc] clients handlers registered')
151180
}

src/main/licensing/licensing.service.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,23 @@
1010
* @description LemonSqueezy license validation service. Handles activation,
1111
* validation, and deactivation of license keys via the LemonSqueezy API.
1212
*
13-
* The validation result is cached in `electron.safeStorage` (encrypted AES-256
14-
* on Windows) so the app can start instantly without a network round-trip. The
15-
* cache is considered stale after 7 days and re-validated in the background.
16-
* If the network is unavailable during re-validation, a 7-day grace period
17-
* keeps the cached status active. On expiry or an explicit invalid response
18-
* the app falls back to the free tier.
13+
* The validation result is encrypted via `electron.safeStorage` (AES-256 on
14+
* Windows) and persisted to `<userData>/license.enc` so the app can start
15+
* instantly without a network round-trip on every launch. The cache is
16+
* considered stale after 7 days and re-validated in the background. If the
17+
* network is unavailable during re-validation, a 7-day grace period keeps the
18+
* cached status active. On expiry or an explicit invalid response the app
19+
* falls back to the free tier.
1920
*
2021
* Lifecycle:
21-
* app start → getStatus() reads cache → background re-validate if stale
22-
* user enters key → activateLicense(key) → validate → update cache
23-
* user deactivates → deactivateLicense() → API call → clear cache
22+
* app start → getStatus() reads cache file → background re-validate if stale
23+
* user enters key → activateLicense(key) → validate → update cache file
24+
* user deactivates → deactivateLicense() → API call → delete cache file
2425
*/
2526

26-
import { safeStorage } from 'electron'
27+
import { safeStorage, app } from 'electron'
28+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
29+
import { join } from 'path'
2730
import log from 'electron-log'
2831
import type { LicenseStatus, PlanTier } from '@shared/types'
2932

@@ -47,8 +50,11 @@ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
4750
*/
4851
const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000
4952

50-
/** safeStorage encryption key name used to persist the license cache. */
51-
const STORAGE_KEY = 'aidrelay-license-cache'
53+
/**
54+
* Returns the absolute path to the encrypted license cache file.
55+
* Stored in the per-user Electron userData directory so it survives app restarts.
56+
*/
57+
const cachePath = (): string => join(app.getPath('userData'), 'license.enc')
5258

5359
// ─── Internal Cache Shape ─────────────────────────────────────────────────────
5460

@@ -76,44 +82,53 @@ const freeTierStatus = (): LicenseStatus => ({
7682
})
7783

7884
/**
79-
* Reads and decrypts the cached license data from `electron.safeStorage`.
80-
* Returns `null` if nothing is stored or decryption fails.
85+
* Reads and decrypts the cached license data from the encrypted file on disk.
86+
* Returns `null` if the file does not exist, encryption is unavailable, or
87+
* the file contents cannot be decrypted (e.g. the OS key changed).
8188
*/
8289
const readCache = (): LicenseCache | null => {
8390
try {
8491
if (!safeStorage.isEncryptionAvailable()) return null
85-
const stored = process.env['_AIDRELAY_LICENSE_CACHE']
86-
if (!stored) return null
87-
const decrypted = safeStorage.decryptString(Buffer.from(stored, 'base64'))
92+
const file = cachePath()
93+
if (!existsSync(file)) return null
94+
const encrypted = readFileSync(file)
95+
const decrypted = safeStorage.decryptString(encrypted)
8896
return JSON.parse(decrypted) as LicenseCache
8997
} catch {
9098
return null
9199
}
92100
}
93101

94102
/**
95-
* Encrypts and writes license cache data to `electron.safeStorage`.
103+
* Encrypts the license cache and writes it to `<userData>/license.enc`.
104+
* The file is replaced atomically so a partial write never corrupts it.
105+
*
106+
* @param cache - The cache data to persist.
96107
*/
97108
const writeCache = (cache: LicenseCache): void => {
98109
try {
99110
if (!safeStorage.isEncryptionAvailable()) return
100111
const json = JSON.stringify(cache)
101112
const encrypted = safeStorage.encryptString(json)
102-
// Store in memory env var as a stand-in for a real persistent key-value store.
103-
// In production this would be written to app.getPath('userData')/license.enc
104-
process.env['_AIDRELAY_LICENSE_CACHE'] = encrypted.toString('base64')
113+
writeFileSync(cachePath(), encrypted)
105114
log.debug(`[license] cache written for key ending ...${cache.key.slice(-4)}`)
106115
} catch (err) {
107116
log.warn('[license] failed to write cache:', err)
108117
}
109118
}
110119

111120
/**
112-
* Clears the cached license data from `electron.safeStorage`.
121+
* Deletes the encrypted license cache file from disk.
122+
* Safe to call even if the file does not exist.
113123
*/
114124
const clearCache = (): void => {
115-
delete process.env['_AIDRELAY_LICENSE_CACHE']
116-
log.debug('[license] cache cleared')
125+
try {
126+
const file = cachePath()
127+
if (existsSync(file)) unlinkSync(file)
128+
log.debug('[license] cache cleared')
129+
} catch (err) {
130+
log.warn('[license] failed to clear cache:', err)
131+
}
117132
}
118133

119134
// ─── API Calls ────────────────────────────────────────────────────────────────
@@ -294,7 +309,6 @@ export const deactivateLicense = async (): Promise<void> => {
294309
await apiDeactivate(cache.key)
295310
}
296311
clearCache()
297-
// Suppress unused variable warning for STORE_ID in stub implementation.
298-
void STORAGE_KEY
312+
// STORE_ID is used via environment variable — suppress the unused warning.
299313
void STORE_ID
300314
}

src/main/updater/updater.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
* checks from non-published builds.
2525
*/
2626

27-
import { autoUpdater } from 'electron-updater'
27+
import updaterPkg from 'electron-updater'
28+
29+
const { autoUpdater } = updaterPkg
2830
import { BrowserWindow } from 'electron'
2931
import log from 'electron-log'
3032
import { is } from '@electron-toolkit/utils'

0 commit comments

Comments
 (0)