This commit is contained in:
SuYao 2025-12-18 20:17:00 +08:00 committed by GitHub
commit 992ff02486
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1060 additions and 18 deletions

View File

@ -66,6 +66,7 @@ asarUnpack:
- resources/** - resources/**
- "**/*.{metal,exp,lib}" - "**/*.{metal,exp,lib}"
- "node_modules/@img/sharp-libvips-*/**" - "node_modules/@img/sharp-libvips-*/**"
- "node_modules/node-screenshots-*/**"
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso # copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
extraResources: extraResources:

View File

@ -116,7 +116,8 @@ export default defineConfig({
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'), miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'), selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html') traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
screenshotSelection: resolve(__dirname, 'src/renderer/screenshotSelection.html')
}, },
onwarn(warning, warn) { onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return

View File

@ -94,6 +94,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"node-screenshots": "0.2.1",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0", "officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2", "os-proxy-config": "^1.1.2",

View File

@ -386,5 +386,13 @@ export enum IpcChannel {
WebSocket_Stop = 'webSocket:stop', WebSocket_Stop = 'webSocket:stop',
WebSocket_Status = 'webSocket:status', WebSocket_Status = 'webSocket:status',
WebSocket_SendFile = 'webSocket:send-file', WebSocket_SendFile = 'webSocket:send-file',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' WebSocket_GetAllCandidates = 'webSocket:get-all-candidates',
// Screenshot
Screenshot_CheckPermission = 'screenshot:check-permission',
Screenshot_Capture = 'screenshot:capture',
Screenshot_CaptureWithSelection = 'screenshot:capture-with-selection',
Screenshot_SelectionWindowReady = 'screenshot:selection-window-ready',
Screenshot_SelectionConfirm = 'screenshot:selection-confirm',
Screenshot_SelectionCancel = 'screenshot:selection-cancel'
} }

View File

@ -61,6 +61,7 @@ import powerMonitorService from './services/PowerMonitorService'
import { proxyManager } from './services/ProxyManager' import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService' import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager' import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { screenshotService } from './services/ScreenshotService'
import { searchService } from './services/SearchService' import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService' import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
@ -615,6 +616,39 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager)) ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager))
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager))
// Screenshot
ipcMain.handle(IpcChannel.Screenshot_CheckPermission, async () => {
if (!isMac) {
return { status: 'granted' as const }
}
const status = systemPreferences.getMediaAccessStatus('screen')
return {
status: status === 'granted' ? ('granted' as const) : ('denied' as const)
}
})
// Screenshot
ipcMain.handle(IpcChannel.Screenshot_Capture, async (_event, fileName: string) => {
return await screenshotService.capture(fileName)
})
ipcMain.handle(IpcChannel.Screenshot_CaptureWithSelection, async (_event, fileName: string) => {
return await screenshotService.captureWithSelection(fileName)
})
ipcMain.handle(
IpcChannel.Screenshot_SelectionConfirm,
async (_event, selection: { x: number; y: number; width: number; height: number }) => {
screenshotService.confirmSelection(selection)
}
)
ipcMain.handle(IpcChannel.Screenshot_SelectionCancel, async () => {
screenshotService.cancelSelection()
})
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))

View File

