@pmate/cli 0.35.1 → 0.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ const { contextBridge, ipcRenderer } = require('electron')
2
+ contextBridge.exposeInMainWorld('pmateIncomingCall', Object.freeze({
3
+ getSession: () => ipcRenderer.invoke('pmate:call:session'),
4
+ updateState: state => ipcRenderer.invoke('pmate:call:state', state),
5
+ onCloseRequested: handler => {
6
+ const listener = () => handler()
7
+ ipcRenderer.on('pmate:call:closeRequested', listener)
8
+ return () => ipcRenderer.removeListener('pmate:call:closeRequested', listener)
9
+ },
10
+ onCaller: handler => {
11
+ const listener = (_event, caller) => handler(caller)
12
+ ipcRenderer.on('pmate:call:caller', listener)
13
+ return () => ipcRenderer.removeListener('pmate:call:caller', listener)
14
+ },
15
+ }))
@@ -0,0 +1,100 @@
1
+ const path = require('node:path')
2
+ function createDesktopCalls({ appId, instanceId, origin, BrowserWindow, ipcMain, dialog, createClient = async options => {
3
+ const { CallClient } = await import('@pmate/chat-sdk/runtime')
4
+ return new CallClient(options)
5
+ }, onError = () => {} }) {
6
+ let client, identity, generation = 0, current, unsubscribe
7
+ const people = new Map()
8
+ const trusted = event => {
9
+ try { return current && event.sender === current.window.webContents && event.senderFrame === event.sender.mainFrame
10
+ && new URL(event.senderFrame.url).origin === origin && new URL(event.senderFrame.url).pathname === '/_desktop/call' } catch { return false }
11
+ }
12
+ function destroy() {
13
+ const previous = current
14
+ current = undefined
15
+ if (previous) { clearTimeout(previous.timer); previous.resolve('suppressed'); previous.window.destroy() }
16
+ }
17
+ function present(callId) {
18
+ if (!client || !identity || client.snapshot().callId !== callId || client.snapshot().phase !== 'incoming') return Promise.resolve('suppressed')
19
+ if (current?.callId === callId) return current.ready
20
+ destroy()
21
+ const window = new BrowserWindow({ width: 420, height: 680, minWidth: 380, minHeight: 540, title: '来电', show: false,
22
+ backgroundColor: '#ffffff', webPreferences: { preload: path.join(__dirname, 'call-preload.cjs'), partition: 'persist:desktop',
23
+ nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, autoplayPolicy: 'no-user-gesture-required' } })
24
+ let resolve, reject
25
+ const ready = new Promise((yes, no) => { resolve = yes; reject = no })
26
+ const entry = current = { callId, window, ready, resolve, reject, phase: 'incoming', confirming: false }
27
+ entry.timer = setTimeout(() => { if (current === entry) { reject(new Error('Incoming call window timeout')); destroy() } }, 1500)
28
+ window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
29
+ for (const event of ['will-navigate', 'will-redirect', 'will-attach-webview']) window.webContents.on(event, e => e.preventDefault())
30
+ window.webContents.on('did-fail-load', (_event, code) => { if (code !== -3 && current === entry) { reject(new Error('Incoming call window failed')); destroy() } })
31
+ window.on('close', event => {
32
+ if (current !== entry) return
33
+ event.preventDefault()
34
+ if (entry.phase === 'incoming' || entry.phase === 'ended' || entry.phase === 'idle') {
35
+ if (entry.phase === 'incoming') client?.hangup()
36
+ destroy(); return
37
+ }
38
+ if (entry.confirming) return
39
+ entry.confirming = true
40
+ void dialog.showMessageBox(window, { type: 'question', message: '结束通话并关闭窗口?', buttons: ['继续通话', '结束通话'], defaultId: 0, cancelId: 0 })
41
+ .then(result => { if (current === entry && result.response === 1) { window.webContents.send('pmate:call:closeRequested'); setTimeout(() => { if (current === entry) destroy() }, 2000) } })
42
+ .catch(onError).finally(() => { entry.confirming = false })
43
+ })
44
+ void window.loadURL(`${origin}/_desktop/call`).catch(error => { if (current === entry) { reject(error); destroy() } })
45
+ return ready
46
+ }
47
+ ipcMain.handle('pmate:call:session', event => {
48
+ if (!trusted(event) || !identity || !client) throw new Error('Untrusted call session request')
49
+ const callerId = client.snapshot().peerId, caller = people.get(callerId) ?? { id: callerId, name: '联系人' }
50
+ return { callId: current.callId, profileId: identity.profileId, token: identity.token, clientInstanceId: instanceId, caller, url: 'wss://calls.pmate.chat/call' }
51
+ })
52
+ ipcMain.handle('pmate:call:state', (event, state) => {
53
+ if (!trusted(event) || !state || state.callId !== current.callId || !['idle', 'requesting', 'ringing', 'incoming', 'connecting', 'connected', 'ended'].includes(state.phase)) throw new Error('Invalid call window state')
54
+ const entry = current
55
+ entry.phase = state.phase
56
+ if (['incoming', 'connecting', 'connected'].includes(state.phase)) {
57
+ clearTimeout(entry.timer); entry.window.show(); entry.window.focus(); entry.resolve('handled')
58
+ } else if (state.phase === 'ended') destroy()
59
+ })
60
+ return {
61
+ getWindow: () => current?.window,
62
+ setPeople(values) {
63
+ if (!Array.isArray(values) || values.length > 2000 || values.some(value => !value || typeof value.id !== 'string' || value.id.length > 128
64
+ || typeof value.name !== 'string' || value.name.length > 256 || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 2048)))) throw new Error('Invalid call directory')
65
+ people.clear(); for (const value of values) people.set(value.id, { id: value.id, name: value.name, avatar: value.avatar })
66
+ if (current && client) { const caller = people.get(client.snapshot().peerId); if (caller) current.window.webContents.send('pmate:call:caller', caller) }
67
+ },
68
+ async configure(value) {
69
+ if (identity?.profileId === value.profileId && identity?.token === value.token && client) return
70
+ generation++; const ownGeneration = generation
71
+ unsubscribe?.(); client?.close(); client = undefined; identity = undefined; destroy(); people.clear()
72
+ const next = await createClient({ profileId: value.profileId, token: value.token, clientInstanceId: instanceId })
73
+ if (generation !== ownGeneration) { next.close(); return }
74
+ identity = { profileId: value.profileId, token: value.token }; client = next
75
+ unsubscribe = next.subscribe(state => {
76
+ if (generation !== ownGeneration) return
77
+ if (state.phase === 'incoming') void present(state.callId).catch(onError)
78
+ else if (state.phase === 'ended' && state.reason !== 'answered_elsewhere' && current?.callId === state.callId) destroy()
79
+ })
80
+ next.connect()
81
+ },
82
+ async handleReach(event) {
83
+ if (event.appId !== appId || !client) throw new Error('Call handler unavailable')
84
+ const ownGeneration = generation, result = await client.handleReach(event)
85
+ if (generation !== ownGeneration) throw new Error('Call identity changed')
86
+ if (event.eventType === 'call.incoming' && result === 'handled') return present(event.payload.callId)
87
+ if (event.eventType === 'call.ended' && result === 'handled' && current?.callId === event.payload.callId) destroy()
88
+ return result
89
+ },
90
+ async incoming(callId) {
91
+ if (!client || typeof callId !== 'string' || !callId || callId.length > 128) throw new Error('Invalid incoming call')
92
+ if (current?.callId === callId && ['incoming', 'connecting', 'connected'].includes(current.phase)) { current.window.show(); current.window.focus(); return current.ready }
93
+ const ownGeneration = generation, result = await client.attachAndWait(callId)
94
+ if (generation !== ownGeneration) throw new Error('Call identity changed')
95
+ return result === 'handled' ? present(callId) : result
96
+ },
97
+ stop() { generation++; unsubscribe?.(); unsubscribe = undefined; client?.close(); client = undefined; identity = undefined; destroy(); people.clear() },
98
+ }
99
+ }
100
+ module.exports = { createDesktopCalls }
@@ -1,7 +1,11 @@
1
- const { app, BrowserWindow, session, shell, Menu, desktopCapturer, ipcMain } = require('electron')
1
+ const { app, BrowserWindow, session, shell, Menu, desktopCapturer, ipcMain, Notification, dialog } = require('electron')
2
2
  const path = require('node:path')
