Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a32ee910a | ||
|
|
fa83097528 |
No files matched your search
@@ -19,3 +19,9 @@ research/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.sftp-test-*.bin
|
.sftp-test-*.bin
|
||||||
.sftp-dl-*
|
.sftp-dl-*
|
||||||
|
|
||||||
|
tests/.commands-store.cjs
|
||||||
|
tests/.session-e2e.cjs
|
||||||
|
tests/.hl-split-smoke.cjs
|
||||||
|
tests/.zmodem-e2e.cjs
|
||||||
|
release/
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
appId: site.codingplan.openterminal
|
||||||
|
productName: OpenTerminal
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
|
||||||
|
files:
|
||||||
|
- out/**
|
||||||
|
- package.json
|
||||||
|
|
||||||
|
asar: true
|
||||||
|
asarUnpack:
|
||||||
|
- "**/node_modules/@lydell/node-pty/**"
|
||||||
|
- "**/node_modules/ssh2/**"
|
||||||
|
|
||||||
|
# @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild.
|
||||||
|
npmRebuild: false
|
||||||
|
|
||||||
|
# Electron-updater only supports NSIS auto-updates (MSI has no update channel).
|
||||||
|
# The NSIS .exe + latest.yml get uploaded together; MSI is manual/enterprise only.
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- target: msi
|
||||||
|
- target: nsis
|
||||||
|
|
||||||
|
msi:
|
||||||
|
oneClick: false
|
||||||
|
# Fixed upgrade code keeps the upgrade chain consistent across releases.
|
||||||
|
upgradeCode: "{5ab9f79e-e4eb-4052-9df6-3af3a301ab0a}"
|
||||||
|
artifactName: ${productName}-${version}-setup.${ext}
|
||||||
|
# No build/icon.ico exists. The WiX template only emits <Icon> when an icon is
|
||||||
|
# set, but the shortcut elements still reference Icon=<iconId>, so MSI linking
|
||||||
|
# fails with LGHT0094. Disabling MSI shortcuts drops the dangling reference.
|
||||||
|
# NSIS still creates its shortcuts independently.
|
||||||
|
createDesktopShortcut: false
|
||||||
|
createStartMenuShortcut: false
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
perMachine: false
|
||||||
|
artifactName: ${productName}-${version}-setup.${ext}
|
||||||
|
|
||||||
|
# Declaring a publish provider makes electron-builder emit the update-info file
|
||||||
|
# (latest.yml) into directories.output for upload. No token is stored here; the
|
||||||
|
# main agent handles feed authentication during upload. Build runs with the
|
||||||
|
# default publish policy, so nothing is uploaded from this script.
|
||||||
|
publish:
|
||||||
|
provider: generic
|
||||||
|
url: https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/
|
||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"productName": "OpenTerminal",
|
"productName": "OpenTerminal",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
|
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "Bill",
|
"author": "Bill",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||||
|
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from 'path'
|
|||||||
import { registerIpc } from './ipc'
|
import { registerIpc } from './ipc'
|
||||||
import { killAllPtys } from './pty'
|
import { killAllPtys } from './pty'
|
||||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||||
|
import { configureAutoUpdater } from './updater'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -54,6 +55,7 @@ app.whenReady().then(() => {
|
|||||||
// settings dialog is never opened this run.
|
// settings dialog is never opened this run.
|
||||||
applyStartupSystemSettings(loadSettings())
|
applyStartupSystemSettings(loadSettings())
|
||||||
createWindow()
|
createWindow()
|
||||||
|
configureAutoUpdater()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { autoUpdater } from 'electron-updater'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default update feed (generic provider). The main agent uploads latest.yml and
|
||||||
|
* the NSIS installer to this stable path.
|
||||||
|
*
|
||||||
|
* KNOWN LIMITATION: electron-updater does NOT support MSI auto-updates. The
|
||||||
|
* update channel always runs through the NSIS .exe (latest.yml + .exe are
|
||||||
|
* uploaded together). MSI is manual/enterprise distribution only.
|
||||||
|
*/
|
||||||
|
const DEFAULT_FEED_URL =
|
||||||
|
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
|
||||||
|
|
||||||
|
export function configureAutoUpdater(): void {
|
||||||
|
// Update checks only run in packaged builds; no-op during development.
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = process.env.OT_UPDATE_URL || DEFAULT_FEED_URL
|
||||||
|
const token = process.env.OT_UPDATE_TOKEN
|
||||||
|
|
||||||
|
autoUpdater.setFeedURL({
|
||||||
|
provider: 'generic',
|
||||||
|
url,
|
||||||
|
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
autoUpdater.logger = console
|
||||||
|
autoUpdater.autoDownload = true
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
|
// Check shortly after startup; log errors without crashing the app.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
autoUpdater
|
||||||
|
.checkForUpdatesAndNotify()
|
||||||
|
.catch((err) => console.warn('[updater] checkForUpdates failed:', err))
|
||||||
|
}, 5000)
|
||||||
|
timer.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public entry point for later UI wiring (not connected this milestone). */
|
||||||
|
export function checkForUpdates(): Promise<void> {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return autoUpdater
|
||||||
|
.checkForUpdates()
|
||||||
|
.then(() => {})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[updater] checkForUpdates failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
// src/renderer/src/terminal/highlightEngine.ts
|
|
||||||
var ESCAPE_RE = /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g;
|
|
||||||
var MAX_PER_RULE = 300;
|
|
||||||
var MAX_CHUNK = 512 * 1024;
|
|
||||||
function hexToRgb(hex) {
|
|
||||||
let h = (hex || "").trim().replace(/^#/, "");
|
|
||||||
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
|
|
||||||
if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 };
|
|
||||||
const n = parseInt(h, 16);
|
|
||||||
return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
|
|
||||||
}
|
|
||||||
function tokenize(input) {
|
|
||||||
const out = [];
|
|
||||||
let acc = "";
|
|
||||||
let i = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = ESCAPE_RE.exec(input)) !== null) {
|
|
||||||
if (m.index > i) acc += input.slice(i, m.index);
|
|
||||||
if (acc.length > 0) {
|
|
||||||
out.push({ kind: "text", value: acc });
|
|
||||||
acc = "";
|
|
||||||
}
|
|
||||||
out.push({ kind: "seq", value: m[0] });
|
|
||||||
i = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (i < input.length) acc += input.slice(i);
|
|
||||||
if (acc.length > 0) out.push({ kind: "text", value: acc });
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function compileRules(rules2) {
|
|
||||||
const out = [];
|
|
||||||
for (const rule of rules2) {
|
|
||||||
if (!rule || rule.enabled === false) continue;
|
|
||||||
const pattern = rule.pattern;
|
|
||||||
if (typeof pattern !== "string" || pattern.length === 0) continue;
|
|
||||||
let regex;
|
|
||||||
try {
|
|
||||||
regex = new RegExp(pattern, "g");
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fg = typeof rule.color?.fg === "string" ? rule.color.fg : "";
|
|
||||||
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue;
|
|
||||||
const bg = typeof rule.color?.bg === "string" ? rule.color.bg : void 0;
|
|
||||||
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority });
|
|
||||||
}
|
|
||||||
out.sort((a, b) => a.priority - b.priority);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function wrap(text, fgHex, bgHex) {
|
|
||||||
const f = hexToRgb(fgHex);
|
|
||||||
let s = `\x1B[38;2;${f.r};${f.g};${f.b}`;
|
|
||||||
if (bgHex) {
|
|
||||||
const b = hexToRgb(bgHex);
|
|
||||||
s += `;48;2;${b.r};${b.g};${b.b}`;
|
|
||||||
}
|
|
||||||
return `${s}m${text}\x1B[0m`;
|
|
||||||
}
|
|
||||||
function overlaps(claimed, s, e) {
|
|
||||||
for (const c of claimed) {
|
|
||||||
if (s < c.end && e > c.start) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function applyRun(text, rules2, budgets) {
|
|
||||||
if (text.length === 0) return "";
|
|
||||||
const claimed = [];
|
|
||||||
for (let ri = 0; ri < rules2.length; ri++) {
|
|
||||||
const rule = rules2[ri];
|
|
||||||
if (budgets[ri] <= 0) continue;
|
|
||||||
rule.regex.lastIndex = 0;
|
|
||||||
let m;
|
|
||||||
let made = 0;
|
|
||||||
while ((m = rule.regex.exec(text)) !== null) {
|
|
||||||
const start = m.index;
|
|
||||||
const end = start + m[0].length;
|
|
||||||
if (end <= start) {
|
|
||||||
rule.regex.lastIndex++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!overlaps(claimed, start, end)) {
|
|
||||||
claimed.push({ start, end, fg: rule.fg, bg: rule.bg });
|
|
||||||
made++;
|
|
||||||
budgets[ri]--;
|
|
||||||
if (budgets[ri] <= 0) break;
|
|
||||||
}
|
|
||||||
if (rule.regex.lastIndex === start) rule.regex.lastIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (claimed.length === 0) return text;
|
|
||||||
claimed.sort((a, b) => a.start - b.start);
|
|
||||||
let out = "";
|
|
||||||
let pos = 0;
|
|
||||||
for (const c of claimed) {
|
|
||||||
if (c.start > pos) out += text.slice(pos, c.start);
|
|
||||||
out += wrap(text.slice(c.start, c.end), c.fg, c.bg);
|
|
||||||
pos = c.end;
|
|
||||||
}
|
|
||||||
if (pos < text.length) out += text.slice(pos);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function applyHighlights(chunk, rules2) {
|
|
||||||
if (!chunk || rules2.length === 0) return chunk;
|
|
||||||
if (chunk.length > MAX_CHUNK) return chunk;
|
|
||||||
let budgets = rules2.map(() => MAX_PER_RULE);
|
|
||||||
let usedBudget = false;
|
|
||||||
let out = "";
|
|
||||||
for (const token of tokenize(chunk)) {
|
|
||||||
if (token.kind === "seq") {
|
|
||||||
out += token.value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (token.value.includes("\x1B")) {
|
|
||||||
out += token.value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const before = budgets.reduce((a, b) => a + b, 0);
|
|
||||||
out += applyRun(token.value, rules2, budgets);
|
|
||||||
const after = budgets.reduce((a, b) => a + b, 0);
|
|
||||||
if (after < before) usedBudget = true;
|
|
||||||
}
|
|
||||||
return usedBudget ? out : chunk;
|
|
||||||
}
|
|
||||||
function isIncompleteEscape(seg) {
|
|
||||||
if (seg === "\x1B") return true;
|
|
||||||
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true;
|
|
||||||
if (seg.startsWith("\x1B]") && !seg.includes("\x07") && !seg.includes("\x1B\\")) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function lastEscapeEnd(input) {
|
|
||||||
let end = -1;
|
|
||||||
let m;
|
|
||||||
ESCAPE_RE.lastIndex = 0;
|
|
||||||
while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length;
|
|
||||||
return end;
|
|
||||||
}
|
|
||||||
function findIncompleteIndex(input) {
|
|
||||||
const idx = input.lastIndexOf("\x1B");
|
|
||||||
if (idx < 0) return -1;
|
|
||||||
return isIncompleteEscape(input.slice(idx)) ? idx : -1;
|
|
||||||
}
|
|
||||||
var MAX_CARRY = 1024;
|
|
||||||
var HighlightStream = class {
|
|
||||||
rules;
|
|
||||||
buffer = "";
|
|
||||||
constructor(rules2) {
|
|
||||||
this.rules = rules2;
|
|
||||||
}
|
|
||||||
/** Swap the active rules without touching any buffered content. */
|
|
||||||
setRules(rules2) {
|
|
||||||
this.rules = rules2;
|
|
||||||
}
|
|
||||||
/** True while content is held back (a pending flush will release it). */
|
|
||||||
hasPending() {
|
|
||||||
return this.buffer.length > 0;
|
|
||||||
}
|
|
||||||
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
|
|
||||||
push(chunk) {
|
|
||||||
this.buffer += chunk;
|
|
||||||
const incompleteAt = findIncompleteIndex(this.buffer);
|
|
||||||
if (incompleteAt >= 0) {
|
|
||||||
const head2 = this.buffer.slice(0, incompleteAt);
|
|
||||||
const tail = this.buffer.slice(incompleteAt);
|
|
||||||
if (tail.length > MAX_CARRY) {
|
|
||||||
this.buffer = "";
|
|
||||||
return applyHighlights(head2, this.rules) + tail;
|
|
||||||
}
|
|
||||||
this.buffer = tail;
|
|
||||||
return applyHighlights(head2, this.rules);
|
|
||||||
}
|
|
||||||
const lastEnd = lastEscapeEnd(this.buffer);
|
|
||||||
if (lastEnd < 0) {
|
|
||||||
if (this.buffer.length > MAX_CHUNK) {
|
|
||||||
const raw = this.buffer;
|
|
||||||
this.buffer = "";
|
|
||||||
return applyHighlights(raw, this.rules);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const head = this.buffer.slice(0, lastEnd);
|
|
||||||
this.buffer = this.buffer.slice(lastEnd);
|
|
||||||
return applyHighlights(head, this.rules);
|
|
||||||
}
|
|
||||||
/** Flush any held content (session end) — highlighted as a final run. */
|
|
||||||
flush() {
|
|
||||||
const b = this.buffer;
|
|
||||||
this.buffer = "";
|
|
||||||
return applyHighlights(b, this.rules);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// tests/hl-split-smoke.mjs
|
|
||||||
var rules = compileRules([
|
|
||||||
{ id: "okstate", pattern: "\\b(SUCCESS|PASS|OK|DONE)\\b", enabled: true, priority: 5, color: { fg: "#3fb950" } },
|
|
||||||
{ id: "badstate", pattern: "\\b(ERROR|FAILED)\\b", enabled: true, priority: 6, color: { fg: "#f85149" } },
|
|
||||||
{ id: "numbers", pattern: "\\b\\d+(?:\\.\\d+)?(?:%)?\\b", enabled: true, priority: 25, color: { fg: "#f2cc60" } },
|
|
||||||
{ id: "url", pattern: "https?://[^\\s]+", enabled: true, priority: 30, color: { fg: "#58a6ff" } }
|
|
||||||
]);
|
|
||||||
var banner = "\x1B]0;kimi \u2014 session\x07\x1B[38;2;79;168;255m\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E\x1B[0m\r\n\x1B[38;2;136;136;136mWelcome to Kimi Code!\x1B[0m \x1B[38;2;79;168;255m\u2502\x1B[0m send /help\r\n\x1B[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1B[0m\r\n";
|
|
||||||
var reference = applyHighlights(banner, rules);
|
|
||||||
var failed = 0;
|
|
||||||
var splitAt = (s, i) => [s.slice(0, i), s.slice(i)];
|
|
||||||
for (let i = 1; i < banner.length - 1; i++) {
|
|
||||||
const [a, b] = splitAt(banner, i);
|
|
||||||
const stream = new HighlightStream(rules);
|
|
||||||
const out = stream.push(a) + stream.push(b) + stream.flush();
|
|
||||||
if (out !== reference) {
|
|
||||||
failed++;
|
|
||||||
if (failed <= 3) {
|
|
||||||
console.log(`MISMATCH at split ${i}:`);
|
|
||||||
console.log(" got: " + JSON.stringify(out.slice(0, 120)));
|
|
||||||
console.log(" expected: " + JSON.stringify(reference.slice(0, 120)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const i = Math.floor(banner.length / 3);
|
|
||||||
const j = Math.floor(banner.length * 2 / 3);
|
|
||||||
const stream = new HighlightStream(rules);
|
|
||||||
const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush();
|
|
||||||
if (out !== reference) failed++;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const stream = new HighlightStream(rules);
|
|
||||||
let out = "";
|
|
||||||
for (const ch of banner) out += stream.push(ch);
|
|
||||||
out += stream.flush();
|
|
||||||
if (out !== reference) failed++;
|
|
||||||
}
|
|
||||||
if (failed === 0) {
|
|
||||||
console.log("SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference");
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user