@ -0,0 +1,353 @@
import { loggerService } from '@logger'
import { isDev, isMac, isWin } from '@main/constant'
import { fileStorage } from '@main/services/FileStorage'
import { IpcChannel } from '@shared/IpcChannel'
import type { FileMetadata } from '@types'
import { FileTypes } from '@types'
import { BrowserWindow, screen, systemPreferences } from 'electron'
import fs from 'fs'
import { join } from 'path'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
const logger = loggerService.withContext('ScreenshotService')
type PermissionStatus = 'granted' | 'denied'
type CaptureResult =
| { success: true; file: FileMetadata }
| { success: false; status: PermissionStatus; needsRestart: boolean; message: string }
type SelectionCaptureResult =
| { success: true; file: FileMetadata }
| { success: false; status: 'cancelled' | 'denied' | 'error'; needsRestart?: boolean; message: string }
interface Rectangle {
x: number
y: number
width: number
height: number
}
class ScreenshotService {
private selectionWindow: BrowserWindow | null = null
private screenshotBuffer: Buffer | null = null
private screenshotData: string | null = null
private screenshotTempPath: string | null = null
private currentFileName: string | null = null
private selectionPromise: {
resolve: (value: SelectionCaptureResult) => void
reject: (reason?: any) => void
} | null = null
public async capture(fileName: string): Promise<CaptureResult> {
try {
const Screenshots = await import('node-screenshots')
// Try to capture - this will trigger permission dialog if needed
const monitor = Screenshots.Monitor.fromPoint(0, 0) ?? Screenshots.Monitor.all()[0]
if (!monitor) {
logger.error('No monitor found for screenshot')
return {
success: false,
status: 'denied',
needsRestart: false,
message: 'No monitor found'
}
}
const image = await monitor.captureImage()
const buffer = await image.toPng()
const ext = '.png'
const tempFilePath = await fileStorage.createTempFile({} as any, fileName || `screenshot${ext}`)
await fs.promises.writeFile(tempFilePath, buffer)
const stats = await fs.promises.stat(tempFilePath)
const id = uuidv4()
const file: FileMetadata = {
id,
name: `${id}${ext}`,
origin_name: path.basename(fileName || 'screenshot.png'),
path: tempFilePath,
size: stats.size,
ext,
type: FileTypes.IMAGE,
created_at: new Date().toISOString(),
count: 1
}
return { success: true, file }
} catch (error) {
logger.error('Screenshot capture failed', error as Error)
// Check if it's a permission issue on macOS
if (process.platform === 'darwin') {
const status = systemPreferences.getMediaAccessStatus('screen')
logger.info('Permission status after error:', { status })
if (status !== 'granted') {
return {
success: false,
status: 'denied',
needsRestart: status === 'not-determined' ? false : true,
message: 'Screen recording permission required'
}
}
}
return {
success: false,
status: 'denied',
needsRestart: false,
message: error instanceof Error ? error.message : 'Screenshot capture failed'
}
}
}
private createSelectionWindow(): BrowserWindow {
const display = screen.getPrimaryDisplay()
const { width, height } = display.bounds
const window = new BrowserWindow({
width,
height,
x: display.bounds.x,
y: display.bounds.y,
show: false,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
movable: false,
hasShadow: false,
enableLargerThanScreen: true,
// Platform specific settings
...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }),
...(isMac && { hiddenInMissionControl: true, acceptFirstMouse: true, visibleOnAllWorkspaces: true }),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
devTools: isDev ? true : false
}
})
// Clean up when closed
window.on('closed', async () => {
await this.cleanupSelection()
})
// Load the selection HTML
if (isDev) {
window.loadURL(`http://localhost:5173/screenshotSelection.html`)
} else {
window.loadFile(join(__dirname, '../renderer/screenshotSelection.html'))
}
return window
}
private async cleanupSelection() {
if (this.selectionWindow && !this.selectionWindow.isDestroyed()) {
this.selectionWindow.destroy()
}
this.selectionWindow = null
this.screenshotBuffer = null
this.screenshotData = null
this.currentFileName = null
this.selectionPromise = null
// Clean up temporary file
if (this.screenshotTempPath) {
try {
await fs.promises.unlink(this.screenshotTempPath)
} catch (error) {
logger.warn('Failed to delete temporary screenshot file', error as Error)
}
this.screenshotTempPath = null
}
}
public async confirmSelection(selection: Rectangle): Promise<void> {
if (!this.selectionPromise) {
logger.warn('No active selection to confirm')
return
}
// Validate selection
if (selection.width < 10 || selection.height < 10) {
this.selectionPromise.resolve({
success: false,
status: 'error',
message: 'Selection too small (minimum 10×10 pixels)'
})
await this.cleanupSelection()
return
}
// Process the selection
this.processSelection(selection)
}
public async cancelSelection(): Promise<void> {
if (!this.selectionPromise) {
logger.warn('No active selection to cancel')
return
}
this.selectionPromise.resolve({
success: false,
status: 'cancelled',
message: 'User cancelled selection'
})
await this.cleanupSelection()
}
private async processSelection(selection: Rectangle): Promise<void> {
if (!this.screenshotBuffer || !this.selectionPromise) {
logger.error('Missing screenshot buffer or selection promise')
this.cleanupSelection()
return
}
try {
// Get display scale factor for HiDPI support
const cursorPoint = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPoint)
const scaleFactor = display.scaleFactor
// Crop the screenshot
const croppedBuffer = await this.cropScreenshot(this.screenshotBuffer, selection, scaleFactor)
// Save the cropped image
const ext = '.png'
const fileName = this.currentFileName || `screenshot_${Date.now()}${ext}`
const tempFilePath = await fileStorage.createTempFile({} as any, fileName)
await fs.promises.writeFile(tempFilePath, croppedBuffer)
const stats = await fs.promises.stat(tempFilePath)
const id = uuidv4()
const file: FileMetadata = {
id,
name: `${id}${ext}`,
origin_name: fileName,
path: tempFilePath,
size: stats.size,
ext,
type: FileTypes.IMAGE,
created_at: new Date().toISOString(),
count: 1
}
this.selectionPromise.resolve({ success: true, file })
} catch (error) {
logger.error('Failed to process selection', error as Error)
this.selectionPromise.resolve({
success: false,
status: 'error',
message: error instanceof Error ? error.message : 'Failed to process selection'
})
} finally {
await this.cleanupSelection()
}
}
private async cropScreenshot(buffer: Buffer, selection: Rectangle, scaleFactor: number): Promise<Buffer> {
const sharp = (await import('sharp')).default
return sharp(buffer)
.extract({
left: Math.round(selection.x * scaleFactor),
top: Math.round(selection.y * scaleFactor),
width: Math.round(selection.width * scaleFactor),
height: Math.round(selection.height * scaleFactor)
})
.png()
.toBuffer()
}
public async captureWithSelection(fileName: string): Promise<SelectionCaptureResult> {
try {
// Try to capture - this will trigger permission dialog if needed
const Screenshots = await import('node-screenshots')
const monitor = Screenshots.Monitor.fromPoint(0, 0) ?? Screenshots.Monitor.all()[0]
if (!monitor) {
logger.error('No monitor found for screenshot')
return {
success: false,
status: 'error',
message: 'No monitor found'
}
}
const image = await monitor.captureImage()
const buffer = await image.toPng()
// Store the buffer and fileName for later cropping
this.screenshotBuffer = buffer
this.currentFileName = fileName
// Write buffer to a temporary file and store the file URL for the renderer
const tempFilePath = await fileStorage.createTempFile({} as any, `screenshot-${uuidv4()}.png`)
await fs.promises.writeFile(tempFilePath, buffer)
this.screenshotTempPath = tempFilePath
this.screenshotData = `file://${tempFilePath}`
// Create or show selection window
if (!this.selectionWindow || this.selectionWindow.isDestroyed()) {
this.selectionWindow = this.createSelectionWindow()
}
// Return a promise that resolves when user confirms or cancels
return new Promise((resolve, reject) => {
this.selectionPromise = { resolve, reject }
// Show the window and send screenshot data
this.selectionWindow!.once('ready-to-show', () => {
this.selectionWindow!.show()
this.selectionWindow!.focus()
// Send screenshot data to renderer
this.selectionWindow!.webContents.send(IpcChannel.Screenshot_SelectionWindowReady, {
screenshotData: this.screenshotData
})
})
})
} catch (error) {
logger.error('Screenshot capture with selection failed', error as Error)
await this.cleanupSelection()
// Check if it's a permission issue on macOS
if (process.platform === 'darwin') {
const status = systemPreferences.getMediaAccessStatus('screen')
logger.info('Permission status after error:', { status })
if (status !== 'granted') {
return {
success: false,
status: 'denied',
needsRestart: status === 'not-determined' ? false : true,
message: 'Screen recording permission required'
}
}
}
return {
success: false,
status: 'error',
message: error instanceof Error ? error.message : 'Screenshot capture failed'
}
}
}
}
export const screenshotService = new ScreenshotService()

