Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions main/src/daemon/socketFraming.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
66 changes: 66 additions & 0 deletions main/src/daemon/socketFraming.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
36 changes: 36 additions & 0 deletions main/src/daemon/socketPath.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions main/src/daemon/socketPath.ts
Original file line number Diff line number Diff line change
@@ -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,
),
};
}
Loading
Loading