From 4dc7a23690bbf242aaa6ecb8351abce97cb0ad3d Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 24 Sep 2026 11:19:18 -0400 Subject: [PATCH] fix(desktop): adopt an unmarked copy of a package's own desktop half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folders already installed by shipped builds have no marker, and reconcile refused them on every pass, so the stuck "copying…" row never recovered. Reconcile now stamps such a folder in place when its `plugin.js` is byte for byte the package's own half — that can only be our pre-marker copy, and adopting it writes no files. Anything that differs is a standalone plugin the user installed and is still never touched (#112450). Co-authored-by: xxxigm --- .../electron/desktop-plugins-root.test.ts | 19 ++++++ apps/desktop/electron/desktop-plugins-root.ts | 65 ++++++++++++++----- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/apps/desktop/electron/desktop-plugins-root.test.ts b/apps/desktop/electron/desktop-plugins-root.test.ts index 086c439e61..49b7a17b45 100644 --- a/apps/desktop/electron/desktop-plugins-root.test.ts +++ b/apps/desktop/electron/desktop-plugins-root.test.ts @@ -182,6 +182,25 @@ describe('reconcileUnifiedDesktopHalves', () => { expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('user standalone') }) + it('adopts an unmarked copy of its own half that a pre-marker install left behind', async () => { + const home = makeHome() + const appRoot = path.join(home, 'desktop-plugins') + // What `installDesktopPluginFromGit` used to publish: the package's half, + // byte for byte, with no marker. The page showed it as a second, enabled + // row while the agent row waited on "copying…". + write(path.join(home, 'plugins', 'media', 'desktop', 'plugin.js'), 'package half') + write(path.join(appRoot, 'media', 'plugin.js'), 'package half') + + expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'media')]) + + const marker = JSON.parse(fs.readFileSync(path.join(appRoot, 'media', PACKAGE_MARKER), 'utf8')) + expect(marker.package).toBe('media') + // Adoption is a stamp, not a copy: the folder's own files are untouched and + // the next pass has nothing left to do. + expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('package half') + expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([]) + }) + it('replaces an interrupted marker-less copy, stamps it, and converges on retry', async () => { const home = makeHome() const appRoot = path.join(home, 'desktop-plugins') diff --git a/apps/desktop/electron/desktop-plugins-root.ts b/apps/desktop/electron/desktop-plugins-root.ts index 5e84d059d4..7b85676c63 100644 --- a/apps/desktop/electron/desktop-plugins-root.ts +++ b/apps/desktop/electron/desktop-plugins-root.ts @@ -146,12 +146,28 @@ async function readMarker(dir: string): Promise { } } +/** Same bytes on both paths. Unreadable either side answers `false` — a + * comparison we cannot make is never evidence of a match. */ +async function sameFile(a: string, b: string): Promise { + try { + const [left, right] = await Promise.all([fs.promises.readFile(a), fs.promises.readFile(b)]) + + return left.equals(right) + } catch { + return false + } +} + +/** Write the marker into a desktop-half folder. The one place that serializes + * it, so the git installer and this reconcile cannot drift. */ +export async function writeDesktopHalfMarker(dir: string, marker: DesktopHalfMarker): Promise { + await fs.promises.writeFile(path.join(dir, PACKAGE_MARKER), JSON.stringify(marker, null, 2) + '\n') +} + /** Copy one unified package's `desktop/` half into the app root as * `//`, stamping the marker. Skips when the root copy is - * already current for this source; replaces it when the source is newer. A - * root folder of the same name WITHOUT a marker is a standalone install the - * user made on purpose and is never overwritten. Returns the target path - * when a copy happened. */ + * already current for this source; replaces it when the source is newer. + * Returns the target path when a copy (or an adoption) happened. */ export async function materializeDesktopHalf( packageDir: string, appRoot: string, @@ -179,12 +195,36 @@ export async function materializeDesktopHalf( const target = path.join(appRoot, packageName) const existing = await readMarker(target) + const marker: DesktopHalfMarker = { + package: packageName, + source: sourceDir, + sourceMtimeMs: stat.mtimeMs, + ...(await packageOrigin(packageDir)) + } + if (fs.existsSync(target)) { if (!existing) { - // A real standalone install has its entry point. A marker-less folder - // without one is an interrupted unified-package copy: the old copy - // wrote the marker last, so leaving it here would block every retry. + // A marker-less folder carrying the entry point is either a standalone + // plugin the user installed on purpose (never touch it) or a desktop half + // this app copied out before it stamped markers — `installDesktopPluginFromGit` + // published without one, which left the Plugins page waiting on "copying…" + // beside a second, already-enabled row, forever, because this function + // then refused the folder on every pass. + // + // Identical entry points tell the two apart: our own copy of this + // package's half still matches it byte for byte, so adopting it is a + // no-op on disk — stamp the marker in place and the row pairs, with the + // opt-in posture a marker implies. Anything that differs is the user's + // and is left exactly as it was (#112450). A marker-less folder with no + // entry point is an interrupted copy (the marker is written last) and is + // replaced as before. if (fs.existsSync(path.join(target, 'plugin.js'))) { + if (await sameFile(path.join(target, 'plugin.js'), entry)) { + await writeDesktopHalfMarker(target, marker) + + return target + } + return null } } @@ -194,16 +234,7 @@ export async function materializeDesktopHalf( } } - const marker: DesktopHalfMarker = { - package: packageName, - source: sourceDir, - sourceMtimeMs: stat.mtimeMs, - ...(await packageOrigin(packageDir)) - } - - await publishDesktopTree(sourceDir, target, staged => - fs.promises.writeFile(path.join(staged, PACKAGE_MARKER), JSON.stringify(marker, null, 2) + '\n') - ) + await publishDesktopTree(sourceDir, target, staged => writeDesktopHalfMarker(staged, marker)) return target }