View File

@ -137,6 +137,25 @@ const api = {
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text), compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
}, },
screenshot: {
checkPermission: (): Promise<{ status: 'granted' | 'denied' }> =>
ipcRenderer.invoke(IpcChannel.Screenshot_CheckPermission),
capture: (
fileName: string
): Promise<
| { success: true; file: FileMetadata }
| { success: false; status: 'granted' | 'denied'; needsRestart: boolean; message: string }
> => ipcRenderer.invoke(IpcChannel.Screenshot_Capture, fileName),
captureWithSelection: (
fileName: string
): Promise<
| { success: true; file: FileMetadata }
| { success: false; status: 'cancelled' | 'denied' | 'error'; needsRestart?: boolean; message: string }
> => ipcRenderer.invoke(IpcChannel.Screenshot_CaptureWithSelection, fileName),
confirmSelection: (selection: { x: number; y: number; width: number; height: number }): Promise<void> =>
ipcRenderer.invoke(IpcChannel.Screenshot_SelectionConfirm, selection),
cancelSelection: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Screenshot_SelectionCancel)
},
backup: { backup: {
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) => backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile), ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),

View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Screenshot Selection</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/screenshot/entryPoint.tsx"></script>
<style>
html {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background-color: transparent !important;
background-image: none !important;
}
body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
width: 100vw !important;
height: 100vh !important;
background-color: transparent !important;
cursor: crosshair !important;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
</style>
</body>
</html>

