diff --git a/scripts/release.cjs b/scripts/release.cjs index 3e3eed3..871603a 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -31,6 +31,22 @@ const files = [ for (const f of files) { if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) } } +// Guard rails: the artifacts must belong to the version being published, and +// the tag must not exist yet — otherwise a re-run silently republishes a +// *different* binary under an already-released version number, and installed +// clients never see it (their version check compares numbers, not hashes). +const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version +if (pkgVersion !== V) { + console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`) + process.exit(1) +} +const ymlPath = path.join(R, 'latest.yml') +if (!fs.existsSync(ymlPath)) { console.error('missing release/latest.yml — run npm run dist first'); process.exit(1) } +const ymlVersion = fs.readFileSync(ymlPath, 'utf8').match(/^version:\s*(\S+)/m)?.[1] +if (ymlVersion !== V) { + console.error(`release/latest.yml says ${ymlVersion} but you asked to publish ${V} — rebuild first`) + process.exit(1) +} const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY if (proxy) { @@ -53,7 +69,10 @@ async function upload(url, file, token, authScheme) { duplex: 'half' }) console.log(` upload ${name}: HTTP ${resp.status}`) - if (!resp.ok) console.log(' ', (await resp.text()).slice(0, 300)) + if (!resp.ok) { + const body = (await resp.text()).slice(0, 300) + throw new Error(`upload failed (${resp.status}) for ${name}: ${body}`) + } } async function gitea() { @@ -64,7 +83,7 @@ async function gitea() { headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }) }) - if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return } + if (!resp.ok) { throw new Error(`Gitea release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) } const rel = await resp.json() console.log('release created, id =', rel.id) for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token') @@ -73,8 +92,6 @@ async function gitea() { async function giteaChannel() { console.log('=== Gitea update channel ===') const base = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable' - const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } }) - console.log(' delete old stable:', del.status) const channelFiles = [ [path.join(R, 'latest.yml'), 'latest.yml'], [path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`], @@ -83,7 +100,20 @@ async function giteaChannel() { // releases API 404s), but the generic package is publicly readable. [path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md'] ] - for (const [f, name] of channelFiles) { + // Validate before touching the channel: a missing file after the DELETE would + // leave the update channel empty (clients then fall back to GitHub, which is + // unreachable in China for most users). + for (const [f] of channelFiles) { + if (!fs.existsSync(f)) throw new Error(`missing channel file: ${f}`) + } + // latest.yml goes last: it is what makes clients start downloading, so the + // payload must already be in place. + const isLatest = ([f]) => path.basename(f) === 'latest.yml' + const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)] + // Replace the version only once every file is known to be present on disk. + const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } }) + console.log(' delete old stable:', del.status) + for (const [f, name] of ordered) { const stat = fs.statSync(f) const resp = await fetch(`${base}/${encodeURIComponent(name)}`, { method: 'PUT', @@ -92,6 +122,7 @@ async function giteaChannel() { duplex: 'half' }) console.log(` put ${name}: HTTP ${resp.status}`) + if (!resp.ok) throw new Error(`channel put failed (${resp.status}) for ${name}: ${(await resp.text()).slice(0, 200)}`) } } @@ -107,7 +138,7 @@ async function github() { }, body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }) }) - if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return } + if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) } const rel = await resp.json() console.log('release created, id =', rel.id) const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets` diff --git a/src/main/pty.ts b/src/main/pty.ts index 4f79c07..a61a839 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -173,6 +173,9 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { pty.onExit(({ exitCode }) => { try { sessions.delete(id) + // The session is gone: drop its replay buffer too (killPty was the only + // path that did, so naturally-exiting shells leaked up to 64KB each). + replayBuffers.delete(id) safeStopLog(id) broadcast(Ipc.PTY_EXIT, { id, exitCode }) } catch { @@ -258,6 +261,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin detachZmodem(handle.id) safeStopLog(handle.id) sessions.delete(handle.id) + replayBuffers.delete(handle.id) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) } catch { // never crash the event loop diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index cef9eb1..a2fe2b4 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -21,7 +21,9 @@ const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false, globalShowHide: '', - closeAction: 'ask', + // Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made + // a fresh install prompt on close while the docs and UI promised tray. + closeAction: 'tray', autoCheckUpdate: true } @@ -43,9 +45,11 @@ function isHighlightRule(value: unknown): value is HighlightRule { } function sanitizeRules(value: unknown): HighlightRule[] { + // An explicit empty array is a valid choice ("no highlighting"); only + // malformed data falls back to the built-in rules. Returning the defaults for + // [] made deleting the last rule look like it silently failed. if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES - const rules = value.filter(isHighlightRule) - return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES + return value.filter(isHighlightRule) } function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { diff --git a/src/main/sftp.ts b/src/main/sftp.ts index 07fd3bf..c7ca88a 100644 --- a/src/main/sftp.ts +++ b/src/main/sftp.ts @@ -195,13 +195,23 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise * chmod/chown run over an exec channel: the JD test server's sftp subsystem * accepts SETSTAT but silently ignores it, while shell chmod/chown work. */ +/** + * POSIX single-quote escaping. `JSON.stringify` only escapes `"`, so a remote + * file name containing `$`, a backtick or a quote would still be expanded by the + * far-side shell — that is remote command execution triggered by a file name. + */ +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + export function chmodRemote(sessionId: string, path: string, mode: string): Promise { if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`) - return execQuiet(sessionId, `chmod ${mode} ${JSON.stringify(path)}`) + return execQuiet(sessionId, `chmod ${mode} ${shQuote(path)}`) } export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise { - return execQuiet(sessionId, `chown ${uid}:${gid} ${JSON.stringify(path)}`) + if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error('非法 uid/gid') + return execQuiet(sessionId, `chown ${uid}:${gid} ${shQuote(path)}`) } /** Run a command on the session's shell channel and wait for it to finish. */ diff --git a/src/main/zmodem.ts b/src/main/zmodem.ts index 3446ff3..1d3e4e8 100644 --- a/src/main/zmodem.ts +++ b/src/main/zmodem.ts @@ -309,6 +309,17 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void { engine.mode = role === 'receive' ? 'receive' : 'send' engine.detection = detection engine.active = true + // A detect is the start of a fresh transfer on this session, so the + // one-shot flags of the previous one must go with it. Leaving `confirmed` + // set made the *second* `sz`/`rz` on a session a no-op: respondZmodem + // bailed out, the engine stayed `active` (swallowing every keystroke and + // all terminal data) until the stall timer finally fired. + engine.confirmed = false + engine.progressEmitted = false + engine.doneEmitted = false + engine.dir = null + engine.session = null + engine.receiveStream = null engine.transferId = `zm-${sessionId}` emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 }) try { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8289210..9cad413 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,7 +121,7 @@ const api: AppApi = { updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK), updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD), - updateInstall: () => ipcRenderer.send(Ipc.UPDATE_INSTALL), + updateInstall: () => ipcRenderer.invoke(Ipc.UPDATE_INSTALL), updateChangelog: () => ipcRenderer.invoke(Ipc.UPDATE_CHANGELOG), onUpdateState: (cb: (s: UpdateState) => void) => { const listener = (_: unknown, s: UpdateState): void => cb(s) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a7fdabc..ec927dc 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -89,7 +89,7 @@ if (typeof window !== 'undefined' && !window.api) { deleteLayout: async () => undefined, updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }), updateDownload: async () => undefined, - updateInstall: noop, + updateInstall: async () => undefined, updateChangelog: async () => [], onUpdateState: () => noop } diff --git a/src/renderer/src/settings/AboutTab.tsx b/src/renderer/src/settings/AboutTab.tsx index 530b63b..f9c35dc 100644 --- a/src/renderer/src/settings/AboutTab.tsx +++ b/src/renderer/src/settings/AboutTab.tsx @@ -67,7 +67,7 @@ export function AboutTab(): React.JSX.Element { )} {status === 'downloaded' && ( - )} diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index b62f3d6..c71ce8b 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -154,7 +154,7 @@ function stepLineBuffer( // clear-line) only removes buffer content; never appends. const first = data[0] if (b === '' && first === '\x1b') return { line: '' } - if (first === '\r') return submitOr(b) + if (first === '\r') return submitOr(outBuf, b) if (first === '\x7f') { outBuf.current = b.slice(0, -1) return { line: outBuf.current } @@ -168,8 +168,12 @@ function stepLineBuffer( return { line: outBuf.current } } -function submitOr(b: string): { line: string; submit?: string } { +function submitOr(outBuf: React.MutableRefObject, b: string): { line: string; submit?: string } { const t = b.trim() + // Commit the line out of the buffer: the shell has it now, and anything typed + // next belongs to a fresh line. Leaving it in made every following command + // accumulate onto the previous one (`ls` + `pwd` → `lspwd` in the history). + outBuf.current = '' return t ? { line: '', submit: t } : { line: '', submit: '' } } @@ -684,6 +688,21 @@ export const TerminalView: ForwardRefExoticComponent 0) { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + } + return + } // M5: line capture → recordCommand + inline completion overlay. const step = stepLineBuffer(lineBufRef, data, consumeRewrite) if (typeof step.submit === 'string' && step.submit) { @@ -810,6 +829,9 @@ export const TerminalView: ForwardRefExoticComponent() + for (const existing of api.panels) { + const sid = sessionIdOf(existing) + if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1) + } + const countPanel = (panel: IDockviewPanel): void => { + const sid = sessionIdOf(panel) + if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1) + } + const releaseSession = (panel: IDockviewPanel): void => { + const sid = sessionIdOf(panel) + if (!sid) return + const left = (useCount.get(sid) ?? 1) - 1 + if (left > 0) { + useCount.set(sid, left) + return + } + useCount.delete(sid) + killSession(sid) + } + const cleanups = [ api.onDidRemovePanel((panel: IDockviewPanel) => { - killSession(sessionIdOf(panel)) + releaseSession(panel) recomputeLocalPanels() recountAll() }), @@ -261,7 +287,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX recomputeLocalPanels() recountAll() }), - api.onDidAddPanel(() => recountAll()) + api.onDidAddPanel((panel: IDockviewPanel) => { + countPanel(panel) + recountAll() + }) ] disposablesRef.current.push(...cleanups) }, diff --git a/src/shared/api.ts b/src/shared/api.ts index 8abefb3..0cddacc 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -110,7 +110,7 @@ export interface AppApi { // ---- updater (domestic feed first, GitHub fallback) ---- updateCheck(): Promise updateDownload(): Promise - updateInstall(): void + updateInstall(): Promise updateChangelog(): Promise onUpdateState(cb: (s: UpdateState) => void): () => void }