mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
The Windows shape clamp used the nominal PET_WINDOW_HEIGHT constant. Renderer regions are measured against the live viewport, so once the content area was taller than that constant the mascot -- which sits flush with the viewport bottom -- got sliced off from below, while the task badge kept its own unclamped rect and stayed visible. Clamp the shape to the real content box. Dragging also moved the window with setPosition, which Windows resolves as getSize() + setBounds(); that DIP round trip grows the window a pixel at a time on fractional display scaling, and the drag timer fires every 16ms. Restate the recorded size on every tick so dragging stays size-neutral.
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
import type {
|
|
BrowserWindow,
|
|
BrowserWindowConstructorOptions,
|
|
Menu,
|
|
MenuItemConstructorOptions,
|
|
Point,
|
|
Rectangle,
|
|
} from 'electron'
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
export const PET_WINDOW_WIDTH = 384
|
|
export const PET_WINDOW_HEIGHT = 400
|
|
export const PET_WINDOW_MARGIN = 24
|
|
export const PET_WINDOW_PARTITION = 'cc-haha-pet'
|
|
export const PET_WINDOW_STATE_FILE = 'pet-window.json'
|
|
|
|
const MAX_ABSOLUTE_SCREEN_COORDINATE = 1_000_000
|
|
const PET_WINDOW_DRAG_INTERVAL_MS = 16
|
|
const PET_WINDOW_SHAPE_PADDING = 12
|
|
const failedPetWindowStateWritePaths = new Set<string>()
|
|
|
|
type PetWindow = BrowserWindow
|
|
|
|
export type PetContextMenuFactory = {
|
|
buildFromTemplate(template: MenuItemConstructorOptions[]): Pick<Menu, 'popup'>
|
|
}
|
|
|
|
export type PetWindowPosition = Pick<Point, 'x' | 'y'>
|
|
|
|
export type PetWindowDragPayload = PetWindowPosition & {
|
|
phase: 'start' | 'move' | 'end'
|
|
}
|
|
|
|
function isFiniteScreenCoordinate(value: unknown): value is number {
|
|
return typeof value === 'number'
|
|
&& Number.isFinite(value)
|
|
&& Math.abs(value) <= MAX_ABSOLUTE_SCREEN_COORDINATE
|
|
}
|
|
|
|
function isPetWindowPosition(value: unknown): value is PetWindowPosition {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
const record = value as Record<string, unknown>
|
|
return isFiniteScreenCoordinate(record.x) && isFiniteScreenCoordinate(record.y)
|
|
}
|
|
|
|
function resolveHomePath(input: string, homeDir: string): string {
|
|
if (input === '~') return homeDir
|
|
if (input.startsWith(`~${path.sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
|
|
return path.join(homeDir, input.slice(2))
|
|
}
|
|
return input
|
|
}
|
|
|
|
export function petWindowStatePath(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homeDir: string = os.homedir(),
|
|
): string {
|
|
const normalizedHome = path.resolve(homeDir)
|
|
const configuredRoot = env.CLAUDE_CONFIG_DIR?.trim()
|
|
const configRoot = configuredRoot
|
|
? path.resolve(resolveHomePath(configuredRoot, normalizedHome))
|
|
: path.join(normalizedHome, '.claude')
|
|
return path.join(configRoot, 'cc-haha', PET_WINDOW_STATE_FILE)
|
|
}
|
|
|
|
export function readPetWindowPosition(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homeDir: string = os.homedir(),
|
|
): PetWindowPosition | null {
|
|
const statePath = petWindowStatePath(env, homeDir)
|
|
if (!existsSync(statePath)) return null
|
|
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as unknown
|
|
return isPetWindowPosition(parsed)
|
|
? { x: Math.round(parsed.x), y: Math.round(parsed.y) }
|
|
: null
|
|
} catch (error) {
|
|
console.error(`[desktop] failed to read pet window state ${statePath}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function writePetWindowPosition(
|
|
position: PetWindowPosition,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homeDir: string = os.homedir(),
|
|
): void {
|
|
if (!isPetWindowPosition(position)) return
|
|
const statePath = petWindowStatePath(env, homeDir)
|
|
const temporaryPath = `${statePath}.${process.pid}.tmp`
|
|
try {
|
|
mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 })
|
|
writeFileSync(temporaryPath, `${JSON.stringify({
|
|
x: Math.round(position.x),
|
|
y: Math.round(position.y),
|
|
}, null, 2)}\n`, { mode: 0o600 })
|
|
renameSync(temporaryPath, statePath)
|
|
failedPetWindowStateWritePaths.delete(statePath)
|
|
} catch (error) {
|
|
rmSync(temporaryPath, { force: true })
|
|
if (!failedPetWindowStateWritePaths.has(statePath)) {
|
|
failedPetWindowStateWritePaths.add(statePath)
|
|
console.error(`[desktop] failed to write pet window state ${statePath}:`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clampPetWindowPosition(
|
|
position: PetWindowPosition,
|
|
workArea: Rectangle,
|
|
visibleRegion: Rectangle = {
|
|
x: 0,
|
|
y: 0,
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
},
|
|
): PetWindowPosition {
|
|
const minX = workArea.x - visibleRegion.x
|
|
const minY = workArea.y - visibleRegion.y
|
|
const maxX = minX + Math.max(0, workArea.width - visibleRegion.width)
|
|
const maxY = minY + Math.max(0, workArea.height - visibleRegion.height)
|
|
return {
|
|
x: Math.min(Math.max(Math.round(position.x), minX), maxX),
|
|
y: Math.min(Math.max(Math.round(position.y), minY), maxY),
|
|
}
|
|
}
|
|
|
|
type PetWindowExtent = { width: number; height: number }
|
|
|
|
// Renderer regions are measured against the live viewport, so the shape has to
|
|
// be clamped to the real content box. Clamping to the nominal constants slices
|
|
// off whatever sits below it once the content area is taller than expected.
|
|
function petWindowContentExtent(window: PetWindow): PetWindowExtent {
|
|
const contentBounds = window.getContentBounds?.()
|
|
const width = contentBounds?.width
|
|
const height = contentBounds?.height
|
|
return {
|
|
width: typeof width === 'number' && width > 0 ? Math.round(width) : PET_WINDOW_WIDTH,
|
|
height: typeof height === 'number' && height > 0 ? Math.round(height) : PET_WINDOW_HEIGHT,
|
|
}
|
|
}
|
|
|
|
function normalizePetWindowRegion(region: Rectangle): Rectangle {
|
|
const x = Math.max(0, Math.min(PET_WINDOW_WIDTH - 1, Math.round(region.x)))
|
|
const y = Math.max(0, Math.min(PET_WINDOW_HEIGHT - 1, Math.round(region.y)))
|
|
const right = Math.max(x + 1, Math.min(PET_WINDOW_WIDTH, Math.round(region.x + region.width)))
|
|
const bottom = Math.max(y + 1, Math.min(PET_WINDOW_HEIGHT, Math.round(region.y + region.height)))
|
|
return { x, y, width: right - x, height: bottom - y }
|
|
}
|
|
|
|
export function getPetWindowBounds(
|
|
workArea: Rectangle,
|
|
restoredPosition?: PetWindowPosition | null,
|
|
): Rectangle {
|
|
if (restoredPosition) {
|
|
return {
|
|
...clampPetWindowPosition(restoredPosition, workArea),
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
}
|
|
}
|
|
return {
|
|
x: Math.max(
|
|
workArea.x,
|
|
workArea.x + workArea.width - PET_WINDOW_WIDTH - PET_WINDOW_MARGIN,
|
|
),
|
|
y: Math.max(
|
|
workArea.y,
|
|
workArea.y + workArea.height - PET_WINDOW_HEIGHT - PET_WINDOW_MARGIN,
|
|
),
|
|
width: PET_WINDOW_WIDTH,
|
|
height: PET_WINDOW_HEIGHT,
|
|
}
|
|
}
|
|
|
|
export function petWindowOptions(
|
|
bounds: Rectangle,
|
|
preload: string,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): BrowserWindowConstructorOptions {
|
|
return {
|
|
...bounds,
|
|
alwaysOnTop: true,
|
|
autoHideMenuBar: true,
|
|
backgroundColor: '#00000000',
|
|
frame: false,
|
|
fullscreenable: false,
|
|
hasShadow: false,
|
|
maximizable: false,
|
|
minimizable: false,
|
|
resizable: false,
|
|
show: false,
|
|
...(platform === 'darwin' ? {} : { skipTaskbar: true }),
|
|
transparent: true,
|
|
type: platform === 'darwin' ? 'panel' : undefined,
|
|
webPreferences: {
|
|
preload,
|
|
partition: PET_WINDOW_PARTITION,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
function configurePetWindow(window: PetWindow, platform: NodeJS.Platform): void {
|
|
if (platform === 'darwin') {
|
|
window.setIgnoreMouseEvents(true, { forward: true })
|
|
} else {
|
|
window.setIgnoreMouseEvents(false)
|
|
window.setShape([{ x: 0, y: 0, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }])
|
|
}
|
|
if (platform !== 'darwin') return
|
|
|
|
window.setAlwaysOnTop(true, 'floating')
|
|
window.setVisibleOnAllWorkspaces(true, {
|
|
skipTransformProcessType: true,
|
|
visibleOnFullScreen: true,
|
|
})
|
|
}
|
|
|
|
export type PetWindowControllerOptions = {
|
|
createWindow(options: BrowserWindowConstructorOptions): PetWindow
|
|
getCursorScreenPoint?(): Point
|
|
getCurrentWorkArea(): Rectangle
|
|
getWorkAreaForPoint?(point: Point): Rectangle
|
|
load(window: PetWindow): Promise<void>
|
|
onCreated?(window: PetWindow): void
|
|
platform?: NodeJS.Platform
|
|
preloadPath: string
|
|
readPosition?(): PetWindowPosition | null
|
|
writePosition?(position: PetWindowPosition): void
|
|
}
|
|
|
|
export class PetWindowController {
|
|
private window: PetWindow | null = null
|
|
private creating: Promise<PetWindow> | null = null
|
|
private drag: {
|
|
window: PetWindow
|
|
pointerStart: PetWindowPosition
|
|
windowStart: PetWindowPosition
|
|
size: PetWindowExtent
|
|
lastPosition: PetWindowPosition
|
|
} | null = null
|
|
private dragTimer: ReturnType<typeof setInterval> | null = null
|
|
private visibleDragRegion: Rectangle | null = null
|
|
private pendingRestoredPosition: PetWindowPosition | null = null
|
|
private readonly options: PetWindowControllerOptions
|
|
|
|
constructor(options: PetWindowControllerOptions) {
|
|
this.options = options
|
|
}
|
|
|
|
private async create(): Promise<PetWindow> {
|
|
const restoredPosition = this.options.readPosition?.() ?? null
|
|
this.visibleDragRegion = null
|
|
this.pendingRestoredPosition = restoredPosition
|
|
const currentWorkArea = restoredPosition && this.options.getWorkAreaForPoint
|
|
? this.options.getWorkAreaForPoint({
|
|
x: restoredPosition.x + Math.floor(PET_WINDOW_WIDTH / 2),
|
|
y: restoredPosition.y + Math.floor(PET_WINDOW_HEIGHT / 2),
|
|
})
|
|
: this.options.getCurrentWorkArea()
|
|
const window = this.options.createWindow(petWindowOptions(
|
|
getPetWindowBounds(currentWorkArea, restoredPosition),
|
|
this.options.preloadPath,
|
|
this.options.platform,
|
|
))
|
|
this.window = window
|
|
window.on('closed', () => {
|
|
this.finishDrag(window)
|
|
if (this.window === window) {
|
|
this.window = null
|
|
this.visibleDragRegion = null
|
|
this.pendingRestoredPosition = null
|
|
}
|
|
})
|
|
|
|
try {
|
|
configurePetWindow(window, this.options.platform ?? process.platform)
|
|
this.options.onCreated?.(window)
|
|
await this.options.load(window)
|
|
return window
|
|
} catch (error) {
|
|
if (!window.isDestroyed()) window.destroy()
|
|
if (this.window === window) {
|
|
this.window = null
|
|
this.visibleDragRegion = null
|
|
this.pendingRestoredPosition = null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private ensureWindow(): Promise<PetWindow> {
|
|
if (this.window && !this.window.isDestroyed()) {
|
|
return Promise.resolve(this.window)
|
|
}
|
|
if (this.creating) return this.creating
|
|
|
|
const creating = this.create()
|
|
this.creating = creating
|
|
void creating.finally(() => {
|
|
if (this.creating === creating) this.creating = null
|
|
}).catch(() => undefined)
|
|
return creating
|
|
}
|
|
|
|
async show(): Promise<void> {
|
|
const window = await this.ensureWindow()
|
|
if (!window.isVisible()) {
|
|
if ((this.options.platform ?? process.platform) === 'darwin') {
|
|
window.setIgnoreMouseEvents(true, { forward: true })
|
|
}
|
|
window.showInactive()
|
|
if ((this.options.platform ?? process.platform) === 'darwin') {
|
|
window.setAlwaysOnTop(true, 'floating')
|
|
} else {
|
|
window.setAlwaysOnTop(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
hide(): void {
|
|
const window = this.window
|
|
this.finishDrag(window ?? undefined)
|
|
if (!window || window.isDestroyed()) {
|
|
this.window = null
|
|
return
|
|
}
|
|
window.destroy()
|
|
this.window = null
|
|
this.visibleDragRegion = null
|
|
this.pendingRestoredPosition = null
|
|
}
|
|
|
|
owns(window: PetWindow | null): boolean {
|
|
return window !== null && this.window === window && !window.isDestroyed()
|
|
}
|
|
|
|
setIgnoreMouseEvents(window: PetWindow, ignore: boolean): void {
|
|
if (!this.owns(window)) {
|
|
throw new Error('Pet window IPC sender does not own the companion window')
|
|
}
|
|
if ((this.options.platform ?? process.platform) !== 'darwin') return
|
|
window.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined)
|
|
}
|
|
|
|
setInteractiveRegions(window: PetWindow, regions: Rectangle[]): void {
|
|
if (!this.owns(window)) {
|
|
throw new Error('Pet window IPC sender does not own the companion window')
|
|
}
|
|
const platform = this.options.platform ?? process.platform
|
|
const primaryRegion = regions[0]
|
|
if (platform === 'darwin' && primaryRegion) {
|
|
this.visibleDragRegion = normalizePetWindowRegion(primaryRegion)
|
|
const requestedPosition = this.pendingRestoredPosition ?? window.getBounds()
|
|
this.pendingRestoredPosition = null
|
|
const anchor = {
|
|
x: requestedPosition.x + this.visibleDragRegion.x + Math.floor(this.visibleDragRegion.width / 2),
|
|
y: requestedPosition.y + this.visibleDragRegion.y + Math.floor(this.visibleDragRegion.height / 2),
|
|
}
|
|
const workArea = this.options.getWorkAreaForPoint?.(anchor)
|
|
?? this.options.getCurrentWorkArea()
|
|
const nextPosition = clampPetWindowPosition(
|
|
requestedPosition,
|
|
workArea,
|
|
this.visibleDragRegion,
|
|
)
|
|
const bounds = window.getBounds()
|
|
if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) {
|
|
window.setPosition(nextPosition.x, nextPosition.y, false)
|
|
}
|
|
}
|
|
|
|
if (platform === 'darwin') return
|
|
|
|
const extent = petWindowContentExtent(window)
|
|
const shape = regions.flatMap((region) => {
|
|
const requestedLeft = Math.round(region.x) - PET_WINDOW_SHAPE_PADDING
|
|
const requestedTop = Math.round(region.y) - PET_WINDOW_SHAPE_PADDING
|
|
const requestedRight = Math.round(region.x + region.width) + PET_WINDOW_SHAPE_PADDING
|
|
const requestedBottom = Math.round(region.y + region.height) + PET_WINDOW_SHAPE_PADDING
|
|
const x = Math.max(0, Math.min(extent.width - 1, requestedLeft))
|
|
const y = Math.max(0, Math.min(extent.height - 1, requestedTop))
|
|
const right = Math.max(x + 1, Math.min(extent.width, requestedRight))
|
|
const bottom = Math.max(y + 1, Math.min(extent.height, requestedBottom))
|
|
return [{ x, y, width: right - x, height: bottom - y }]
|
|
})
|
|
if (shape.length > 0) window.setShape(shape)
|
|
}
|
|
|
|
dragWindow(window: PetWindow, payload: PetWindowDragPayload): void {
|
|
if (!this.owns(window)) {
|
|
throw new Error('Pet window IPC sender does not own the companion window')
|
|
}
|
|
if (!isFiniteScreenCoordinate(payload.x) || !isFiniteScreenCoordinate(payload.y)) {
|
|
throw new Error('Pet window drag coordinates must be finite screen coordinates')
|
|
}
|
|
|
|
if (payload.phase === 'start') {
|
|
this.finishDrag()
|
|
const bounds = window.getBounds()
|
|
const sampledPointer = this.options.getCursorScreenPoint?.()
|
|
const pointerStart = sampledPointer && isPetWindowPosition(sampledPointer)
|
|
? sampledPointer
|
|
: payload
|
|
this.drag = {
|
|
window,
|
|
pointerStart: { x: pointerStart.x, y: pointerStart.y },
|
|
windowStart: { x: bounds.x, y: bounds.y },
|
|
size: { width: bounds.width, height: bounds.height },
|
|
lastPosition: { x: bounds.x, y: bounds.y },
|
|
}
|
|
if (this.options.getCursorScreenPoint) {
|
|
this.dragTimer = setInterval(() => this.sampleDragPosition(), PET_WINDOW_DRAG_INTERVAL_MS)
|
|
}
|
|
return
|
|
}
|
|
|
|
const drag = this.drag
|
|
if (!drag || drag.window !== window) {
|
|
throw new Error('Pet window drag has not started')
|
|
}
|
|
|
|
const payloadPosition = { x: payload.x, y: payload.y }
|
|
const cursorPosition = payload.phase === 'end'
|
|
? this.readCursorScreenPoint() ?? payloadPosition
|
|
: payloadPosition
|
|
this.updateDragPosition(drag, cursorPosition)
|
|
|
|
if (payload.phase === 'end') {
|
|
this.finishDrag(window)
|
|
}
|
|
}
|
|
|
|
private readCursorScreenPoint(): PetWindowPosition | null {
|
|
const point = this.options.getCursorScreenPoint?.()
|
|
return point && isPetWindowPosition(point)
|
|
? { x: point.x, y: point.y }
|
|
: null
|
|
}
|
|
|
|
private sampleDragPosition(): void {
|
|
const drag = this.drag
|
|
if (!drag || drag.window.isDestroyed()) {
|
|
this.finishDrag(drag?.window)
|
|
return
|
|
}
|
|
const point = this.readCursorScreenPoint()
|
|
if (point) this.updateDragPosition(drag, point)
|
|
}
|
|
|
|
private updateDragPosition(
|
|
drag: NonNullable<PetWindowController['drag']>,
|
|
pointer: PetWindowPosition,
|
|
): void {
|
|
const requestedPosition = {
|
|
x: drag.windowStart.x + pointer.x - drag.pointerStart.x,
|
|
y: drag.windowStart.y + pointer.y - drag.pointerStart.y,
|
|
}
|
|
const workArea = this.options.getWorkAreaForPoint?.(pointer)
|
|
?? this.options.getCurrentWorkArea()
|
|
const nextPosition = clampPetWindowPosition(
|
|
requestedPosition,
|
|
workArea,
|
|
this.visibleDragRegion ?? undefined,
|
|
)
|
|
if (
|
|
nextPosition.x === drag.lastPosition.x
|
|
&& nextPosition.y === drag.lastPosition.y
|
|
) return
|
|
|
|
// Windows implements setPosition as getSize() + setBounds(), and that DIP
|
|
// round trip grows the window a pixel at a time on fractional display
|
|
// scaling. Restating the size every tick keeps the drag size-neutral.
|
|
drag.window.setBounds({
|
|
x: nextPosition.x,
|
|
y: nextPosition.y,
|
|
width: drag.size.width,
|
|
height: drag.size.height,
|
|
})
|
|
drag.lastPosition = nextPosition
|
|
}
|
|
|
|
private finishDrag(window?: PetWindow): void {
|
|
const drag = this.drag
|
|
if (window && drag && drag.window !== window) return
|
|
if (this.dragTimer) {
|
|
clearInterval(this.dragTimer)
|
|
this.dragTimer = null
|
|
}
|
|
if (!drag) return
|
|
|
|
this.drag = null
|
|
this.options.writePosition?.(drag.lastPosition)
|
|
}
|
|
|
|
showContextMenu(
|
|
window: PetWindow,
|
|
closeLabel: string,
|
|
menuFactory: PetContextMenuFactory,
|
|
): Promise<boolean> {
|
|
if (!this.owns(window)) {
|
|
return Promise.reject(new Error('Pet window IPC sender does not own the companion window'))
|
|
}
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
let settled = false
|
|
const settle = (selected: boolean) => {
|
|
if (settled) return
|
|
settled = true
|
|
resolve(selected)
|
|
}
|
|
const menu = menuFactory.buildFromTemplate([{
|
|
label: closeLabel,
|
|
click: () => settle(true),
|
|
}])
|
|
menu.popup({
|
|
window,
|
|
callback: () => settle(false),
|
|
})
|
|
})
|
|
}
|
|
|
|
dispose(): void {
|
|
this.finishDrag()
|
|
if (this.window && !this.window.isDestroyed()) this.window.destroy()
|
|
this.window = null
|
|
}
|
|
}
|