diff --git a/main/src/daemon/socketFraming.test.ts b/main/src/daemon/socketFraming.test.ts new file mode 100644 index 0000000..c91c93f --- /dev/null +++ b/main/src/daemon/socketFraming.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { + encodePaneDaemonFrame, + PaneDaemonFrameDecoder, +} from './socketFraming'; +import { + isDaemonOwnedChannel, + isPaneDaemonEventFrame, + isPaneDaemonFrame, + isPaneDaemonRequestFrame, + isPaneDaemonResponseFrame, + type PaneDaemonEventFrame, + type PaneDaemonRequestFrame, + type PaneDaemonResponseFrame, +} from '../../../shared/types/daemon'; + +describe('Pane daemon framing', () => { + it('encodes frames as newline-delimited JSON', () => { + const frame: PaneDaemonRequestFrame = { + type: 'request', + id: 42, + channel: 'sessions:get-all', + args: [], + }; + + expect(encodePaneDaemonFrame(frame)).toBe('{"type":"request","id":42,"channel":"sessions:get-all","args":[]}\n'); + }); + + it('decodes frames split across multiple chunks', () => { + const decoder = new PaneDaemonFrameDecoder(); + const frame: PaneDaemonEventFrame = { + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1' }], + }; + + const encoded = encodePaneDaemonFrame(frame); + + expect(decoder.push(encoded.slice(0, 12))).toEqual([]); + expect(decoder.push(encoded.slice(12))).toEqual([frame]); + expect(decoder.pendingBuffer()).toBe(''); + }); + + it('preserves multibyte UTF-8 characters split across buffer chunks', () => { + const decoder = new PaneDaemonFrameDecoder(); + const frame: PaneDaemonEventFrame = { + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1', label: 'Pane café 日本語' }], + }; + + const encodedBuffer = Buffer.from(encodePaneDaemonFrame(frame), 'utf8'); + const multibyteChar = Buffer.from('é', 'utf8'); + const splitIndex = encodedBuffer.indexOf(multibyteChar) + 1; + expect(splitIndex).toBeGreaterThan(0); + + expect(decoder.push(encodedBuffer.subarray(0, splitIndex))).toEqual([]); + expect(decoder.push(encodedBuffer.subarray(splitIndex))).toEqual([frame]); + expect(decoder.pendingBuffer()).toBe(''); + }); + + it('decodes multiple frames from a single chunk', () => { + const decoder = new PaneDaemonFrameDecoder(); + const first: PaneDaemonRequestFrame = { + type: 'request', + id: 1, + channel: 'projects:get-all', + args: [], + }; + const second: PaneDaemonResponseFrame = { + type: 'response', + id: 1, + ok: true, + result: [{ id: 7 }], + }; + + const frames = decoder.push(`${encodePaneDaemonFrame(first)}${encodePaneDaemonFrame(second)}`); + + expect(frames).toEqual([first, second]); + }); + + it('rejects invalid JSON frames', () => { + const decoder = new PaneDaemonFrameDecoder(); + + expect(() => decoder.push('{"type":"request"\n')).toThrow('Failed to parse Pane daemon frame'); + }); + + it('rejects frames that do not match the Pane daemon protocol', () => { + const decoder = new PaneDaemonFrameDecoder(); + + expect(() => decoder.push('{"type":"request","id":"bad","channel":"sessions:get-all","args":[]}\n')).toThrow( + 'Failed to parse Pane daemon frame: frame does not match Pane daemon protocol', + ); + }); + + it('rejects incomplete trailing frames when finishing the stream', () => { + const decoder = new PaneDaemonFrameDecoder(); + decoder.push('{"type":"request","id":1'); + + expect(() => decoder.finish()).toThrow('Incomplete Pane daemon frame at end of stream'); + }); +}); + +describe('Pane daemon shared protocol helpers', () => { + it('classifies daemon-owned channels conservatively', () => { + expect(isDaemonOwnedChannel('sessions:get-all')).toBe(true); + expect(isDaemonOwnedChannel('projects:create')).toBe(true); + expect(isDaemonOwnedChannel('git:commit')).toBe(true); + expect(isDaemonOwnedChannel('file:write')).toBe(true); + expect(isDaemonOwnedChannel('file:showInFolder')).toBe(false); + expect(isDaemonOwnedChannel('openExternal')).toBe(false); + }); + + it('detects request frames', () => { + const frame = { + type: 'request', + id: 1, + channel: 'sessions:get-all', + args: [], + }; + + expect(isPaneDaemonRequestFrame(frame)).toBe(true); + expect(isPaneDaemonFrame(frame)).toBe(true); + }); + + it('detects response frames', () => { + const successFrame = { + type: 'response', + id: 1, + ok: true, + result: { success: true }, + }; + const errorFrame = { + type: 'response', + id: 2, + ok: false, + error: { message: 'boom', code: 'ERR_TEST' }, + }; + + expect(isPaneDaemonResponseFrame(successFrame)).toBe(true); + expect(isPaneDaemonResponseFrame(errorFrame)).toBe(true); + expect(isPaneDaemonFrame(successFrame)).toBe(true); + expect(isPaneDaemonFrame(errorFrame)).toBe(true); + }); + + it('detects event frames', () => { + const frame = { + type: 'event', + channel: 'panel:created', + args: [{ id: 'panel-1' }], + }; + + expect(isPaneDaemonEventFrame(frame)).toBe(true); + expect(isPaneDaemonFrame(frame)).toBe(true); + }); +}); diff --git a/main/src/daemon/socketFraming.ts b/main/src/daemon/socketFraming.ts new file mode 100644 index 0000000..62e6d6d --- /dev/null +++ b/main/src/daemon/socketFraming.ts @@ -0,0 +1,66 @@ +import { StringDecoder } from 'string_decoder'; +import { isPaneDaemonFrame, type PaneDaemonFrame } from '../../../shared/types/daemon'; + +const FRAME_DELIMITER = '\n'; + +export function encodePaneDaemonFrame(frame: PaneDaemonFrame): string { + return `${JSON.stringify(frame)}${FRAME_DELIMITER}`; +} + +export class PaneDaemonFrameDecoder { + private buffer = ''; + private decoder = new StringDecoder('utf8'); + + push(chunk: string | Buffer): PaneDaemonFrame[] { + this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk); + + const frames: PaneDaemonFrame[] = []; + let delimiterIndex = this.buffer.indexOf(FRAME_DELIMITER); + + while (delimiterIndex !== -1) { + const rawFrame = this.buffer.slice(0, delimiterIndex); + this.buffer = this.buffer.slice(delimiterIndex + FRAME_DELIMITER.length); + + if (rawFrame.trim().length > 0) { + frames.push(this.parseFrame(rawFrame)); + } + + delimiterIndex = this.buffer.indexOf(FRAME_DELIMITER); + } + + return frames; + } + + finish(): void { + this.buffer += this.decoder.end(); + + if (this.buffer.trim().length > 0) { + throw new Error('Incomplete Pane daemon frame at end of stream'); + } + + this.buffer = ''; + this.decoder = new StringDecoder('utf8'); + } + + pendingBuffer(): string { + return this.buffer; + } + + private parseFrame(rawFrame: string): PaneDaemonFrame { + let parsed: unknown; + + try { + parsed = JSON.parse(rawFrame) as unknown; + } catch (error) { + throw new Error( + `Failed to parse Pane daemon frame: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!isPaneDaemonFrame(parsed)) { + throw new Error('Failed to parse Pane daemon frame: frame does not match Pane daemon protocol'); + } + + return parsed; + } +} diff --git a/main/src/daemon/socketPath.test.ts b/main/src/daemon/socketPath.test.ts new file mode 100644 index 0000000..6877ca9 --- /dev/null +++ b/main/src/daemon/socketPath.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getPaneDaemonEndpoint, getPaneDaemonSocketDirectory } from './socketPath'; + +describe('Pane daemon socket path', () => { + it('uses a sockets subdirectory and stable socket file on Unix-like platforms', () => { + const endpoint = getPaneDaemonEndpoint('/Users/parsa/.pane', 'darwin'); + + expect(endpoint).toEqual({ + transport: 'unix', + path: '/Users/parsa/.pane/sockets/daemon.sock', + }); + expect(getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin')).toBe('/Users/parsa/.pane/sockets'); + }); + + it('uses a stable named pipe on Windows', () => { + const endpoint = getPaneDaemonEndpoint('C:\\Users\\Parsa\\.pane', 'win32'); + + expect(endpoint.transport).toBe('pipe'); + expect(endpoint.path).toMatch(/^\\\\\.\\pipe\\pane-daemon-[0-9a-f]{16}$/); + expect(getPaneDaemonSocketDirectory('C:\\Users\\Parsa\\.pane', 'win32')).toBeNull(); + }); + + it('normalizes Windows path case before hashing the pipe name', () => { + const upper = getPaneDaemonEndpoint('C:\\Users\\Parsa\\.pane', 'win32'); + const lower = getPaneDaemonEndpoint('c:\\users\\parsa\\.pane', 'win32'); + + expect(upper).toEqual(lower); + }); + + it('resolves relative Unix paths before building the endpoint', () => { + const endpoint = getPaneDaemonEndpoint('.pane-test', 'linux'); + + expect(endpoint.transport).toBe('unix'); + expect(endpoint.path.endsWith('/sockets/daemon.sock')).toBe(true); + }); +}); diff --git a/main/src/daemon/socketPath.ts b/main/src/daemon/socketPath.ts new file mode 100644 index 0000000..76a90c0 --- /dev/null +++ b/main/src/daemon/socketPath.ts @@ -0,0 +1,56 @@ +import { createHash } from 'crypto'; +import path from 'path'; + +export interface PaneDaemonEndpoint { + transport: 'pipe' | 'unix'; + path: string; +} + +const DAEMON_SOCKET_DIRECTORY = 'sockets'; +const DAEMON_SOCKET_FILENAME = 'daemon.sock'; + +function resolveAppDirectory(appDirectory: string, platform: NodeJS.Platform): string { + if (platform === 'win32') { + return path.win32.resolve(appDirectory); + } + + return path.posix.resolve(appDirectory); +} + +function getWindowsPipeName(appDirectory: string): string { + const hash = createHash('sha256') + .update(appDirectory.toLowerCase()) + .digest('hex') + .slice(0, 16); + + return `\\\\.\\pipe\\pane-daemon-${hash}`; +} + +export function getPaneDaemonSocketDirectory(appDirectory: string, platform: NodeJS.Platform = process.platform): string | null { + const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); + if (platform === 'win32') { + return null; + } + + return path.posix.join(resolvedAppDirectory, DAEMON_SOCKET_DIRECTORY); +} + +export function getPaneDaemonEndpoint(appDirectory: string, platform: NodeJS.Platform = process.platform): PaneDaemonEndpoint { + const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); + + if (platform === 'win32') { + return { + transport: 'pipe', + path: getWindowsPipeName(resolvedAppDirectory), + }; + } + + const socketDirectory = getPaneDaemonSocketDirectory(resolvedAppDirectory, platform); + return { + transport: 'unix', + path: path.posix.join( + socketDirectory ?? resolvedAppDirectory, + DAEMON_SOCKET_FILENAME, + ), + }; +} diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts new file mode 100644 index 0000000..48f7e5b --- /dev/null +++ b/shared/types/daemon.ts @@ -0,0 +1,159 @@ +export interface PaneDaemonRequestFrame { + type: 'request'; + id: number; + channel: string; + args: unknown[]; +} + +export interface PaneDaemonSuccessResponseFrame { + type: 'response'; + id: number; + ok: true; + result?: unknown; +} + +export interface PaneDaemonError { + message: string; + code?: string; +} + +export interface PaneDaemonErrorResponseFrame { + type: 'response'; + id: number; + ok: false; + error: PaneDaemonError; +} + +export interface PaneDaemonEventFrame { + type: 'event'; + channel: string; + args: unknown[]; +} + +export type PaneDaemonResponseFrame = + | PaneDaemonSuccessResponseFrame + | PaneDaemonErrorResponseFrame; + +export type PaneDaemonFrame = + | PaneDaemonRequestFrame + | PaneDaemonResponseFrame + | PaneDaemonEventFrame; + +interface PaneDaemonResponseFrameCandidate { + type?: unknown; + id?: unknown; + ok?: unknown; + error?: unknown; +} + +const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ + 'file:showInFolder', +]); + +export function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + +export function isPaneDaemonRequestFrame(frame: unknown): frame is PaneDaemonRequestFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as Partial; + return ( + candidate.type === 'request' && + typeof candidate.id === 'number' && + typeof candidate.channel === 'string' && + Array.isArray(candidate.args) + ); +} + +export function isPaneDaemonResponseFrame(frame: unknown): frame is PaneDaemonResponseFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as PaneDaemonResponseFrameCandidate; + if (candidate.type !== 'response' || typeof candidate.id !== 'number' || typeof candidate.ok !== 'boolean') { + return false; + } + + if (candidate.ok === true) { + return true; + } + + if (typeof candidate.error !== 'object' || candidate.error === null) { + return false; + } + + const error = candidate.error as { message?: unknown }; + return typeof error.message === 'string'; +} + +export function isPaneDaemonEventFrame(frame: unknown): frame is PaneDaemonEventFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as Partial; + return ( + candidate.type === 'event' && + typeof candidate.channel === 'string' && + Array.isArray(candidate.args) + ); +} + +export function isPaneDaemonFrame(frame: unknown): frame is PaneDaemonFrame { + return ( + isPaneDaemonRequestFrame(frame) || + isPaneDaemonResponseFrame(frame) || + isPaneDaemonEventFrame(frame) + ); +}