[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
359
domain/terminalOscNotifications.ts
Normal file
359
domain/terminalOscNotifications.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
export type OscNotificationProtocol = 'osc9' | 'osc777' | 'osc99';
|
||||
|
||||
export type OscNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
protocol: OscNotificationProtocol;
|
||||
};
|
||||
|
||||
export const DEFAULT_OSC_NOTIFICATION_TITLE = 'Netcatty';
|
||||
export const OSC_NOTIFICATION_TITLE_MAX = 120;
|
||||
export const OSC_NOTIFICATION_BODY_MAX = 500;
|
||||
|
||||
const CONEMU_OSC9_COMMAND = /^\d+(?:;|$)/;
|
||||
const OSC_NOTIFICATION_CARRY_MAX = 8192;
|
||||
const OSC99_PENDING_TTL_MS = 10_000;
|
||||
const OSC99_PENDING_MAX = 16;
|
||||
|
||||
const DEFAULT_LIMIT_WINDOW_MS = 10_000;
|
||||
const DEFAULT_LIMIT_MAX = 4;
|
||||
const DEFAULT_LIMIT_MIN_GAP_MS = 400;
|
||||
|
||||
const isForbiddenOscNotificationChar = (code: number): boolean => (
|
||||
code <= 8
|
||||
|| code === 0x0b
|
||||
|| code === 0x0c
|
||||
|| (code >= 0x0e && code <= 0x1f)
|
||||
|| code === 0x7f
|
||||
);
|
||||
|
||||
export function sanitizeOscNotificationText(value: string, max: number): string {
|
||||
if (typeof value !== 'string' || max <= 0) return '';
|
||||
let cleaned = '';
|
||||
for (const char of value) {
|
||||
if (!isForbiddenOscNotificationChar(char.charCodeAt(0))) cleaned += char;
|
||||
}
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned.length <= max) return cleaned;
|
||||
return `${cleaned.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function resolveOscNotificationPresentation(
|
||||
notification: OscNotification,
|
||||
fallbackTitle = DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
): { title: string; body: string } {
|
||||
const fallback = sanitizeOscNotificationText(fallbackTitle, OSC_NOTIFICATION_TITLE_MAX)
|
||||
|| DEFAULT_OSC_NOTIFICATION_TITLE;
|
||||
const title = sanitizeOscNotificationText(notification.title, OSC_NOTIFICATION_TITLE_MAX);
|
||||
const body = sanitizeOscNotificationText(notification.body, OSC_NOTIFICATION_BODY_MAX);
|
||||
if (title && body) return { title, body };
|
||||
if (body) return { title: fallback, body };
|
||||
if (title) return { title: fallback, body: title };
|
||||
return { title: fallback, body: '' };
|
||||
}
|
||||
|
||||
export function shouldShowOscDesktopNotification(
|
||||
mode: 'off' | 'unfocused' | 'always' | undefined,
|
||||
context: { windowFocused: boolean; sessionFocused: boolean },
|
||||
): boolean {
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'unfocused') return !context.windowFocused || !context.sessionFocused;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseOsc9Payload(data: string): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
const payload = data.trim();
|
||||
if (!payload) return null;
|
||||
// ConEmu/Windows Terminal also use OSC 9;n (especially 9;4 progress).
|
||||
// Those must not become desktop notifications.
|
||||
if (CONEMU_OSC9_COMMAND.test(payload)) return null;
|
||||
return {
|
||||
title: '',
|
||||
body: payload,
|
||||
protocol: 'osc9',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOsc777Payload(data: string): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
const match = data.match(/^notify;(.*)$/i);
|
||||
if (!match) return null;
|
||||
const rest = match[1] ?? '';
|
||||
const separator = rest.indexOf(';');
|
||||
if (separator < 0) {
|
||||
const body = rest.trim();
|
||||
if (!body) return null;
|
||||
return { title: '', body, protocol: 'osc777' };
|
||||
}
|
||||
const title = rest.slice(0, separator).trim();
|
||||
const body = rest.slice(separator + 1).trim();
|
||||
if (!title && !body) return null;
|
||||
return { title, body, protocol: 'osc777' };
|
||||
}
|
||||
|
||||
type Osc99Pending = {
|
||||
title: string;
|
||||
body: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const decodeOsc99Payload = (payload: string, encoded: boolean): string => {
|
||||
if (!encoded) return payload;
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export class Osc99Assembler {
|
||||
private readonly pending = new Map<string, Osc99Pending>();
|
||||
|
||||
consume(data: string, now = Date.now()): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
this.prune(now);
|
||||
|
||||
const separator = data.indexOf(';');
|
||||
const metadata = separator < 0 ? '' : data.slice(0, separator);
|
||||
const rawPayload = separator < 0 ? data : data.slice(separator + 1);
|
||||
const fields = parseOsc99Metadata(metadata);
|
||||
if (fields.close) {
|
||||
if (fields.id) this.pending.delete(fields.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = decodeOsc99Payload(rawPayload, fields.encoded);
|
||||
const current = this.pending.get(fields.id) ?? { title: '', body: '', updatedAt: now };
|
||||
if (fields.part === 'body') current.body += payload;
|
||||
else current.title += payload;
|
||||
current.updatedAt = now;
|
||||
|
||||
if (!fields.done) {
|
||||
this.pending.set(fields.id, current);
|
||||
this.evictOldest();
|
||||
return null;
|
||||
}
|
||||
|
||||
this.pending.delete(fields.id);
|
||||
if (!current.title.trim() && !current.body.trim()) return null;
|
||||
return {
|
||||
title: current.title,
|
||||
body: current.body,
|
||||
protocol: 'osc99',
|
||||
};
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [id, entry] of this.pending) {
|
||||
if (now - entry.updatedAt > OSC99_PENDING_TTL_MS) this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private evictOldest(): void {
|
||||
while (this.pending.size > OSC99_PENDING_MAX) {
|
||||
const oldest = this.pending.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
this.pending.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseOsc99Metadata(metadata: string): {
|
||||
id: string;
|
||||
part: 'title' | 'body';
|
||||
done: boolean;
|
||||
encoded: boolean;
|
||||
close: boolean;
|
||||
} {
|
||||
const fields = {
|
||||
id: '',
|
||||
part: 'title' as 'title' | 'body',
|
||||
done: true,
|
||||
encoded: false,
|
||||
close: false,
|
||||
};
|
||||
if (!metadata.trim()) return fields;
|
||||
|
||||
for (const token of metadata.split(':')) {
|
||||
const eq = token.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = token.slice(0, eq).trim();
|
||||
const value = token.slice(eq + 1).trim();
|
||||
if (key === 'i') fields.id = value;
|
||||
else if (key === 'p' && (value === 'title' || value === 'body')) fields.part = value;
|
||||
else if (key === 'd') fields.done = value !== '0';
|
||||
else if (key === 'e') fields.encoded = value === '1';
|
||||
else if ((key === 'p' || key === 'a') && value === 'close') fields.close = true;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
type OscScanResult =
|
||||
| { incomplete: true }
|
||||
| { incomplete: false; notification: boolean; aborted?: boolean; id: number; payload: string; end: number };
|
||||
|
||||
const isNotificationOscId = (id: number): boolean => id === 9 || id === 777 || id === 99;
|
||||
|
||||
const readOscAt = (input: string, start: number): OscScanResult => {
|
||||
if (input[start] !== '\x1b') return { incomplete: false, notification: false, id: -1, payload: '', end: start + 1 };
|
||||
if (start + 1 >= input.length) return { incomplete: true };
|
||||
if (input[start + 1] !== ']') {
|
||||
return { incomplete: false, notification: false, id: -1, payload: '', end: start + 1 };
|
||||
}
|
||||
|
||||
let index = start + 2;
|
||||
if (index >= input.length) return { incomplete: true };
|
||||
|
||||
let id = 0;
|
||||
let sawDigit = false;
|
||||
while (index < input.length) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 48 && code <= 57) {
|
||||
sawDigit = true;
|
||||
id = id * 10 + (code - 48);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!sawDigit) {
|
||||
return { incomplete: index >= input.length, notification: false, id: -1, payload: '', end: index };
|
||||
}
|
||||
if (index >= input.length) return { incomplete: true };
|
||||
|
||||
if (input[index] === ';') index += 1;
|
||||
|
||||
const payloadStart = index;
|
||||
while (index < input.length) {
|
||||
if (input[index] === '\x07') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: isNotificationOscId(id),
|
||||
id,
|
||||
payload: input.slice(payloadStart, index),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
if (input[index] === '\x18' || input[index] === '\x1a') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
aborted: isNotificationOscId(id),
|
||||
id,
|
||||
payload: '',
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
if (input[index] === '\x1b') {
|
||||
if (index + 1 >= input.length) {
|
||||
return isNotificationOscId(id) ? { incomplete: true } : {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
id,
|
||||
payload: '',
|
||||
end: index,
|
||||
};
|
||||
}
|
||||
if (input[index + 1] === '\\') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: isNotificationOscId(id),
|
||||
id,
|
||||
payload: input.slice(payloadStart, index),
|
||||
end: index + 2,
|
||||
};
|
||||
}
|
||||
// xterm aborts an OSC at a non-ST ESC and then processes that ESC.
|
||||
return { incomplete: false, notification: false, aborted: isNotificationOscId(id), id, payload: '', end: index };
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return isNotificationOscId(id) ? { incomplete: true } : {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
id,
|
||||
payload: '',
|
||||
end: input.length,
|
||||
};
|
||||
};
|
||||
|
||||
export class OscNotificationStreamScanner {
|
||||
private carry = '';
|
||||
private readonly assembler = new Osc99Assembler();
|
||||
|
||||
consume(chunk: string): { notifications: OscNotification[]; remainder: string } {
|
||||
const input = this.carry + (typeof chunk === 'string' ? chunk : '');
|
||||
this.carry = '';
|
||||
const notifications: OscNotification[] = [];
|
||||
let remainder = '';
|
||||
let index = 0;
|
||||
|
||||
while (index < input.length) {
|
||||
const esc = input.indexOf('\x1b', index);
|
||||
if (esc < 0) {
|
||||
remainder += input.slice(index);
|
||||
break;
|
||||
}
|
||||
remainder += input.slice(index, esc);
|
||||
const scanned = readOscAt(input, esc);
|
||||
if (scanned.incomplete) {
|
||||
const leftover = input.slice(esc);
|
||||
if (leftover.length > OSC_NOTIFICATION_CARRY_MAX) {
|
||||
remainder += leftover;
|
||||
} else {
|
||||
this.carry = leftover;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (scanned.notification) {
|
||||
const notification = scanned.id === 9
|
||||
? parseOsc9Payload(scanned.payload)
|
||||
: scanned.id === 777
|
||||
? parseOsc777Payload(scanned.payload)
|
||||
: this.assembler.consume(scanned.payload);
|
||||
if (notification) notifications.push(notification);
|
||||
} else if (!scanned.aborted) {
|
||||
remainder += input.slice(esc, scanned.end);
|
||||
}
|
||||
index = scanned.end;
|
||||
}
|
||||
|
||||
return { notifications, remainder };
|
||||
}
|
||||
|
||||
flush(): string {
|
||||
const leftover = this.carry;
|
||||
this.carry = '';
|
||||
return leftover;
|
||||
}
|
||||
}
|
||||
|
||||
export class OscNotificationLimiter {
|
||||
private readonly stamps = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly windowMs = DEFAULT_LIMIT_WINDOW_MS,
|
||||
private readonly max = DEFAULT_LIMIT_MAX,
|
||||
private readonly minGapMs = DEFAULT_LIMIT_MIN_GAP_MS,
|
||||
) {}
|
||||
|
||||
allow(key: string, now = Date.now()): boolean {
|
||||
const recent = (this.stamps.get(key) ?? []).filter((stamp) => now - stamp < this.windowMs);
|
||||
const last = recent[recent.length - 1];
|
||||
if (last !== undefined && now - last < this.minGapMs) {
|
||||
this.stamps.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
if (recent.length >= this.max) {
|
||||
this.stamps.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
this.stamps.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user