feat(desktop): add Electron desktop pet for Claude CLI
- Electron + React + TypeScript 桌面宠物外壳 - xterm.js + node-pty 直接对接 Claude CLI 进程 - 宠物 orb(80x80)可拖拽,点击展开终端面板 - CRT 复古终端主题(扫描线、文字发光) - 全局快捷键 Cmd+Shift+C 显隐 - 窗口位置自动保存 - 中华田园犬吉祥物 icon(AI 生成) - Makefile 新增 `make app` 打包命令
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { app } from 'electron'
|
||||
|
||||
interface AppConfig {
|
||||
windowPosition?: { x: number; y: number }
|
||||
autoStart?: boolean
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
autoStart: false,
|
||||
collapsed: true,
|
||||
}
|
||||
|
||||
function getConfigPath(): string {
|
||||
return path.join(app.getPath('userData'), 'config.json')
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = fs.readFileSync(configPath, 'utf8')
|
||||
return { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err)
|
||||
}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
console.error('Failed to save config:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { app, BrowserWindow, ipcMain, screen, globalShortcut } from 'electron'
|
||||
import * as path from 'path'
|
||||
import { PTYManager } from './pty'
|
||||
import { loadConfig, saveConfig } from './config'
|
||||
|
||||
const ptyManager = new PTYManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
const COLLAPSED_SIZE = { width: 80, height: 80 }
|
||||
const EXPANDED_SIZE = { width: 900, height: 600 }
|
||||
|
||||
function createWindow() {
|
||||
const config = loadConfig()
|
||||
const display = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = display.workAreaSize
|
||||
|
||||
// Use saved position or default to bottom-right
|
||||
let x = config.windowPosition?.x ?? screenWidth - COLLAPSED_SIZE.width - 20
|
||||
let y = config.windowPosition?.y ?? screenHeight - COLLAPSED_SIZE.height - 20
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: COLLAPSED_SIZE.width,
|
||||
height: COLLAPSED_SIZE.height,
|
||||
x,
|
||||
y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
resizable: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Dock hide on macOS
|
||||
if (app.dock) {
|
||||
app.dock.hide()
|
||||
}
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
// Save position when moved
|
||||
mainWindow.on('moved', () => {
|
||||
if (!mainWindow) return
|
||||
const [x, y] = mainWindow.getPosition()
|
||||
const config = loadConfig()
|
||||
saveConfig({ ...config, windowPosition: { x, y } })
|
||||
})
|
||||
}
|
||||
|
||||
function expandWindow() {
|
||||
if (!mainWindow) return
|
||||
const display = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = display.workAreaSize
|
||||
|
||||
const currentPos = mainWindow.getPosition()
|
||||
let x = currentPos[0]
|
||||
let y = currentPos[1]
|
||||
|
||||
// Ensure window stays on screen
|
||||
if (x + EXPANDED_SIZE.width > screenWidth) {
|
||||
x = screenWidth - EXPANDED_SIZE.width - 20
|
||||
}
|
||||
if (y + EXPANDED_SIZE.height > screenHeight) {
|
||||
y = screenHeight - EXPANDED_SIZE.height - 20
|
||||
}
|
||||
|
||||
mainWindow.setResizable(true)
|
||||
mainWindow.setMinimumSize(EXPANDED_SIZE.width, EXPANDED_SIZE.height)
|
||||
mainWindow.setSize(EXPANDED_SIZE.width, EXPANDED_SIZE.height)
|
||||
mainWindow.setPosition(x, y)
|
||||
mainWindow.setAlwaysOnTop(true)
|
||||
mainWindow.setIgnoreMouseEvents(false)
|
||||
mainWindow.setHasShadow(true)
|
||||
|
||||
const config = loadConfig()
|
||||
saveConfig({ ...config, collapsed: false })
|
||||
}
|
||||
|
||||
function collapseWindow() {
|
||||
if (!mainWindow) return
|
||||
|
||||
const currentPos = mainWindow.getPosition()
|
||||
|
||||
mainWindow.setResizable(false)
|
||||
mainWindow.setSize(COLLAPSED_SIZE.width, COLLAPSED_SIZE.height)
|
||||
mainWindow.setPosition(currentPos[0], currentPos[1])
|
||||
mainWindow.setAlwaysOnTop(true)
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
mainWindow.setHasShadow(false)
|
||||
|
||||
const config = loadConfig()
|
||||
saveConfig({ ...config, collapsed: true, windowPosition: { x: currentPos[0], y: currentPos[1] } })
|
||||
}
|
||||
|
||||
function toggleWindow() {
|
||||
if (!mainWindow) {
|
||||
createWindow()
|
||||
return
|
||||
}
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
ptyManager.start('claude')
|
||||
|
||||
// Collapse after load to ensure pet mode
|
||||
mainWindow?.webContents.on('did-finish-load', () => {
|
||||
collapseWindow()
|
||||
})
|
||||
|
||||
// Register global shortcut
|
||||
const ret = globalShortcut.register('CommandOrControl+Shift+C', () => {
|
||||
if (!mainWindow) {
|
||||
createWindow()
|
||||
return
|
||||
}
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
if (!ret) {
|
||||
console.log('Global shortcut registration failed')
|
||||
}
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.on('pty:write', (_event, data: string) => {
|
||||
ptyManager.write(data)
|
||||
})
|
||||
|
||||
ipcMain.on('pty:resize', (_event, cols: number, rows: number) => {
|
||||
ptyManager.resize(cols, rows)
|
||||
})
|
||||
|
||||
ipcMain.on('window:expand', () => {
|
||||
expandWindow()
|
||||
})
|
||||
|
||||
ipcMain.on('window:collapse', () => {
|
||||
collapseWindow()
|
||||
})
|
||||
|
||||
ipcMain.on('window:set-position', (_event, x: number, y: number) => {
|
||||
mainWindow?.setPosition(x, y)
|
||||
})
|
||||
|
||||
ipcMain.on('window:set-ignore-mouse', (_event, ignore: boolean) => {
|
||||
mainWindow?.setIgnoreMouseEvents(ignore, { forward: true })
|
||||
})
|
||||
|
||||
ipcMain.on('window:close', () => {
|
||||
mainWindow?.close()
|
||||
})
|
||||
|
||||
ipcMain.on('window:minimize', () => {
|
||||
mainWindow?.minimize()
|
||||
})
|
||||
|
||||
ptyManager.on('data', (data: string) => {
|
||||
mainWindow?.webContents.send('pty:data', data)
|
||||
})
|
||||
|
||||
ptyManager.on('exit', (code: number) => {
|
||||
mainWindow?.webContents.send('pty:exit', code)
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
ptyManager.kill()
|
||||
globalShortcut.unregisterAll()
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
ptyManager.kill()
|
||||
globalShortcut.unregisterAll()
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll()
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
const api = {
|
||||
pty: {
|
||||
write: (data) => ipcRenderer.send('pty:write', data),
|
||||
resize: (cols, rows) => ipcRenderer.send('pty:resize', cols, rows),
|
||||
onData: (callback) => {
|
||||
ipcRenderer.on('pty:data', (_event, data) => callback(data))
|
||||
},
|
||||
onExit: (callback) => {
|
||||
ipcRenderer.on('pty:exit', (_event, code) => callback(code))
|
||||
},
|
||||
},
|
||||
window: {
|
||||
expand: () => ipcRenderer.send('window:expand'),
|
||||
collapse: () => ipcRenderer.send('window:collapse'),
|
||||
setPosition: (x, y) => ipcRenderer.send('window:set-position', x, y),
|
||||
setIgnoreMouseEvents: (ignore) => ipcRenderer.send('window:set-ignore-mouse', ignore),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('petAPI', api)
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as pty from 'node-pty'
|
||||
import { execSync } from 'child_process'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
export class PTYManager extends EventEmitter {
|
||||
private ptyProcess: pty.IPty | null = null
|
||||
|
||||
start(shell: string = 'claude', args: string[] = []) {
|
||||
const resolvedShell = this.resolveShell(shell)
|
||||
const resolvedArgs = shell === 'claude' ? args : []
|
||||
|
||||
this.ptyProcess = pty.spawn(resolvedShell, resolvedArgs, {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME,
|
||||
env: process.env as { [key: string]: string },
|
||||
})
|
||||
|
||||
this.ptyProcess.onData((data: string) => {
|
||||
this.emit('data', data)
|
||||
})
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
this.emit('exit', exitCode)
|
||||
})
|
||||
|
||||
return this.ptyProcess
|
||||
}
|
||||
|
||||
write(data: string) {
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number) {
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.resize(cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
kill() {
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.kill()
|
||||
this.ptyProcess = null
|
||||
}
|
||||
}
|
||||
|
||||
private resolveShell(shell: string): string {
|
||||
if (shell === 'claude') {
|
||||
try {
|
||||
const path = execSync('which claude', { encoding: 'utf8' }).trim()
|
||||
return path
|
||||
} catch {
|
||||
try {
|
||||
return execSync('which zsh', { encoding: 'utf8' }).trim()
|
||||
} catch {
|
||||
return '/bin/zsh'
|
||||
}
|
||||
}
|
||||
}
|
||||
return shell
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user