3
3
  const { createResourceHandler } = require('./resources.cjs')
4
4
  const { installMediaPermissions } = require('./media.cjs')
5
+ const { createDesktopNotifications } = require('./notifications.cjs')
6
+ const { desktopInstanceId, createDesktopReach } = require('./reach.cjs')
7
+ const { createRendererReach } = require('./reach-renderer.cjs')
8
+ const { createDesktopCalls } = require('./calls.cjs')
5
9
  const config = require('./desktop-config.json')
6
10
  const manifest = require('./resource-manifest.json')
7
11
  app.setName(config.productName)
@@ -9,6 +13,7 @@ app.setName(config.productName)
9
13
  app.setPath('userData', path.join(app.getPath('appData'), config.bundleId))
10
14
  const allowedNavigation = new Set([config.origin, ...config.authOrigins])
11
15
  let mainWindow
16
+ let desktopNotifications, desktopReach, desktopCalls
12
17
  function isAllowed(raw) { try { return allowedNavigation.has(new URL(raw).origin) } catch { return false } }
13
18
  function external(raw) {
14
19
  try { if (['https:', 'http:', 'mailto:'].includes(new URL(raw).protocol)) void shell.openExternal(raw) } catch { /* Invalid links are not executable targets. */ }
@@ -26,6 +31,7 @@ function restrict(window) {
26
31
  function createWindow() {
27
32
  mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 380, minHeight: 540, title: config.productName, backgroundColor: '#f6f8fb', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), partition: 'persist:desktop', nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
28
33
  restrict(mainWindow)
34
+ mainWindow.webContents.on('did-start-navigation', (_event, _url, _inPlace, mainFrame) => { if (mainFrame) desktopNotifications?.clearContext() })
29
35
  mainWindow.webContents.on('did-fail-load', (_event, code, _description, url, mainFrame) => {
30
36
  if (mainFrame && code !== -3 && url !== `${config.origin}/_desktop/offline`) void mainWindow.loadURL(`${config.origin}/_desktop/offline`)
31
37
  })
@@ -45,7 +51,7 @@ else {
45
51
  }
46
52
  return resources(request) ?? desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
47
53
  })