View File

@ -726,6 +726,18 @@
"pause": "Pause", "pause": "Pause",
"placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools", "placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools",
"placeholder_without_triggers": "Type your message here, press {{key}} to send", "placeholder_without_triggers": "Type your message here, press {{key}} to send",
"screenshot": {
"cancel": "Cancel",
"capture_failed": "Failed to capture screenshot",
"full_screen": "Full Screen",
"open_settings": "Open Settings",
"permission_denied": "Screen recording permission required",
"permission_dialog_content": "Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "Screen Recording Permission Required",
"permission_granted_restart": "Permission has been granted. Please restart the application for the changes to take effect.",
"select_region": "Select Region",
"tooltip": "Take screenshot"
},
"send": "Send", "send": "Send",
"settings": "Settings", "settings": "Settings",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,18 @@
"pause": "暂停", "pause": "暂停",
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具",
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
"screenshot": {
"cancel": "取消",
"capture_failed": "截屏失败",
"full_screen": "全屏截图",
"open_settings": "打开设置",
"permission_denied": "需要屏幕录制权限",
"permission_dialog_content": "使用截屏功能需要授予屏幕录制权限。是否要打开系统设置进行授权?",
"permission_dialog_title": "需要屏幕录制权限",
"permission_granted_restart": "权限已授予,请重启应用以使权限生效。",
"select_region": "选择区域",
"tooltip": "截屏"
},
"send": "发送", "send": "发送",
"settings": "设置", "settings": "设置",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,18 @@
"pause": "暫停", "pause": "暫停",
"placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具",
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
"screenshot": {
"cancel": "取消",
"capture_failed": "截圖失敗",
"full_screen": "全螢幕截圖",
"open_settings": "打開設定",
"permission_denied": "需要螢幕錄製權限",
"permission_dialog_content": "使用截圖功能需要授<E8A681><E68E88>螢幕錄製權限。是否要打開系統設定進行授權",
"permission_dialog_title": "需要螢幕錄製權限",
"permission_granted_restart": "權限已授予,請重啟應用程式以使權限生效。",
"select_region": "選擇區域",
"tooltip": "截圖"
},
"send": "傳送", "send": "傳送",
"settings": "設定", "settings": "設定",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Pause", "pause": "Pause",
"placeholder": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden - @ für Modellauswahl, / für Tools", "placeholder": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden - @ für Modellauswahl, / für Tools",
"placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden", "placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Senden", "send": "Senden",
"settings": "Einstellungen", "settings": "Einstellungen",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Παύση", "pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...", "placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή", "placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Αποστολή", "send": "Αποστολή",
"settings": "Ρυθμίσεις", "settings": "Ρυθμίσεις",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Pausar", "pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...", "placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar", "placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Enviar", "send": "Enviar",
"settings": "Configuración", "settings": "Configuración",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Pause", "pause": "Pause",
"placeholder": "Entrez votre message ici...", "placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer", "placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Envoyer", "send": "Envoyer",
"settings": "Paramètres", "settings": "Paramètres",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "一時停止", "pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "送信", "send": "送信",
"settings": "設定", "settings": "設定",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Pausar", "pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...", "placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar", "placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Enviar", "send": "Enviar",
"settings": "Configurações", "settings": "Configurações",
"slash_commands": { "slash_commands": {

View File

@ -726,6 +726,16 @@
"pause": "Остановить", "pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...", "placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки", "placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
"screenshot": {
"cancel": "[to be translated]:Cancel",
"capture_failed": "[to be translated]:Failed to capture screenshot",
"open_settings": "[to be translated]:Open Settings",
"permission_denied": "[to be translated]:Screen recording permission required",
"permission_dialog_content": "[to be translated]:Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?",
"permission_dialog_title": "[to be translated]:Screen Recording Permission Required",
"permission_granted_restart": "[to be translated]:Permission has been granted. Please restart the application for the changes to take effect.",
"tooltip": "[to be translated]:Take screenshot"
},
"send": "Отправить", "send": "Отправить",
"settings": "Настройки", "settings": "Настройки",
"slash_commands": { "slash_commands": {

View File

@ -188,25 +188,21 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se
}, [isGenerateImageSupported, isVisionSupported]) }, [isGenerateImageSupported, isVisionSupported])
const supportedExts = useMemo(() => { const supportedExts = useMemo(() => {
if (canAddImageFile && canAddTextFile) { // Always allow images so screenshot files can be attached regardless of model caps
return [...imageExts, ...documentExts, ...textExts] const image = imageExts
} const text = canAddTextFile ? [...documentExts, ...textExts] : []
return [...image, ...text]
if (canAddImageFile) { }, [canAddTextFile])
return [...imageExts]
}
if (canAddTextFile) {
return [...documentExts, ...textExts]
}
return []
}, [canAddImageFile, canAddTextFile])
useEffect(() => { useEffect(() => {
setCouldAddImageFile(canAddImageFile) setCouldAddImageFile(canAddImageFile)
}, [canAddImageFile, setCouldAddImageFile]) }, [canAddImageFile, setCouldAddImageFile])
// Ensure screenshot files (images) are allowed
useEffect(() => {
setCouldAddImageFile(true)
}, [setCouldAddImageFile])
const onUnmount = useEffectEvent((id: string) => { const onUnmount = useEffectEvent((id: string) => {
CacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL) CacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL)
}) })

