程序员阿江(Relakkes) ced096c894 fix(desktop): keep the dragged pet at the display edge across restarts
8a3ea62b taught dragging to clamp against the mascot rather than the whole
window, so a saved edge position deliberately leaves the transparent padding
off-screen. Recreating the window re-clamps that position against the whole
window again, and the reposition-on-restore step that undoes it was gated on
darwin -- so on Windows and Linux the mascot walked inwards by the padding
width on every hide/show, not just on restart. The gate only existed because
visibleDragRegion used to be darwin-only; the region now arrives everywhere,
so the repositioning runs everywhere.

e24d59fe restated the recorded window size on every drag tick to stop
setPosition from growing the window, but sampled that size from getBounds().
Chromium converts window rects between physical pixels and DIP with ceil in
both directions, so the sampled value has already been rounded up once and
restating it rounds up again: stable within a drag, a pixel per drag across
them. The window is created non-resizable at the nominal size, so restating
the constants is idempotent instead -- and heals a window that already drifted.
Both call sites now go through movePetWindow.

petWindowContentExtent fell back to the nominal constants when
getContentBounds returned an empty rect, which silently reinstates the exact
clamp it exists to avoid. Fall through to the live window box first.

The fake window in the tests returned one object for both getBounds and
getContentBounds, and modelled setBounds as inherently size-neutral -- so
neither "clamp to the content box" nor "size-neutral drag" was actually
proven: reverting either left all 33 tests green. It now models the ceil/ceil
DIP round trip and carries a distinct content box, and covers the previously
untested paths: cross-platform restore, multi-region shapes, the region
normalization bounds, and the getContentBounds fallbacks.
2026-07-25 15:56:39 +08:00

561 lines
18 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 }
function isPositiveExtent(extent: Partial<PetWindowExtent> | undefined): boolean {
return typeof extent?.width === 'number' && extent.width > 0
&& typeof extent?.height === 'number' && extent.height > 0
}
// Renderer regions are measured against the live viewport, so they have 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.
//
// getContentBounds returns an empty rect while the content view is detached, so
// fall through to the live window box rather than to the constants — falling
// back to those would silently reinstate the very clamp this exists to avoid.
function petWindowContentExtent(window: PetWindow): PetWindowExtent {
const candidates = [window.getContentBounds?.(), window.getBounds()]
const measured = candidates.find(isPositiveExtent)
return {
width: measured ? Math.round(measured.width) : PET_WINDOW_WIDTH,
height: measured ? Math.round(measured.height) : PET_WINDOW_HEIGHT,
}
}
/**
* Moves the window without touching its size.
*
* Electron implements setPosition as `SetBounds(Rect(position, GetSize()))`, and
* on Windows both halves of that DIP round trip round up — so on a fractional
* display scale every call grows the window by a pixel. Restating the nominal
* size is idempotent instead: the window is created non-resizable at exactly
* these dimensions, so re-applying them also heals a window that already drifted.
*/
function movePetWindow(window: PetWindow, position: PetWindowPosition): void {
window.setBounds({
x: position.x,
y: position.y,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
}
function normalizePetWindowRegion(region: Rectangle, extent: PetWindowExtent): Rectangle {
const x = Math.max(0, Math.min(extent.width - 1, Math.round(region.x)))
const y = Math.max(0, Math.min(extent.height - 1, Math.round(region.y)))
const right = Math.max(x + 1, Math.min(extent.width, Math.round(region.x + region.width)))
const bottom = Math.max(y + 1, Math.min(extent.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
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 extent = petWindowContentExtent(window)
const primaryRegion = regions[0]
// The window is mostly transparent padding around the mascot, so dragging
// has to clamp against the mascot on every platform. Clamping against the
// whole window stops the mascot short of the display edges.
const dragRegion = primaryRegion
? normalizePetWindowRegion(primaryRegion, extent)
: null
if (dragRegion) this.visibleDragRegion = dragRegion
// A saved edge position leaves the transparent padding off-screen, so
// creating the window re-clamps it against the whole window and walks the
// mascot inwards by the padding width. Restoring it needs the reported
// region, which only arrives here — and it arrives on every platform, so
// this runs on every platform too.
if (dragRegion) {
const requestedPosition = this.pendingRestoredPosition ?? window.getBounds()
this.pendingRestoredPosition = null
const anchor = {
x: requestedPosition.x + dragRegion.x + Math.floor(dragRegion.width / 2),
y: requestedPosition.y + dragRegion.y + Math.floor(dragRegion.height / 2),
}
const workArea = this.options.getWorkAreaForPoint?.(anchor)
?? this.options.getCurrentWorkArea()
const nextPosition = clampPetWindowPosition(requestedPosition, workArea, dragRegion)
const bounds = window.getBounds()
if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) {
movePetWindow(window, nextPosition)
}
}
if (platform === 'darwin') return
const shape = regions.map((region) => normalizePetWindowRegion({
x: region.x - PET_WINDOW_SHAPE_PADDING,
y: region.y - PET_WINDOW_SHAPE_PADDING,
width: region.width + PET_WINDOW_SHAPE_PADDING * 2,
height: region.height + PET_WINDOW_SHAPE_PADDING * 2,
}, extent))
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 },
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
movePetWindow(drag.window, nextPosition)
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
}
}