217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import { app, BrowserWindow, desktopCapturer, ipcMain, shell, systemPreferences } from 'electron'
|
|
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
|
|
|
|
import { createScreenshotCapture } from './command-screenshot-capture'
|
|
import { CommandScreenshotMonitor } from './command-screenshot-monitor'
|
|
import type { ScreenshotStatus } from './command-screenshot-types'
|
|
|
|
/** Device-local, opt-in native gesture. No renderer can request an arbitrary screenshot. */
|
|
export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string }): () => void {
|
|
if (process.platform !== 'darwin') {
|
|
return () => undefined
|
|
}
|
|
|
|
const configPath = path.join(app.getPath('userData'), 'screenshot.json')
|
|
const expectedUrl = new URL(rendererUrl)
|
|
const monitor = new CommandScreenshotMonitor({ appPath: app.getAppPath() })
|
|
const hasScreenPermission = () => systemPreferences.getMediaAccessStatus('screen') === 'granted'
|
|
const capture = createScreenshotCapture({ hasScreenPermission, getSources: options => desktopCapturer.getSources(options) })
|
|
const recipients = new Map<number, () => void>()
|
|
let lastRecipient: BrowserWindow | null = null
|
|
let enabled = false
|
|
let monitorState: ScreenshotStatus['state'] = 'disabled'
|
|
let disposed = false
|
|
let generation = 0
|
|
|
|
try {
|
|
enabled = JSON.parse(readFileSync(configPath, 'utf8')).enabled === true
|
|
} catch {
|
|
// Missing or malformed device preference must never opt the user in.
|
|
}
|
|
|
|
const status = (): ScreenshotStatus => ({
|
|
enabled,
|
|
state: !enabled ? 'disabled' : monitorState === 'ready' && !hasScreenPermission() ? 'screen-permission' : monitorState
|
|
})
|
|
|
|
const trustedWindow = (event: IpcMainEvent | IpcMainInvokeEvent): BrowserWindow | null => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
|
|
if (!win || win.isDestroyed() || event.senderFrame !== event.sender.mainFrame) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const url = new URL(event.senderFrame.url)
|
|
|
|
return url.protocol === expectedUrl.protocol && url.host === expectedUrl.host && url.pathname === expectedUrl.pathname ? win : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const publish = () => {
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (!win.isDestroyed()) {
|
|
win.webContents.send('hermes:screenshot:status', status())
|
|
}
|
|
}
|
|
}
|
|
|
|
const start = (requestPermission = false) => {
|
|
const current = ++generation
|
|
monitor.start(window => {
|
|
if (disposed || !enabled || current !== generation) {
|
|
return
|
|
}
|
|
|
|
// Retain the last Hermes chat window when another application takes focus.
|
|
// Closing it cancels this destination; never silently pick another chat.
|
|
const focused = BrowserWindow.getFocusedWindow()
|
|
const recipient = focused && recipients.has(focused.webContents.id) ? focused : lastRecipient
|
|
|
|
if (!recipient || recipient.isDestroyed() || !recipients.has(recipient.webContents.id)) {
|
|
return
|
|
}
|
|
|
|
const requestId = capture.request(recipient.webContents.id, window)
|
|
|
|
if (requestId) {
|
|
recipient.webContents.send('hermes:screenshot:request', requestId)
|
|
}
|
|
}, result => {
|
|
if (disposed || current !== generation) {
|
|
return
|
|
}
|
|
|
|
monitorState = result.type === 'error'
|
|
? result.code === 'permission-required' ? 'input-permission' : 'unavailable'
|
|
: result.type === 'stopped' ? 'disabled' : result.type
|
|
publish()
|
|
}, requestPermission)
|
|
}
|
|
|
|
const onFocus = (_event: unknown, win: BrowserWindow) => {
|
|
if (recipients.has(win.webContents.id)) {
|
|
lastRecipient = win
|
|
}
|
|
}
|
|
|
|
const onSubscribe = (event: IpcMainEvent, subscribed: unknown) => {
|
|
const win = trustedWindow(event)
|
|
|
|
if (!win) {
|
|
return
|
|
}
|
|
|
|
if (subscribed === true) {
|
|
if (!recipients.has(event.sender.id)) {
|
|
const id = event.sender.id
|
|
const onDestroyed = () => {
|
|
recipients.delete(id)
|
|
if (lastRecipient === win) {
|
|
lastRecipient = null
|
|
}
|
|
}
|
|
event.sender.once('destroyed', onDestroyed)
|
|
recipients.set(id, () => event.sender.removeListener('destroyed', onDestroyed))
|
|
}
|
|
|
|
if (!lastRecipient || BrowserWindow.getFocusedWindow() === win) {
|
|
lastRecipient = win
|
|
}
|
|
} else if (subscribed === false) {
|
|
recipients.get(event.sender.id)?.()
|
|
recipients.delete(event.sender.id)
|
|
if (lastRecipient === win) {
|
|
lastRecipient = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const channels: string[] = []
|
|
const handle = (name: string, callback: (event: IpcMainInvokeEvent, value: unknown) => unknown) => {
|
|
const channel = `hermes:screenshot:${name}`
|
|
channels.push(channel)
|
|
ipcMain.handle(channel, async (event, value) => {
|
|
if (!trustedWindow(event)) {
|
|
throw new Error('Screenshot request from an untrusted frame')
|
|
}
|
|
|
|
return callback(event, value)
|
|
})
|
|
}
|
|
|
|
handle('settings:get', () => status())
|
|
handle('settings:set', async (_event, value) => {
|
|
if (typeof value !== 'boolean') {
|
|
throw new Error('Screenshot setting must be a boolean')
|
|
}
|
|
|
|
// Persist first: a failed authoritative write must not leave a live monitor.
|
|
mkdirSync(path.dirname(configPath), { recursive: true })
|
|
writeFileSync(`${configPath}.tmp`, JSON.stringify({ enabled: value }), { mode: 0o600 })
|
|
renameSync(`${configPath}.tmp`, configPath)
|
|
enabled = value
|
|
generation += 1
|
|
capture.clear()
|
|
monitor.stop()
|
|
|
|
if (enabled) {
|
|
start(true)
|
|
if (!hasScreenPermission()) {
|
|
// Zero-size thumbnails skip content capture and may never request TCC
|
|
// consent. Request the smallest thumbnail only on explicit opt-in;
|
|
// discard it rather than retaining or attaching permission-probe pixels.
|
|
await desktopCapturer.getSources({ types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false }).catch(() => undefined)
|
|
}
|
|
} else {
|
|
monitorState = 'disabled'
|
|
}
|
|
|
|
publish()
|
|
|
|
return status()
|
|
})
|
|
handle('capture', async (event, requestId) => {
|
|
const result = await capture.take(event.sender.id, requestId)
|
|
if (result.ok === false && result.reason === 'screen-permission') {
|
|
publish()
|
|
}
|
|
|
|
return result
|
|
})
|
|
handle('permission', async (_event, kind) => {
|
|
const pane = kind === 'input' ? 'Privacy_ListenEvent' : kind === 'screen' ? 'Privacy_ScreenCapture' : null
|
|
if (!pane) {
|
|
throw new Error('Unknown screenshot permission')
|
|
}
|
|
await shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${pane}`)
|
|
})
|
|
ipcMain.on('hermes:screenshot:subscribe', onSubscribe)
|
|
app.on('browser-window-focus', onFocus)
|
|
|
|
const dispose = () => {
|
|
disposed = true
|
|
capture.clear()
|
|
monitor.stop()
|
|
recipients.forEach(unsubscribe => unsubscribe())
|
|
recipients.clear()
|
|
lastRecipient = null
|
|
channels.forEach(channel => ipcMain.removeHandler(channel))
|
|
ipcMain.removeListener('hermes:screenshot:subscribe', onSubscribe)
|
|
app.removeListener('browser-window-focus', onFocus)
|
|
app.removeListener('will-quit', dispose)
|
|
}
|
|
|
|
app.once('will-quit', dispose)
|
|
if (enabled) {
|
|
start()
|
|
}
|
|
|
|
return dispose
|
|
}
|