View File

@ -14,6 +14,7 @@ import './generateImageTool'
import './clearTopicTool' import './clearTopicTool'
import './toggleExpandTool' import './toggleExpandTool'
import './newContextTool' import './newContextTool'
import './screenshotTool'
// Agent Session tools // Agent Session tools
import './createSessionTool' import './createSessionTool'
import './slashCommandsTool' import './slashCommandsTool'

View File

@ -0,0 +1,160 @@
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
import type { FileType } from '@renderer/types'
import { FileTypes } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Dropdown } from 'antd'
import { Camera, ChevronDown } from 'lucide-react'
import { useCallback, useState } from 'react'
const logger = loggerService.withContext('ScreenshotTool')
const ScreenshotTool = ({ context }) => {
const { actions, t } = context
const [isCapturing, setIsCapturing] = useState(false)
const showPermissionDialog = useCallback(
(needsRestart: boolean = false) => {
const content = needsRestart
? (t('chat.input.screenshot.permission_granted_restart') ??
'Permission has been granted. Please restart the application for the changes to take effect.')
: (t('chat.input.screenshot.permission_dialog_content') ??
'Screenshot feature requires screen recording permission. Would you like to open system settings to grant permission?')
window.modal.confirm({
title: t('chat.input.screenshot.permission_dialog_title') ?? 'Screen Recording Permission Required',
content,
centered: true,
okText: needsRestart ? (t('common.ok') ?? 'OK') : (t('chat.input.screenshot.open_settings') ?? 'Open Settings'),
cancelText: needsRestart ? undefined : (t('chat.input.screenshot.cancel') ?? 'Cancel'),
onOk: () => {
if (!needsRestart && window.electron.process.platform === 'darwin') {
void window.api.shell.openExternal(
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
)
}
}
})
},
[t]
)
const handleCapture = useCallback(async () => {
if (isCapturing) return
setIsCapturing(true)
try {
// Directly try to capture - this will trigger system permission dialog on first use
const fileName = `screenshot_${Date.now()}.png`
const result = await window.api.screenshot.capture(fileName)
if (!result.success) {
// Handle permission errors
if (result.status === 'denied') {
if (result.needsRestart) {
showPermissionDialog(true)
} else {
showPermissionDialog(false)
}
} else {
logger.error('Screenshot capture failed', new Error(result.message))
window.toast?.error(t('chat.input.screenshot.capture_failed') ?? 'Failed to capture screenshot')
}
return
}
// Normalize to FileType
const nextFile: FileType = {
...result.file,
type: FileTypes.IMAGE
}
actions.setFiles((prev) => [...prev, nextFile])
} catch (error: any) {
logger.error('Screenshot capture failed', error as Error)
window.toast?.error(t('chat.input.screenshot.capture_failed') ?? 'Failed to capture screenshot')
} finally {
setIsCapturing(false)
}
}, [actions, isCapturing, showPermissionDialog, t])
const handleCaptureWithSelection = useCallback(async () => {
if (isCapturing) return
setIsCapturing(true)
try {
const fileName = `screenshot_${Date.now()}.png`
const result = await window.api.screenshot.captureWithSelection(fileName)
if (!result.success) {
// Handle different status types
if (result.status === 'denied') {
if (result.needsRestart) {
showPermissionDialog(true)
} else {
showPermissionDialog(false)
}
} else if (result.status === 'cancelled') {
logger.info('User cancelled screenshot selection')
// No toast for cancelled - user intentionally cancelled
} else {
logger.error('Screenshot selection failed', new Error(result.message))
window.toast?.error(t('chat.input.screenshot.capture_failed') ?? 'Failed to capture screenshot')
}
return
}
// Normalize to FileType
const nextFile: FileType = {
...result.file,
type: FileTypes.IMAGE
}
actions.setFiles((prev) => [...prev, nextFile])
} catch (error: any) {
logger.error('Screenshot selection failed', error as Error)
window.toast?.error(t('chat.input.screenshot.capture_failed') ?? 'Failed to capture screenshot')
} finally {
setIsCapturing(false)
}
}, [actions, isCapturing, showPermissionDialog, t])
const menuItems: MenuProps['items'] = [
{
key: 'full',
label: t('chat.input.screenshot.full_screen') ?? 'Full Screen',
onClick: handleCapture
},
{
key: 'region',
label: t('chat.input.screenshot.select_region') ?? 'Select Region',
onClick: handleCaptureWithSelection
}
]
return (
<Dropdown menu={{ items: menuItems }} trigger={['click']} disabled={isCapturing}>
<ActionIconButton loading={isCapturing} disabled={isCapturing}>
<Camera size={16} />
<ChevronDown size={12} style={{ marginLeft: 2 }} />
</ActionIconButton>
</Dropdown>
)
}
const screenshotTool = defineTool({
key: 'screenshot',
label: (t) => t('chat.input.tools.screenshot') ?? 'Screenshot',
visibleInScopes: [TopicType.Chat, TopicType.Session, 'mini-window'],
dependencies: {
state: ['files'] as const,
actions: ['setFiles'] as const
},
render: (context) => <ScreenshotTool context={context} />
})
registerTool(screenshotTool)
export default screenshotTool

