From f0dbcf47697bb6d165bded15f32c7dafac3ee9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Platero?= Date: Mon, 9 Nov 2026 08:41:05 +0100 Subject: [PATCH 1/3] fix(whats-new): list fixes under "Bugs fixed" and never open a blank window --- .../contrib/picode/browser/picodeWhatsNew.ts | 6 ++++-- .../picode/browser/picodeWhatsNewData.ts | 11 +++++++++++ .../picode/browser/picodeWhatsNewWindow.ts | 18 +++++++++++++----- .../browser/test/picodeWhatsNewData.test.ts | 11 ++++++++++- src/vs/workbench/contrib/picode/l10n/cs.json | 1 + src/vs/workbench/contrib/picode/l10n/de.json | 1 + src/vs/workbench/contrib/picode/l10n/es.json | 1 + src/vs/workbench/contrib/picode/l10n/fr.json | 1 + src/vs/workbench/contrib/picode/l10n/it.json | 1 + src/vs/workbench/contrib/picode/l10n/ja.json | 1 + src/vs/workbench/contrib/picode/l10n/ko.json | 1 + src/vs/workbench/contrib/picode/l10n/pl.json | 1 + .../workbench/contrib/picode/l10n/pt-br.json | 1 + src/vs/workbench/contrib/picode/l10n/ru.json | 1 + .../workbench/contrib/picode/l10n/source.json | 1 + src/vs/workbench/contrib/picode/l10n/tr.json | 1 + .../workbench/contrib/picode/l10n/zh-cn.json | 1 + .../workbench/contrib/picode/l10n/zh-tw.json | 1 + 18 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/picode/browser/picodeWhatsNew.ts b/src/vs/workbench/contrib/picode/browser/picodeWhatsNew.ts index dad55018..a4be044e 100644 --- a/src/vs/workbench/contrib/picode/browser/picodeWhatsNew.ts +++ b/src/vs/workbench/contrib/picode/browser/picodeWhatsNew.ts @@ -20,7 +20,7 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; import { ILifecycleService, LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince, WhatsNewEntry } from './picodeWhatsNewData.js'; +import { composeWhatsNew, sameVersionLine, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince, WhatsNewEntry } from './picodeWhatsNewData.js'; import { whatsNewHighlights } from './picodeWhatsNewHighlights.js'; import { IPicodeProEntitlement } from './account/picodeProEntitlement.js'; import { IWhatsNewWindowServices, showWhatsNewWindow } from './picodeWhatsNewWindow.js'; @@ -66,7 +66,9 @@ async function showWhatsNew(version: string, last: string, accessor: { readonly const subtitle = last ? localize('picodeWhatsNew.subtitleSince', "New since {0}", last) : localize('picodeWhatsNew.subtitleAll', "Try each one right here."); - showWhatsNewWindow({ version, subtitle, highlights, changes, releaseUrl }, services); + // Inside one line of versions (2.0.0 to 2.0.1) the list is the fixes that came since: the page says so. + const fixesOnly = !manual && sameVersionLine(last, version); + showWhatsNewWindow({ version, subtitle, highlights, changes, fixesOnly, releaseUrl }, services); return true; } diff --git a/src/vs/workbench/contrib/picode/browser/picodeWhatsNewData.ts b/src/vs/workbench/contrib/picode/browser/picodeWhatsNewData.ts index fa3b4ea2..2c6a2c35 100644 --- a/src/vs/workbench/contrib/picode/browser/picodeWhatsNewData.ts +++ b/src/vs/workbench/contrib/picode/browser/picodeWhatsNewData.ts @@ -85,6 +85,17 @@ export function composeWhatsNew(entries: readonly WhatsNewEntry[]): string | und return entries.map(entry => `## ${entry.version}\n\n${entry.notes.trim()}`).join('\n\n'); } +/** + * Whether two versions are in the same line of news (`2.0.0` and `2.0.1`): the first two numbers + * decide it. The last one is for fixes: their list comes under "Bugs fixed", and the news of the + * line (the pages with a demo) only change when the line does. + */ +export function sameVersionLine(a: string, b: string): boolean { + const line = (version: string) => /^(\d+)\.(\d+)/.exec(version)?.slice(1, 3).join('.'); + const left = line(a); + return left !== undefined && left === line(b); +} + /** * Whether the window opens after this start. * diff --git a/src/vs/workbench/contrib/picode/browser/picodeWhatsNewWindow.ts b/src/vs/workbench/contrib/picode/browser/picodeWhatsNewWindow.ts index 169b7bce..e05991bf 100644 --- a/src/vs/workbench/contrib/picode/browser/picodeWhatsNewWindow.ts +++ b/src/vs/workbench/contrib/picode/browser/picodeWhatsNewWindow.ts @@ -33,6 +33,8 @@ export interface IWhatsNewWindowOptions { readonly highlights: readonly IWhatsNewHighlight[]; /** The releases' list of changes, as Markdown; it may arrive after the window opened. */ readonly changes: Promise; + /** The update stays inside one line of versions (2.0.0 to 2.0.1): that list is the bugs fixed since. */ + readonly fixesOnly: boolean; readonly releaseUrl: string; } @@ -94,6 +96,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW const highlights = [...options.highlights.filter(h => h.plan === 'pro'), ...options.highlights.filter(h => h.plan === 'free')]; let changes: string | undefined; let active = 0; + const changesTitle = () => options.fixesOnly ? localize('picodeWhatsNew.bugsFixed', "Bugs fixed") : localize('picodeWhatsNew.allChanges', "All changes"); const pageCount = () => highlights.length + (changes ? 1 : 0); const renderNav = () => { @@ -109,7 +112,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW navRow(highlight.icon, highlight.title, index); }); if (changes) { - navRow(Codicon.listUnordered, localize('picodeWhatsNew.allChanges', "All changes"), highlights.length); + navRow(Codicon.listUnordered, changesTitle(), highlights.length); } }; const navRow = (icon: ThemeIcon, label: string, index: number) => { @@ -134,7 +137,7 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW if (highlight) { renderHighlight(page, highlight, pageStore, services, close, render); } else if (changes) { - append(page, $('h2.picode-whatsnew-page-title')).textContent = localize('picodeWhatsNew.allChanges', "All changes"); + append(page, $('h2.picode-whatsnew-page-title')).textContent = changesTitle(); const rendered = services.markdownRendererService.render(new MarkdownString(changes)); pageStore.add(rendered); page.appendChild(rendered.element).classList.add('picode-whatsnew-changes'); @@ -186,9 +189,14 @@ export function showWhatsNewWindow(options: IWhatsNewWindowOptions, services: IW void options.changes.then(markdown => { if (markdown && !disposables.isDisposed) { changes = markdown; - // One more page in the list; the one being read (and its demo) is left as it is. - renderNav(); - renderFooter(); + if (highlights[active]) { + // One more page in the list; the one being read (and its demo) is left as it is. + renderNav(); + renderFooter(); + } else { + // Nothing of our own to show: this is the only page, and it was blank until now. + render(); + } } }); diff --git a/src/vs/workbench/contrib/picode/browser/test/picodeWhatsNewData.test.ts b/src/vs/workbench/contrib/picode/browser/test/picodeWhatsNewData.test.ts index 9817df42..f016f290 100644 --- a/src/vs/workbench/contrib/picode/browser/test/picodeWhatsNewData.test.ts +++ b/src/vs/workbench/contrib/picode/browser/test/picodeWhatsNewData.test.ts @@ -13,7 +13,7 @@ import assert from 'assert'; import { test } from 'node:test'; -import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince } from '../picodeWhatsNewData.js'; +import { composeWhatsNew, extractWhatChanged, highlightsSince, readHistory, sameVersionLine, shouldShowWhatsNew, whatsNewHistoryUrl, whatsNewSince } from '../picodeWhatsNewData.js'; test('returns the grouped summary without its heading or the sections after it', () => { const body = ['Channel text', '', '## What changed', '', '### ✨ New', '', '- A thing', '', '## What\'s Changed', '', '* raw changelog'].join('\n'); @@ -49,6 +49,15 @@ test('stays quiet on a first install', () => { assert.strictEqual(shouldShowWhatsNew({ last: '', current: '', editorLast: '1.142.14', editorCurrent: '1.142.15' }), false); }); +test('versions share their news when only the last number differs', () => { + assert.strictEqual(sameVersionLine('2.0.0-experimental', '2.0.1-experimental'), true); + assert.strictEqual(sameVersionLine('2.0.1', '2.0.12'), true); + assert.strictEqual(sameVersionLine('2.0.9', '2.1.0'), false); + assert.strictEqual(sameVersionLine('1.9.9', '2.0.0'), false); + assert.strictEqual(sameVersionLine('', '2.0.0'), false); + assert.strictEqual(sameVersionLine('junk', 'junk'), false); +}); + const entry = (version: string) => ({ version, notes: `- change in ${version}` }); test('after skipping versions, shows this one first and then each skipped one, newest first', () => { diff --git a/src/vs/workbench/contrib/picode/l10n/cs.json b/src/vs/workbench/contrib/picode/l10n/cs.json index 11d92993..5989ce28 100644 --- a/src/vs/workbench/contrib/picode/l10n/cs.json +++ b/src/vs/workbench/contrib/picode/l10n/cs.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "Probíhá", "picodeWhatsNew.board.done": "Hotovo", "picodeWhatsNew.board.todo": "K udělání", + "picodeWhatsNew.bugsFixed": "Opravené chyby", "picodeWhatsNew.close": "Zavřít", "picodeWhatsNew.command": "Zobrazit novinky", "picodeWhatsNew.connect": "Připojit GitHub nebo Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/de.json b/src/vs/workbench/contrib/picode/l10n/de.json index ca86afa0..93b19e86 100644 --- a/src/vs/workbench/contrib/picode/l10n/de.json +++ b/src/vs/workbench/contrib/picode/l10n/de.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "In Arbeit", "picodeWhatsNew.board.done": "Erledigt", "picodeWhatsNew.board.todo": "Zu erledigen", + "picodeWhatsNew.bugsFixed": "Behobene Fehler", "picodeWhatsNew.close": "Schließen", "picodeWhatsNew.command": "Neuigkeiten anzeigen", "picodeWhatsNew.connect": "GitHub oder Jira verbinden", diff --git a/src/vs/workbench/contrib/picode/l10n/es.json b/src/vs/workbench/contrib/picode/l10n/es.json index 0b58fa6e..a15b44d1 100644 --- a/src/vs/workbench/contrib/picode/l10n/es.json +++ b/src/vs/workbench/contrib/picode/l10n/es.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "En curso", "picodeWhatsNew.board.done": "Hecho", "picodeWhatsNew.board.todo": "Por hacer", + "picodeWhatsNew.bugsFixed": "Errores corregidos", "picodeWhatsNew.close": "Cerrar", "picodeWhatsNew.command": "Mostrar las novedades", "picodeWhatsNew.connect": "Conectar GitHub o Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/fr.json b/src/vs/workbench/contrib/picode/l10n/fr.json index 1eca554f..15c8b469 100644 --- a/src/vs/workbench/contrib/picode/l10n/fr.json +++ b/src/vs/workbench/contrib/picode/l10n/fr.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "En cours", "picodeWhatsNew.board.done": "Terminé", "picodeWhatsNew.board.todo": "À faire", + "picodeWhatsNew.bugsFixed": "Bugs corrigés", "picodeWhatsNew.close": "Fermer", "picodeWhatsNew.command": "Afficher les nouveautés", "picodeWhatsNew.connect": "Connecter GitHub ou Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/it.json b/src/vs/workbench/contrib/picode/l10n/it.json index ec4e0ba8..50484ca5 100644 --- a/src/vs/workbench/contrib/picode/l10n/it.json +++ b/src/vs/workbench/contrib/picode/l10n/it.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "In corso", "picodeWhatsNew.board.done": "Fatto", "picodeWhatsNew.board.todo": "Da fare", + "picodeWhatsNew.bugsFixed": "Bug risolti", "picodeWhatsNew.close": "Chiudi", "picodeWhatsNew.command": "Mostra le novità", "picodeWhatsNew.connect": "Connetti GitHub o Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/ja.json b/src/vs/workbench/contrib/picode/l10n/ja.json index e457d09c..952be6b9 100644 --- a/src/vs/workbench/contrib/picode/l10n/ja.json +++ b/src/vs/workbench/contrib/picode/l10n/ja.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "進行中", "picodeWhatsNew.board.done": "完了", "picodeWhatsNew.board.todo": "未着手", + "picodeWhatsNew.bugsFixed": "修正されたバグ", "picodeWhatsNew.close": "閉じる", "picodeWhatsNew.command": "新着情報を表示", "picodeWhatsNew.connect": "GitHub または Jira を接続", diff --git a/src/vs/workbench/contrib/picode/l10n/ko.json b/src/vs/workbench/contrib/picode/l10n/ko.json index c4d2a61e..52a447b0 100644 --- a/src/vs/workbench/contrib/picode/l10n/ko.json +++ b/src/vs/workbench/contrib/picode/l10n/ko.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "진행 중", "picodeWhatsNew.board.done": "완료", "picodeWhatsNew.board.todo": "할 일", + "picodeWhatsNew.bugsFixed": "수정된 버그", "picodeWhatsNew.close": "닫기", "picodeWhatsNew.command": "새 소식 보기", "picodeWhatsNew.connect": "GitHub 또는 Jira 연결", diff --git a/src/vs/workbench/contrib/picode/l10n/pl.json b/src/vs/workbench/contrib/picode/l10n/pl.json index 91db349f..11e1cbcb 100644 --- a/src/vs/workbench/contrib/picode/l10n/pl.json +++ b/src/vs/workbench/contrib/picode/l10n/pl.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "W toku", "picodeWhatsNew.board.done": "Gotowe", "picodeWhatsNew.board.todo": "Do zrobienia", + "picodeWhatsNew.bugsFixed": "Naprawione błędy", "picodeWhatsNew.close": "Zamknij", "picodeWhatsNew.command": "Pokaż nowości", "picodeWhatsNew.connect": "Połącz GitHub lub Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/pt-br.json b/src/vs/workbench/contrib/picode/l10n/pt-br.json index 2a6aa467..de3f5802 100644 --- a/src/vs/workbench/contrib/picode/l10n/pt-br.json +++ b/src/vs/workbench/contrib/picode/l10n/pt-br.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "Em andamento", "picodeWhatsNew.board.done": "Concluído", "picodeWhatsNew.board.todo": "A fazer", + "picodeWhatsNew.bugsFixed": "Bugs corrigidos", "picodeWhatsNew.close": "Fechar", "picodeWhatsNew.command": "Mostrar novidades", "picodeWhatsNew.connect": "Conectar GitHub ou Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/ru.json b/src/vs/workbench/contrib/picode/l10n/ru.json index 8db0b2ec..c827ed56 100644 --- a/src/vs/workbench/contrib/picode/l10n/ru.json +++ b/src/vs/workbench/contrib/picode/l10n/ru.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "В работе", "picodeWhatsNew.board.done": "Готово", "picodeWhatsNew.board.todo": "К выполнению", + "picodeWhatsNew.bugsFixed": "Исправленные ошибки", "picodeWhatsNew.close": "Закрыть", "picodeWhatsNew.command": "Показать «Что нового»", "picodeWhatsNew.connect": "Подключить GitHub или Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/source.json b/src/vs/workbench/contrib/picode/l10n/source.json index b74e338b..49931d0b 100644 --- a/src/vs/workbench/contrib/picode/l10n/source.json +++ b/src/vs/workbench/contrib/picode/l10n/source.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "In progress", "picodeWhatsNew.board.done": "Done", "picodeWhatsNew.board.todo": "To do", + "picodeWhatsNew.bugsFixed": "Bugs fixed", "picodeWhatsNew.close": "Close", "picodeWhatsNew.command": "Show What's New", "picodeWhatsNew.connect": "Connect GitHub or Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/tr.json b/src/vs/workbench/contrib/picode/l10n/tr.json index d5fdcc51..83f207de 100644 --- a/src/vs/workbench/contrib/picode/l10n/tr.json +++ b/src/vs/workbench/contrib/picode/l10n/tr.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "Devam ediyor", "picodeWhatsNew.board.done": "Bitti", "picodeWhatsNew.board.todo": "Yapılacak", + "picodeWhatsNew.bugsFixed": "Düzeltilen hatalar", "picodeWhatsNew.close": "Kapat", "picodeWhatsNew.command": "Yenilikleri göster", "picodeWhatsNew.connect": "GitHub veya Jira’yı bağla", diff --git a/src/vs/workbench/contrib/picode/l10n/zh-cn.json b/src/vs/workbench/contrib/picode/l10n/zh-cn.json index 50a70bec..8be10800 100644 --- a/src/vs/workbench/contrib/picode/l10n/zh-cn.json +++ b/src/vs/workbench/contrib/picode/l10n/zh-cn.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "进行中", "picodeWhatsNew.board.done": "已完成", "picodeWhatsNew.board.todo": "待办", + "picodeWhatsNew.bugsFixed": "已修复的问题", "picodeWhatsNew.close": "关闭", "picodeWhatsNew.command": "显示新增功能", "picodeWhatsNew.connect": "连接 GitHub 或 Jira", diff --git a/src/vs/workbench/contrib/picode/l10n/zh-tw.json b/src/vs/workbench/contrib/picode/l10n/zh-tw.json index 9694050e..531b4a22 100644 --- a/src/vs/workbench/contrib/picode/l10n/zh-tw.json +++ b/src/vs/workbench/contrib/picode/l10n/zh-tw.json @@ -637,6 +637,7 @@ "picodeWhatsNew.board.doing": "進行中", "picodeWhatsNew.board.done": "已完成", "picodeWhatsNew.board.todo": "待辦", + "picodeWhatsNew.bugsFixed": "已修復的問題", "picodeWhatsNew.close": "關閉", "picodeWhatsNew.command": "顯示新功能", "picodeWhatsNew.connect": "連接 GitHub 或 Jira", From 711db0a931b85846557670a860b21a357ca4cdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Platero?= Date: Mon, 9 Nov 2026 08:41:06 +0100 Subject: [PATCH 2/3] fix(update): stop the memory engine running from the install folder before installing --- AGENTS.md | 1 + .../picode/test/update-memory-engine.test.ts | 83 ++++++++++++++++ .../electron-main/picodeMemoryEngine.ts | 96 +++++++++++++++++++ .../electron-main/updateService.win32.ts | 18 ++++ 4 files changed, 198 insertions(+) create mode 100644 extensions/picode/test/update-memory-engine.test.ts create mode 100644 src/vs/platform/update/electron-main/picodeMemoryEngine.ts diff --git a/AGENTS.md b/AGENTS.md index 07f41d64..29440e80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,3 +499,4 @@ Si algo cambia en el proyecto, se actualiza la frase o se añade una nueva, pero * **2026-11-08** · Fuera el selector de permisos del chat («Sí, quítalo»): *Ask / Allow all* prometía una elección que no existía, porque pi ejecuta sin preguntar. Los MCP conectados en el propio editor siguen preguntando (ADR-040, adenda). La web y los términos ya dicen que el agente actúa sin preguntar. * **2026-11-09** · Conectar Jira ya no depende del navegador («no conecta jira», «Sí, arréglalo»): Edge no abría el editor al pulsar *Open PiCode*. Ahora el servidor sigue el intento y el editor le pregunta cómo va, como en GitHub. La página de vuelta enseña a qué cuenta se conecta y pide confirmar, para que nadie pueda llevarse el Jira de otra persona con un enlace (ADR-042, adenda 12). * **2026-11-09** · Release 2.0.1 en experimental («saca release no? para que probar esto»): lleva la conexión con Jira sin depender del navegador y las builds locales que ya no le quitan los enlaces a la copia instalada. +* **2026-11-09** · Las novedades salen también con los arreglos, con una parte «Bugs fixed» («de la 2.0.0 a la 2.0.1 mantenlo y vas añadiendo en una parte bugs arreglados»): las páginas con demo cambian solo al pasar de línea (2.0 a 2.1, con lo que tú me digas que lleva), y la última cifra son arreglos. Además, la ventana ya no sale en blanco cuando la versión no trae páginas propias. Y al actualizar, el editor cierra el motor de memoria que corre desde su carpeta justo antes de instalar («esto no es lo suyo»): se quedaba abierto y el actualizador no podía borrar `resources`. Solo cierra el de su propia carpeta; el de otro programa no se toca. diff --git a/extensions/picode/test/update-memory-engine.test.ts b/extensions/picode/test/update-memory-engine.test.ts new file mode 100644 index 00000000..2e3bd331 --- /dev/null +++ b/extensions/picode/test/update-memory-engine.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) PiCode. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Closing the memory engine that PiCode carries before an update replaces its folder, run on its own. + * + * What matters: an engine running from the installation's own folder is stopped (whatever the case + * of the drive letter), and nothing else is: not the one another program installed, not a folder + * with a similar name, not an answer that is not a process. + */ + +import assert from 'assert'; +import { test } from 'node:test'; +import { isInsideFolder, memoryFolderOf, readRunningEngines, stopEnginesInside } from '../../../src/vs/platform/update/electron-main/picodeMemoryEngine.ts'; + +const INSTALL = 'C:\\Users\\tapla\\AppData\\Local\\Programs\\PiCode Experimental-win32-x64-experimental'; +const MEMORY = `${INSTALL}\\resources\\memory`; + +function processes(running: { pid: number; path: string }[], failing: number[] = []) { + const killed: number[] = []; + return { + killed, + io: { + list: () => running, + kill: (pid: number) => { + if (failing.includes(pid)) { + throw new Error('already gone'); + } + killed.push(pid); + } + } + }; +} + +test('the memory folder is the one next to the editor code', () => { + assert.strictEqual(memoryFolderOf(`${INSTALL}\\resources\\app`), MEMORY); +}); + +test('an engine running from the installation is stopped, whatever the case of its drive', () => { + const { io, killed } = processes([{ pid: 15460, path: MEMORY.replace('C:', 'c:') + '\\engram.exe' }]); + assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [15460]); + assert.deepStrictEqual(killed, [15460]); +}); + +test('the engine another program installed is left alone', () => { + const { io, killed } = processes([ + { pid: 28732, path: 'C:\\Users\\tapla\\AppData\\Local\\engram\\bin\\engram.exe' }, + { pid: 15460, path: `${MEMORY}\\engram.exe` }, + ]); + assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [15460]); + assert.deepStrictEqual(killed, [15460]); +}); + +test('a folder that only starts with the same name is not the installation', () => { + assert.strictEqual(isInsideFolder(`${MEMORY}-old\\engram.exe`, MEMORY, true), false); + assert.strictEqual(isInsideFolder(`${MEMORY}\\engram.exe`, MEMORY, true), true); + assert.strictEqual(isInsideFolder(`${MEMORY}\\`, MEMORY, true), false); +}); + +test('one that cannot be stopped does not keep the others from stopping', () => { + const { io, killed } = processes([ + { pid: 1, path: `${MEMORY}\\engram.exe` }, + { pid: 2, path: `${MEMORY}\\engram.exe` }, + ], [1]); + assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), [2]); + assert.deepStrictEqual(killed, [2]); +}); + +test('nothing running means nothing to stop', () => { + const { io, killed } = processes([]); + assert.deepStrictEqual(stopEnginesInside(MEMORY, io, true), []); + assert.deepStrictEqual(killed, []); +}); + +test('the system listing is read one process per line, and anything else is ignored', () => { + const output = `15460|${MEMORY}\\engram.exe\r\n\r\nnot a process\r\n|${MEMORY}\\x.exe\r\n0|${MEMORY}\\x.exe\r\nabc|${MEMORY}\\x.exe\r\n28732|C:\\other\\engram.exe\r\n`; + assert.deepStrictEqual(readRunningEngines(output), [ + { pid: 15460, path: `${MEMORY}\\engram.exe` }, + { pid: 28732, path: 'C:\\other\\engram.exe' }, + ]); +}); diff --git a/src/vs/platform/update/electron-main/picodeMemoryEngine.ts b/src/vs/platform/update/electron-main/picodeMemoryEngine.ts new file mode 100644 index 00000000..68f75f7c --- /dev/null +++ b/src/vs/platform/update/electron-main/picodeMemoryEngine.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) PiCode. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Closing PiCode's own memory engine before an update replaces the folder it runs from. + * + * The engine travels inside the installation (`/resources/memory/engram.exe`). The window that + * started it stops it when it closes, but when the editor quits to update, that window is told to + * stop and exits in a fraction of a second: an engine that outlives it keeps its files open, and the + * updater then cannot delete the folder ("Access denied"). So the main process, once every window is + * gone and right before the installer starts, closes any engine running from the folder being + * replaced. Only by where the program is: another program's engine (one installed on its own, under + * its own folder) is never touched, and neither is anything that is not the engine. + * + * Kept free of other PiCode modules so a test can load it on its own (`node --test`). + */ + +import { execFileSync } from 'child_process'; +import { dirname, join, resolve } from 'path'; + +/** A process running the engine, with the program it was started from. */ +export interface IRunningEngine { + readonly pid: number; + readonly path: string; +} + +export interface IMemoryEngineProcesses { + list(): readonly IRunningEngine[]; + kill(pid: number): void; +} + +/** Where the installation's engine lives, from the folder the editor's code is in (`/resources/app`). */ +export function memoryFolderOf(appRoot: string): string { + return join(dirname(appRoot), 'memory'); +} + +/** Whether `file` is inside `folder`. Windows paths are compared without regard to case. */ +export function isInsideFolder(file: string, folder: string, ignoreCase: boolean): boolean { + const normal = (value: string) => { + const full = resolve(value).replace(/[\\/]+$/, ''); + return ignoreCase ? full.toLowerCase() : full; + }; + const inside = normal(folder); + const candidate = normal(file); + return candidate.startsWith(inside + '\\') || candidate.startsWith(inside + '/'); +} + +/** Stops every engine started from `folder` and returns their process ids. A failure to stop one never stops the others. */ +export function stopEnginesInside(folder: string, processes: IMemoryEngineProcesses, ignoreCase = process.platform === 'win32'): number[] { + const stopped: number[] = []; + for (const engine of processes.list()) { + if (!Number.isInteger(engine.pid) || engine.pid <= 0 || !isInsideFolder(engine.path, folder, ignoreCase)) { + continue; + } + try { + processes.kill(engine.pid); + stopped.push(engine.pid); + } catch { + // Already gone, or not ours to stop: the updater will say so if it still cannot go on. + } + } + return stopped; +} + +/** Reads the output of the system listing: one `pid|path` per line, anything else is ignored. */ +export function readRunningEngines(output: string): IRunningEngine[] { + const engines: IRunningEngine[] = []; + for (const line of output.split(/\r?\n/)) { + const bar = line.indexOf('|'); + const pid = Number(line.slice(0, bar)); + const path = line.slice(bar + 1).trim(); + if (bar > 0 && Number.isInteger(pid) && pid > 0 && path !== '') { + engines.push({ pid, path }); + } + } + return engines; +} + +/** The real thing, on Windows: the engines the system is running, and a way to stop one. */ +export const systemMemoryEngines: IMemoryEngineProcesses = { + list: () => { + try { + const output = execFileSync('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-Command', + // Where the program is comes from the system, not from anything the engine says about itself. + 'Get-Process -Name engram -ErrorAction SilentlyContinue | Where-Object { $_.Path } | ForEach-Object { \'{0}|{1}\' -f $_.Id, $_.Path }' + ], { encoding: 'utf8', windowsHide: true, timeout: 8000 }); + return readRunningEngines(output); + } catch { + return []; + } + }, + kill: pid => process.kill(pid) +}; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 8db1e9a0..49755200 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -24,6 +24,7 @@ import { killTree } from '../../../base/node/processes.js'; import { getWindowsRelease } from '../../../base/node/windowsVersion.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; +import { memoryFolderOf, stopEnginesInside, systemMemoryEngines } from './picodeMemoryEngine.js'; import { IFileService } from '../../files/common/files.js'; import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions, LifecycleMainPhase } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; @@ -845,12 +846,29 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate = undefined; } + /** + * PiCode: every window is gone by now, and the installer is about to replace the folder the memory + * engine runs from. One that outlived its window would keep those files open and the update would + * stop at "Access denied", so it is closed here, by where it runs from, before anything else. + */ + private stopMemoryEngine(): void { + try { + const stopped = stopEnginesInside(memoryFolderOf(this.environmentMainService.appRoot), systemMemoryEngines); + if (stopped.length > 0) { + this.logService.info(`update#quitAndInstall(): stopped the memory engine still running from this installation (${stopped.join(', ')})`); + } + } catch (error) { + this.logService.warn('update#quitAndInstall(): could not check for a memory engine still running', error); + } + } + protected override doQuitAndInstall(): void { if ((this.state.type !== StateType.Ready && this.state.type !== StateType.Restarting) || !this.availableUpdate) { return; } this.logService.trace('update#quitAndInstall(): running raw#quitAndInstall()'); + this.stopMemoryEngine(); // The portable build has no installer to run: the staged copy is swapped in // by its script once this process quits, and the new build starts itself. From 2c83339713b793057d521b5c0d184582a486ed96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Platero?= Date: Mon, 9 Nov 2026 08:41:07 +0100 Subject: [PATCH 3/3] release: v2.0.2-experimental --- package.json | 2 +- product.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a51ef731..770b39f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.144.10", + "version": "1.144.11", "distro": "c842171bd42ca4aef20b0a186c94d99edd763842", "author": { "name": "PiCode" diff --git a/product.json b/product.json index 307ce73a..c27e52eb 100644 --- a/product.json +++ b/product.json @@ -758,7 +758,7 @@ "default": false } }, - "version": "1.144.10", - "picodeVersion": "2.0.1-experimental", + "version": "1.144.11", + "picodeVersion": "2.0.2-experimental", "hotfixPublicKey": "MCowBQYDK2VwAyEAZes8irGrgqOvvCEWRBn3rwOG6qxSNp+zxWOX3HqXyjo=" }