fix(desktop): adopt an unmarked copy of a package's own desktop half

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 <tuancanhnguyen706@gmail.com>
This commit is contained in:
Austin Pickett
2026-09-24 11:19:18 -04:00
parent 0d914a0652
commit 4dc7a23690
2 changed files with 67 additions and 17 deletions

View File

@@ -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')

View File

@@ -146,12 +146,28 @@ async function readMarker(dir: string): Promise<DesktopHalfMarker | null> {
}
}
/** 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<boolean> {
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<void> {
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
* `<appRoot>/<packageName>/`, 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
}