View File

@ -13,6 +13,7 @@ export const DEFAULT_TOOL_ORDER: ToolOrder = {
visible: [ visible: [
'new_topic', 'new_topic',
'attachment', 'attachment',
'screenshot',
'thinking', 'thinking',
'web_search', 'web_search',
'url_context', 'url_context',
@ -30,11 +31,11 @@ export const DEFAULT_TOOL_ORDER: ToolOrder = {
export const DEFAULT_TOOL_ORDER_BY_SCOPE: Record<InputbarScope, ToolOrder> = { export const DEFAULT_TOOL_ORDER_BY_SCOPE: Record<InputbarScope, ToolOrder> = {
[TopicType.Chat]: DEFAULT_TOOL_ORDER, [TopicType.Chat]: DEFAULT_TOOL_ORDER,
[TopicType.Session]: { [TopicType.Session]: {
visible: ['create_session', 'slash_commands', 'attachment'], visible: ['create_session', 'slash_commands', 'attachment', 'screenshot'],
hidden: [] hidden: []
}, },
'mini-window': { 'mini-window': {
visible: ['attachment', 'mention_models', 'quick_phrases'], visible: ['attachment', 'screenshot', 'mention_models', 'quick_phrases'],
hidden: [] hidden: []
} }
} }

View File

@ -14,6 +14,7 @@ export type InputBarToolType =
| 'clear_topic' | 'clear_topic'
| 'toggle_expand' | 'toggle_expand'
| 'new_context' | 'new_context'
| 'screenshot'
// Agent Session tools // Agent Session tools
| 'create_session' | 'create_session'
| 'slash_commands' | 'slash_commands'

View File

@ -0,0 +1,205 @@
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { useCallback, useEffect, useRef, useState } from 'react'
const logger = loggerService.withContext('ScreenshotSelection')
interface SelectionState {
startX: number
startY: number
endX: number
endY: number
isDragging: boolean
}
interface Rectangle {
x: number
y: number
width: number
height: number
}
const ScreenshotSelection = () => {
const [screenshotData, setScreenshotData] = useState<string | null>(null)
const [selection, setSelection] = useState<SelectionState>({
startX: 0,
startY: 0,
endX: 0,
endY: 0,
isDragging: false
})
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Listen for screenshot data from main process
useEffect(() => {
const handler = (_event: any, data: { screenshotData: string }) => {
logger.info('Received screenshot data')
setScreenshotData(data.screenshotData)
}
window.electron.ipcRenderer.on(IpcChannel.Screenshot_SelectionWindowReady, handler)
return () => {
window.electron.ipcRenderer.removeListener(IpcChannel.Screenshot_SelectionWindowReady, handler)
}
}, [])
// Draw screenshot on canvas when data is received
useEffect(() => {
if (!screenshotData || !canvasRef.current) return
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.onload = () => {
// Set canvas size to match window
canvas.width = window.innerWidth
canvas.height = window.innerHeight
// Draw the screenshot
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
logger.info('Screenshot drawn on canvas')
}
img.onerror = (e) => {
logger.error('Failed to load screenshot image', { error: e })
// Close the selection window gracefully on image load error
window.api.screenshot.cancelSelection()
}
img.src = screenshotData
}, [screenshotData])
const getSelectionRectangle = useCallback((): Rectangle | null => {
if (!selection.isDragging && selection.endX === 0 && selection.endY === 0) {
return null
}
const x = Math.min(selection.startX, selection.endX)
const y = Math.min(selection.startY, selection.endY)
const width = Math.abs(selection.endX - selection.startX)
const height = Math.abs(selection.endY - selection.startY)
return { x, y, width, height }
}, [selection])
// Handle keyboard events
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
logger.info('User cancelled selection with ESC')
window.api.screenshot.cancelSelection()
} else if (e.key === 'Enter') {
const rect = getSelectionRectangle()
if (rect && rect.width >= 10 && rect.height >= 10) {
logger.info('User confirmed selection with ENTER', rect)
window.api.screenshot.confirmSelection(rect)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [getSelectionRectangle])
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setSelection({
startX: x,
startY: y,
endX: x,
endY: y,
isDragging: true
})
}
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!selection.isDragging) return
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setSelection((prev) => ({
...prev,
endX: x,
endY: y
}))
}
const handleMouseUp = useCallback(() => {
if (!selection.isDragging) return
const rect = getSelectionRectangle()
if (rect && rect.width >= 10 && rect.height >= 10) {
logger.info('Selection completed', rect)
window.api.screenshot.confirmSelection(rect)
} else {
// Selection too small, reset
setSelection((prev) => ({ ...prev, isDragging: false }))
}
}, [selection, getSelectionRectangle])
const rect = getSelectionRectangle()
const hasValidSelection = rect && rect.width > 0 && rect.height > 0
return (
<div
ref={containerRef}
className="fixed inset-0 h-full w-full overflow-hidden"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: 'crosshair' }}>
{/* Canvas with screenshot */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
{/* Dark overlay */}
<div className="pointer-events-none absolute inset-0 bg-black bg-opacity-40">
{/* Clear area for selection */}
{hasValidSelection && rect && (
<div
className="pointer-events-none absolute"
style={{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
backgroundColor: 'transparent',
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.4)',
border: '2px solid #1890ff'
}}
/>
)}
</div>
{/* Dimension display */}
{hasValidSelection && rect && (
<div
className="pointer-events-none absolute rounded bg-black bg-opacity-75 px-2 py-1 text-sm text-white"
style={{
left: `${rect.x + rect.width / 2}px`,
top: `${rect.y - 30}px`,
transform: 'translateX(-50%)'
}}>
{Math.round(rect.width)} × {Math.round(rect.height)}
</div>
)}
{/* Control hints */}
<div className="-translate-x-1/2 pointer-events-none absolute bottom-4 left-1/2 transform rounded bg-black bg-opacity-75 px-4 py-2 text-white">
ESC to cancel | Drag to select | ENTER to confirm
</div>
</div>
)
}
export default ScreenshotSelection

