Files
NetMesh/domain/terminalOscNotifications.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

360 lines
11 KiB
TypeScript

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;
}
}