48
- installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, desktopCapturer, BrowserWindow, ipcMain })
54
+ installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, getWindows: () => [mainWindow, desktopCalls?.getWindow()], desktopCapturer, BrowserWindow, ipcMain })
49
55
  ipcMain.handle('pmate:screen-settings', event => {
50
56
  if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || new URL(mainWindow.webContents.getURL()).origin !== config.origin) return false
51
57
  if (process.platform === 'darwin') { void shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); return true }
@@ -55,6 +61,42 @@ else {
55
61
  { role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' },
56
62
  { label: config.productName, submenu: [{ label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin) } }] },
57
63
  ]))
64
+ desktopNotifications = createDesktopNotifications({ ipcMain, Notification, origin: config.origin, appId: config.app, getWindow: () => mainWindow, showWindow: () => {
65
+ if (!mainWindow) createWindow()
66
+ if (mainWindow.isMinimized()) mainWindow.restore()
67
+ mainWindow.show(); mainWindow.focus()
68
+ return mainWindow
69
+ } })
70
+ const instanceId = desktopInstanceId(app.getPath('userData'))
71
+ const rendererReach = createRendererReach({ ipcMain, getWindow: () => mainWindow, trusted: event => desktopNotifications.trusted(event) })
72
+ if (config.app === 'pmate:hello') desktopCalls = createDesktopCalls({ appId: config.app, instanceId, origin: config.origin, BrowserWindow, ipcMain, dialog })
73
+ desktopReach = createDesktopReach({ appId: config.app, instanceId, present: input => desktopNotifications.display(input),
74
+ configureCalls: identity => { rendererReach.reset(); return desktopCalls?.configure(identity) }, stopCalls: () => { rendererReach.reset(); desktopCalls?.stop() },
75
+ incomingCall: callId => { if (!desktopCalls) throw new Error('Call handler unavailable'); return desktopCalls.incoming(callId) },
76
+ isViewingThread: threadId => desktopNotifications.isViewingThread(threadId),
77
+ onReach: event => ['call.incoming', 'call.ended'].includes(event.eventType) && desktopCalls ? desktopCalls.handleReach(event) : rendererReach.deliver(event),
78
+ })
79
+ ipcMain.handle('pmate:call:incoming', (event, callId) => {
80
+ if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
81
+ return desktopCalls.incoming(callId)
82
+ })
83
+ ipcMain.handle('pmate:call:reach', (event, value) => {
84
+ if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
85
+ return desktopCalls.handleReach(value)
86
+ })
87
+ ipcMain.handle('pmate:call:people', (event, people) => {
88
+ if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
89
+ desktopCalls.setPeople(people)
90
+ })
91
+ ipcMain.handle('pmate:reach:configure', (event, identity) => {
92
+ if (!desktopNotifications.trusted(event)) throw new Error('Untrusted reach sender')
93
+ return desktopReach.configure(identity)
94
+ })
95
+ ipcMain.handle('pmate:reach:stop', event => {
96
+ if (!desktopNotifications.trusted(event)) throw new Error('Untrusted reach sender')
97
+ desktopReach.stop(); desktopNotifications.reset()
98
+ })
99
+ app.on('before-quit', () => { desktopReach.stop(); desktopNotifications.reset() })
58
100
  createWindow()