View File

@ -0,0 +1,18 @@
import '@renderer/assets/styles/index.css'
import '@renderer/assets/styles/tailwind.css'
import '@ant-design/v5-patch-for-react-19'
import { loggerService } from '@logger'
import { ThemeProvider } from '@renderer/context/ThemeProvider'
import { createRoot } from 'react-dom/client'
import ScreenshotSelection from './ScreenshotSelection'
loggerService.initWindowSource('ScreenshotSelection')
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(
<ThemeProvider>
<ScreenshotSelection />
</ThemeProvider>
)

View File

@ -10278,6 +10278,7 @@ __metadata:
mime: "npm:^4.0.4" mime: "npm:^4.0.4"
mime-types: "npm:^3.0.1" mime-types: "npm:^3.0.1"
motion: "npm:^12.10.5" motion: "npm:^12.10.5"
node-screenshots: "npm:0.2.1"
node-stream-zip: "npm:^1.15.0" node-stream-zip: "npm:^1.15.0"
notion-helper: "npm:^1.3.22" notion-helper: "npm:^1.3.22"
npx-scope-finder: "npm:^1.2.0" npx-scope-finder: "npm:^1.2.0"
@ -19871,6 +19872,95 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-screenshots-darwin-arm64@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-darwin-arm64@npm:0.2.1"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"node-screenshots-darwin-universal@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-darwin-universal@npm:0.2.1"
conditions: os=darwin
languageName: node
linkType: hard
"node-screenshots-darwin-x64@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-darwin-x64@npm:0.2.1"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"node-screenshots-linux-x64-gnu@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-linux-x64-gnu@npm:0.2.1"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"node-screenshots-linux-x64-musl@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-linux-x64-musl@npm:0.2.1"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"node-screenshots-win32-arm64-msvc@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-win32-arm64-msvc@npm:0.2.1"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"node-screenshots-win32-ia32-msvc@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-win32-ia32-msvc@npm:0.2.1"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"node-screenshots-win32-x64-msvc@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots-win32-x64-msvc@npm:0.2.1"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"node-screenshots@npm:0.2.1":
version: 0.2.1
resolution: "node-screenshots@npm:0.2.1"
dependencies:
node-screenshots-darwin-arm64: "npm:0.2.1"
node-screenshots-darwin-universal: "npm:0.2.1"
node-screenshots-darwin-x64: "npm:0.2.1"
node-screenshots-linux-x64-gnu: "npm:0.2.1"
node-screenshots-linux-x64-musl: "npm:0.2.1"
node-screenshots-win32-arm64-msvc: "npm:0.2.1"
node-screenshots-win32-ia32-msvc: "npm:0.2.1"
node-screenshots-win32-x64-msvc: "npm:0.2.1"
dependenciesMeta:
node-screenshots-darwin-arm64:
optional: true
node-screenshots-darwin-universal:
optional: true
node-screenshots-darwin-x64:
optional: true
node-screenshots-linux-x64-gnu:
optional: true
node-screenshots-linux-x64-musl:
optional: true
node-screenshots-win32-arm64-msvc:
optional: true
node-screenshots-win32-ia32-msvc:
optional: true
node-screenshots-win32-x64-msvc:
optional: true
checksum: 10c0/f1c011dbd104e62776292d2efd1fcdc98bc75282d389e0e6fd057e80dc32fcfbaa1e217a8e55c7c329cba1a246f82501733db2cdd79e6d5cb0e05d4219e02edf
languageName: node
linkType: hard
"node-stream-zip@npm:^1.15.0": "node-stream-zip@npm:^1.15.0":
version: 1.15.0 version: 1.15.0
resolution: "node-stream-zip@npm:1.15.0" resolution: "node-stream-zip@npm:1.15.0"