59
101
  app.on('activate', () => { if (!mainWindow) createWindow() })
60
102
  }).catch(error => { console.error('Desktop startup failed:', error.message); app.exit(1) })
@@ -1,9 +1,8 @@
1
1
  const { chooseScreen } = require('./share-picker.cjs')
2
- function installMediaPermissions(desktopSession, { origin, getWindow, desktopCapturer, BrowserWindow, ipcMain }) {
2
+ function installMediaPermissions(desktopSession, { origin, getWindow, getWindows = () => [getWindow()], desktopCapturer, BrowserWindow, ipcMain }) {
3
3
  const sameOrigin = raw => { try { return new URL(raw).origin === origin } catch { return false } }
4
4
  const trusted = contents => {
5
- const window = getWindow()
6
- return !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL())
5
+ return getWindows().some(window => !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL()))
7
6
  }
8
7
  desktopSession.setPermissionRequestHandler((contents, permission, callback, details) => {
9
8
  const allowed = trusted(contents) && details.isMainFrame === true && sameOrigin(details.requestingUrl)
@@ -13,7 +12,7 @@ function installMediaPermissions(desktopSession, { origin, getWindow, desktopCap
13
12
  return Boolean(trusted(contents) && sameOrigin(requestingOrigin) && details.isMainFrame === true && (permission === 'display-capture' || (permission === 'media' && details.mediaType === 'audio')))
14
13
  })
15
14
  desktopSession.setDisplayMediaRequestHandler(async (request, callback) => {
16
- const window = getWindow()
15
+ const window = getWindows().find(window => window && !window.isDestroyed() && request.frame === window.webContents.mainFrame)
17
16
  const valid = () => window && !window.isDestroyed() && trusted(window.webContents) && request.frame && request.frame === window.webContents.mainFrame && sameOrigin(request.securityOrigin)
18
17
  if (!valid() || !request.userGesture || !request.videoRequested || request.audioRequested) return callback(null)
19
18
  let completed = false
@@ -0,0 +1,71 @@
1
+ // Main-process presentation boundary. Only the configured app's top frame may invoke it.
2
+ function createDesktopNotifications({ ipcMain, Notification, origin, appId, getWindow, showWindow, now = Date.now }) {
3
+ let activeThreadId, pendingOpen, epoch = 0
4
+ const shown = new Map()
5
+ const pending = new Map()
6
+ const trusted = event => {
7
+ try { return event.sender === getWindow()?.webContents && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin } catch { return false }
8
+ }
9
+ const context = (event, value) => {
10
+ if (!trusted(event)) throw new Error('Untrusted notification context')
11
+ if (value !== null && (typeof value !== 'string' || value.length > 512)) throw new Error('Invalid thread context')
12
+ activeThreadId = value ?? undefined
13
+ }
14
+ const present = async (event, input) => {
15
+ if (!trusted(event)) throw new Error('Untrusted notification sender')
16
+ return display(input)
17
+ }
18
+ const display = input => {
19
+ if (!input || input.appId !== appId || typeof input.notificationId !== 'string' || !input.notificationId || input.notificationId.length > 512
20
+ || typeof input.title !== 'string' || input.title.length > 256 || typeof input.body !== 'string' || input.body.length > 4096
21
+ || ['threadId', 'callId', 'meetingId'].some(key => input[key] !== undefined && (typeof input[key] !== 'string' || input[key].length > 512))) throw new Error('Invalid notification')
22
+ for (const [id, item] of shown) if (item.until <= now()) { item.notification.close(); shown.delete(id) }
23
+ if (input.expiresAtMs !== undefined && (!Number.isFinite(input.expiresAtMs) || input.expiresAtMs <= now())) return 'suppressed'
24
+ if (shown.has(input.notificationId)) return 'suppressed'
25
+ if (pending.has(input.notificationId)) return pending.get(input.notificationId)
26
+ const window = getWindow()
27
+ if (input.digestThreads !== undefined) {
28
+ if (!Array.isArray(input.digestThreads) || !input.digestThreads.length || input.digestThreads.length > 1000 || input.digestThreads.some(item => !item || typeof item.threadId !== 'string' || !item.threadId || item.threadId.length > 512 || typeof item.title !== 'string' || item.title.length > 512 || !Number.isSafeInteger(item.count) || item.count < 1)) throw new Error('Invalid notification digest')
29
+ const visible = input.digestThreads.filter(item => !(item.threadId === activeThreadId && window && window.isVisible() && window.isFocused() && !window.isMinimized()))
30
+ if (!visible.length) return 'suppressed'
31
+ input = { ...input, threadId: visible.length === 1 ? visible[0].threadId : undefined,
32
+ body: visible.length === input.digestThreads.length ? input.body : `${visible.length} 个会话中有 ${visible.reduce((sum, item) => sum + item.count, 0)} 条新消息` }
33
+ }
34
+ if (input.threadId && input.threadId === activeThreadId && window && window.isVisible() && window.isFocused() && !window.isMinimized()) return 'suppressed'
35
+ if (!Notification.isSupported()) throw new Error('Desktop notifications unavailable')
36
+ const notification = new Notification({ title: input.title, body: input.body, silent: false })
37
+ const until = Math.min(input.expiresAtMs ?? now() + 86400000, now() + 86400000)
38
+ const presentationEpoch = epoch
39
+ const operation = new Promise((resolve, reject) => {
40
+ let settled = false
41
+ const timeout = setTimeout(() => { if (!settled) { settled = true; notification.close(); reject(new Error('Notification display timeout')) } }, 1500)
42
+ notification.once('show', () => {
43
+ if (settled) return
44
+ settled = true; clearTimeout(timeout)
45
+ if (presentationEpoch !== epoch) { notification.close(); reject(new Error('Notification identity changed')); return }
46
+ if (shown.size >= 10000) { const first = shown.keys().next().value; shown.get(first).notification.close(); shown.delete(first) }
47
+ shown.set(input.notificationId, { notification, until }); resolve('displayed')
48
+ })
49
+ notification.once('failed', () => { if (!settled) { settled = true; clearTimeout(timeout); reject(new Error('Notification display failed')) } })
50
+ notification.on('click', () => {
51
+ const main = showWindow()
52
+ pendingOpen = { notificationId: input.notificationId, threadId: input.threadId, callId: input.callId, meetingId: input.meetingId }
53
+ main.webContents.send('pmate:notification:open', pendingOpen)
54
+ notification.close()
55
+ })
56
+ notification.show()
57
+ })
58
+ pending.set(input.notificationId, operation)
59
+ const cleanup = () => { if (pending.get(input.notificationId) === operation) pending.delete(input.notificationId) }
60
+ void operation.then(cleanup, cleanup)
61
+ return operation
62
+ }
63
+ ipcMain.handle('pmate:notification:consumeOpen', event => {
64
+ if (!trusted(event)) throw new Error('Untrusted notification click consumer')
65
+ const intent = pendingOpen; pendingOpen = undefined; return intent
66
+ })
67
+ ipcMain.handle('pmate:notification:context', context)
68
+ ipcMain.handle('pmate:notification:present', present)
69
+ return { display, trusted, isViewingThread(threadId) { const window = getWindow(); return !!threadId && threadId === activeThreadId && !!window && window.isVisible() && window.isFocused() && !window.isMinimized() }, clearContext() { activeThreadId = undefined }, reset() { epoch++; pendingOpen = undefined; activeThreadId = undefined; for (const item of shown.values()) item.notification.close(); shown.clear() } }
70
+ }
71
+ module.exports = { createDesktopNotifications }
@@ -1,2 +1,35 @@
1
1
  const { contextBridge, ipcRenderer } = require('electron')
2
- contextBridge.exposeInMainWorld('pmateDesktop', { openScreenSettings: () => ipcRenderer.invoke('pmate:screen-settings') })
2
+ contextBridge.exposeInMainWorld('pmateDesktop', Object.freeze({
3
+ openScreenSettings: () => ipcRenderer.invoke('pmate:screen-settings'),
4
+ reach: Object.freeze({
5
+ onEvent: handler => {
6
+ const listener = (_event, value) => {
7
+ Promise.resolve().then(() => handler(value.event)).then(status =>
8
+ ipcRenderer.invoke('pmate:reach:ack', { requestId: value.requestId, status }), () =>
9
+ ipcRenderer.invoke('pmate:reach:ack', { requestId: value.requestId, status: 'failed' })).catch(() => {})
10
+ }
11
+ ipcRenderer.on('pmate:reach:event', listener)
12
+ return () => ipcRenderer.removeListener('pmate:reach:event', listener)
13
+ },
14
+ configure: identity => ipcRenderer.invoke('pmate:reach:configure', identity),
15
+ stop: () => ipcRenderer.invoke('pmate:reach:stop'),
16
+ }),
17
+ calls: Object.freeze({
18
+ incoming: callId => ipcRenderer.invoke('pmate:call:incoming', callId),
19
+ handleReach: event => ipcRenderer.invoke('pmate:call:reach', event),
20
+ setPeople: people => ipcRenderer.invoke('pmate:call:people', people),
21
+ }),
22
+ notifications: Object.freeze({
23
+ present: input => ipcRenderer.invoke('pmate:notification:present', input),
24
+ setActiveThread: threadId => ipcRenderer.invoke('pmate:notification:context', threadId),
25
+ onOpen: handler => {
26
+ let active = true
27
+ const seen = new Set()
28
+ const deliver = value => { if (active && value && !seen.has(value.notificationId)) { seen.add(value.notificationId); handler(value) } }
29
+ const listener = () => { void ipcRenderer.invoke('pmate:notification:consumeOpen').then(deliver).catch(() => {}) }
30
+ ipcRenderer.on('pmate:notification:open', listener)
31
+ listener()
32
+ return () => { active = false; ipcRenderer.removeListener('pmate:notification:open', listener) }
33
+ },
34
+ }),
35
+ }))
@@ -0,0 +1,39 @@
1
+ const { randomUUID } = require('node:crypto')
2
+ /** A socket receipt is not a renderer business acknowledgement. */
3
+ function createRendererReach({ ipcMain, getWindow, trusted, timeoutMs = 1800 }) {
4
+ const pending = new Map()
5
+ ipcMain.handle('pmate:reach:ack', (event, reply) => {
6
+ if (!trusted(event)) throw new Error('Untrusted reach acknowledgement')
7
+ const item = pending.get(reply?.requestId)
8
+ if (!item || item.sender !== event.sender) throw new Error('Unknown reach request')
9
+ if (!['handled', 'suppressed', 'failed'].includes(reply.status)) throw new Error('Invalid reach acknowledgement')
10
+ item.finish(reply.status)
11
+ })
12
+ return {
13
+ deliver(event) {
14
+ const window = getWindow()
15
+ if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return Promise.reject(new Error('Reach renderer unavailable'))
16
+ const sender = window.webContents, requestId = randomUUID()
17
+ return new Promise((resolve, reject) => {
18
+ let timer
19
+ const finish = status => {
20
+ if (!pending.delete(requestId)) return
21
+ clearTimeout(timer)
22
+ sender.removeListener('destroyed', destroyed)
23
+ sender.removeListener('did-start-navigation', navigated)
24
+ if (status === 'handled' || status === 'suppressed') resolve(status)
25
+ else reject(new Error('Reach renderer did not handle event'))
26
+ }
27
+ const destroyed = () => finish('failed')
28
+ const navigated = (_event, _url, inPlace, mainFrame) => { if (mainFrame && !inPlace) finish('failed') }
29
+ pending.set(requestId, { sender, finish })
30
+ sender.once('destroyed', destroyed)
31
+ sender.on('did-start-navigation', navigated)
32
+ timer = setTimeout(() => finish('failed'), timeoutMs)
33
+ try { sender.send('pmate:reach:event', { requestId, event }) } catch { finish('failed') }
34
+ })
35
+ },
36
+ reset() { for (const item of [...pending.values()]) item.finish('failed') },
37
+ }
38
+ }
39
+ module.exports = { createRendererReach }
@@ -0,0 +1,62 @@
1
+ const { randomUUID } = require('node:crypto')
2
+ const { readFileSync, writeFileSync, mkdirSync } = require('node:fs')
3
+ const path = require('node:path')
4
+
5
+ function desktopInstanceId(userData) {
6
+ const file = path.join(userData, 'reach-device.json')
7
+ try { const value = JSON.parse(readFileSync(file, 'utf8')); if (typeof value.id === 'string' && /^desktop-[a-f0-9-]{36}$/.test(value.id)) return value.id } catch {}
8
+ mkdirSync(userData, { recursive: true })
9
+ const id = `desktop-${randomUUID()}`
10
+ writeFileSync(file, JSON.stringify({ id }), { mode: 0o600 })
11
+ return id
12
+ }
13
+ /** Credentials remain in memory. This connection survives renderer reload/closure. */
14
+ function createDesktopReach({ appId, instanceId, hubUrl = 'wss://hub.pmate.chat', present, onReach, configureCalls, stopCalls, incomingCall, isViewingThread, createClient = async options => {
15
+ const { ChatClient } = await import('@pmate/chat-sdk/runtime')
16
+ return new ChatClient(options)
17
+ } }) {
18
+ let client, identity, generation = 0
19
+ return {
20
+ async configure(value) {
21
+ if (!value || value.appId !== appId || ['accountId', 'profileId', 'token'].some(key => typeof value[key] !== 'string' || !value[key] || value[key].length > 16384)) throw new Error('Invalid reach identity')
22
+ if (client && identity && identity.accountId === value.accountId && identity.profileId === value.profileId && identity.token === value.token) return { instanceId }
23
+ const ownGeneration = ++generation
24
+ client?.close(); client = undefined; identity = { accountId: value.accountId, profileId: value.profileId, token: value.token }
25
+ await configureCalls?.(value)
26
+ if (generation !== ownGeneration) return { instanceId }
27
+ const next = await createClient({ appId, profileId: value.profileId, clientInstanceId: instanceId, hubUrl,
28
+ session: { token: value.token, session: { identity: { accountId: value.accountId, app: appId, profileId: value.profileId, kind: 'user' } } },
29
+ onNotification: async notification => {
30
+ if (generation !== ownGeneration) throw new Error('Reach account changed')
31
+ const data = notification.data
32
+ let threadId = data.threadId
33
+ if (data.type === 'call.incoming') {
34
+ if (!incomingCall || typeof data.callId !== 'string' || typeof data.callerId !== 'string') throw new Error('Call handler unavailable')
35
+ const { dmThreadId } = await import('@pmate/chat-sdk/runtime')
36
+ threadId = dmThreadId(appId, value.profileId, data.callerId)
37
+ const viewed = isViewingThread?.(threadId)
38
+ const result = await incomingCall(data.callId)
39
+ if (generation !== ownGeneration) throw new Error('Reach account changed')
40
+ if (result === 'suppressed' || viewed) return 'suppressed'
41
+ }
42
+ const digestThreads = data.type === 'social.messageDigest' ? JSON.parse(data.threads) : undefined
43
+ return present({ appId, notificationId: notification.notificationId, title: notification.title ?? '', body: notification.body ?? '', digestThreads,
44
+ threadId, callId: data.callId, meetingId: data.meetingId, expiresAtMs: notification.expiresAtMs })
45
+ },
46
+ onReach: async event => {
47
+ if (generation !== ownGeneration || !onReach) throw new Error('Reach handler unavailable')
48
+ const result = await onReach(event)
49
+ if (generation !== ownGeneration) throw new Error('Reach account changed')
50
+ return result
51
+ },
52
+ })
53
+ if (generation !== ownGeneration) { next.close(); return { instanceId } }
54
+ client = next
55
+ // Authentication and reconnect belong to the host; IPC does not wait for a network roundtrip.
56
+ void next.connect().catch(() => {})
57
+ return { instanceId }
58
+ },
59
+ stop() { generation++; client?.close(); client = undefined; identity = undefined; stopCalls?.() },
60
+ }
61
+ }
62
+ module.exports = { desktopInstanceId, createDesktopReach }
@@ -17,7 +17,7 @@ function createResourceHandler(root, origin, manifest) {
17
17
  if (name.includes('\\') || name.includes('\0') || name.split('/').some(p => p === '..' || p === '.')) return new Response('Invalid path', { status: 400 })
18
18
  // Exact files come from the build manifest. Only extensionless document routes get the SPA shell.
19
19
  if (!Object.hasOwn(manifest, name)) {
20
- if (!path.extname(name) && request.headers.get('sec-fetch-dest') === 'document') name = 'index.html'
20
+ if (name === '_desktop/call' || (!path.extname(name) && request.headers.get('sec-fetch-dest') === 'document')) name = 'index.html'
21
21
  else if (!name) name = 'index.html'
22
22
  else return new Response('Resource not found', { status: 404 })
23
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/cli",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "description": "CLI utilities for the pMate project",
5
5
  "license": "MIT",
6
6
  "bin": {