Procházet zdrojové kódy

feat(desktop): update packaging and auto-update paths for target-specific builds

07akioni před 1 týdnem
rodič
revize
22461aedb0

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: dc21fc568c2aa416ade9792823becd087a9dff4b
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 6928e86db7f184b90aa47005f9c16ed86e0730e7
+2026-08-25-electron-desktop-packaging-and-updates.md: 3a0a23074615cec6fc4f30c995679b4670660e98
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0b520aeb7fa341e4980c7955f645a6e45b116d23

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md


+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 40c0508ed5102908edd12601596fc016f39323e7
-README.zh.md: 78c3a34e729c242969a6d85f3d20170078c2720d
+README.md: 8377a26dc1348e943954f898acc7a13001d9802a
+README.zh.md: f6e14bd213f386f1f4047ef9cdf61e1f73a6b64b

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 1
apps/desktop/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 1
apps/desktop/README.zh.md


+ 7 - 0
apps/desktop/electron-builder.config.d.mts

@@ -1,6 +1,13 @@
 /** Electron-builder fields asserted by the Desktop release tests. */
 export interface DesktopElectronBuilderConfig {
   readonly appId: string
+  readonly directories: {
+    readonly output: string
+  }
+  readonly extraResources: readonly [
+    { readonly from: string, readonly to: 'runtime' },
+    { readonly from: string, readonly to: 'seed' },
+  ]
   readonly mac: {
     readonly identity: string | undefined
     readonly forceCodeSigning: boolean

+ 5 - 3
apps/desktop/electron-builder.config.mjs

@@ -10,6 +10,7 @@ import {
   installWindowsNsisBootstrapSigner,
 } from './scripts/windows-sign.mjs'
 import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
+import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs'
 
 /**
  * Create electron-builder configuration from one release environment.
@@ -43,11 +44,12 @@ export function createElectronBuilderConfig(
     installWindowsNsisBootstrapSigner({ sign: windowsSigner })
   }
   const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
+  const buildPaths = desktopTargetBuildPaths(update.target)
   return {
     appId,
     productName: 'DeepSeek Harness',
     artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
-    directories: { output: '.desktop-build/artifacts' },
+    directories: { output: buildPaths.artifacts },
     asar: true,
     files: [
       'lib/*.js',
@@ -56,8 +58,8 @@ export function createElectronBuilderConfig(
       'package.json',
     ],
     extraResources: [
-      { from: '.desktop-build/runtime', to: 'runtime' },
-      { from: '.desktop-build/seed', to: 'seed' },
+      { from: buildPaths.runtime, to: 'runtime' },
+      { from: buildPaths.seed, to: 'seed' },
     ],
     mac: {
       category: 'public.app-category.developer-tools',

+ 11 - 0
apps/desktop/scripts/desktop-auto-update-environment.d.mts

@@ -50,6 +50,17 @@ export function resolveDesktopAutoUpdateTarget(
  */
 export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): string
 
+/**
+ * Return the electron-builder channel metadata filename for an application version.
+ * @param version - Desktop semantic version.
+ * @param platform - Target platform.
+ * @returns Channel metadata filename emitted for the target.
+ */
+export function desktopUpdateMetadataFilename(
+  version: string,
+  platform: NodeJS.Platform,
+): string
+
 /**
  * Resolve the public updater URL for one release target.
  * @param env - Packaging or upload environment.

+ 20 - 0
apps/desktop/scripts/desktop-auto-update-environment.mjs

@@ -1,5 +1,7 @@
 /** Resolve the Desktop auto-update channel and its Tencent COS destination. */
 
+import { prerelease, valid } from 'semver'
+
 /** Environment variable that selects the Desktop update deployment. */
 export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV'
 
@@ -62,6 +64,24 @@ export function desktopBuildRecordFilename(target) {
   return `${target}-release.json`
 }
 
+/**
+ * Return the electron-builder channel metadata filename for an application version.
+ * @param {string} version - Desktop semantic version.
+ * @param {NodeJS.Platform} platform - Target platform.
+ * @returns {string} Channel metadata filename emitted for the target.
+ */
+export function desktopUpdateMetadataFilename(version, platform) {
+  if (valid(version) === null) {
+    throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`)
+  }
+  if (platform !== 'darwin' && platform !== 'win32') {
+    throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`)
+  }
+  const release = prerelease(version)
+  const channel = release === null ? 'latest' : String(release[0])
+  return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml`
+}
+
 /**
  * Read one required release setting without accepting whitespace-only values.
  * @param {NodeJS.ProcessEnv} env - Packaging or upload environment.

+ 49 - 0
apps/desktop/scripts/desktop-build-paths.d.mts

@@ -0,0 +1,49 @@
+import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
+
+/** Mutable target directories plus the shared immutable download cache. */
+export interface DesktopTargetBuildPaths {
+  readonly root: string
+  readonly artifacts: string
+  readonly runtime: string
+  readonly packageSet: string
+  readonly seed: string
+  readonly seedPnpm: string
+  readonly nodeExtract: string
+  readonly packedDsh: string
+  readonly packedVendor: string
+  readonly packedLandlock: string
+  readonly downloads: string
+}
+
+/**
+ * Resolve the fixed build target selected by a packaging environment.
+ * @param env - Packaging environment.
+ * @param hostPlatform - Build-host platform used when no target override exists.
+ * @param hostArch - Build-host architecture used when no target override exists.
+ * @returns Supported Desktop target name.
+ */
+export function resolveDesktopBuildTarget(
+  env?: NodeJS.ProcessEnv,
+  hostPlatform?: NodeJS.Platform,
+  hostArch?: string,
+): DesktopAutoUpdateTarget
+
+/**
+ * Return the mutable preparation and artifact directories owned by one release target.
+ * @param target - Supported Desktop target name.
+ * @returns Target paths plus the shared immutable download cache.
+ */
+export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
+
+/**
+ * Resolve the paths owned by the target selected in a packaging environment.
+ * @param env - Packaging environment.
+ * @param hostPlatform - Build-host platform used when no target override exists.
+ * @param hostArch - Build-host architecture used when no target override exists.
+ * @returns Selected target paths.
+ */
+export function resolveDesktopTargetBuildPaths(
+  env?: NodeJS.ProcessEnv,
+  hostPlatform?: NodeJS.Platform,
+  hostArch?: string,
+): DesktopTargetBuildPaths

+ 70 - 0
apps/desktop/scripts/desktop-build-paths.mjs

@@ -0,0 +1,70 @@
+/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */
+
+import { join, resolve } from 'node:path'
+
+const APP_ROOT = resolve(import.meta.dirname, '..')
+const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
+const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
+
+/**
+ * Resolve the fixed build target selected by a packaging environment.
+ * @param {NodeJS.ProcessEnv} env - Packaging environment.
+ * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
+ * @param {string} hostArch - Build-host architecture used when no target override exists.
+ * @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Supported Desktop target name.
+ */
+export function resolveDesktopBuildTarget(
+  env = process.env,
+  hostPlatform = process.platform,
+  hostArch = process.arch,
+) {
+  const platform = env.DSH_DESKTOP_TARGET_PLATFORM ?? env.npm_config_platform ?? hostPlatform
+  const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch ?? hostArch
+  const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform
+  const target = `${os}-${arch}`
+  if (!SUPPORTED_TARGETS.has(target)) {
+    throw new Error(`desktop build paths: unsupported target ${target}`)
+  }
+  return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target)
+}
+
+/**
+ * Return the mutable preparation and artifact directories owned by one release target.
+ * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
+ * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
+ */
+export function desktopTargetBuildPaths(target) {
+  if (!SUPPORTED_TARGETS.has(target)) {
+    throw new Error(`desktop build paths: unsupported target ${String(target)}`)
+  }
+  const root = join(BUILD_ROOT, 'targets', target)
+  const packed = join(root, 'packed')
+  return {
+    root,
+    artifacts: join(root, 'artifacts'),
+    runtime: join(root, 'runtime'),
+    packageSet: join(root, 'package-set'),
+    seed: join(root, 'seed'),
+    seedPnpm: join(root, 'seed-pnpm'),
+    nodeExtract: join(root, 'node-extract'),
+    packedDsh: join(packed, 'dsh'),
+    packedVendor: join(packed, 'vendor'),
+    packedLandlock: join(packed, 'landlock'),
+    downloads: join(BUILD_ROOT, 'downloads'),
+  }
+}
+
+/**
+ * Resolve the paths owned by the target selected in a packaging environment.
+ * @param {NodeJS.ProcessEnv} env - Packaging environment.
+ * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
+ * @param {string} hostArch - Build-host architecture used when no target override exists.
+ * @returns {ReturnType<typeof desktopTargetBuildPaths>} Selected target paths.
+ */
+export function resolveDesktopTargetBuildPaths(
+  env = process.env,
+  hostPlatform = process.platform,
+  hostArch = process.arch,
+) {
+  return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
+}

+ 15 - 15
apps/desktop/scripts/desktop-upload-plan.ts

@@ -8,22 +8,21 @@ import { load } from 'js-yaml'
 import type { DesktopPackageTargetName } from './package-target.ts'
 import {
   desktopBuildRecordFilename,
+  desktopUpdateMetadataFilename,
   resolveDesktopUploadConfig,
 } from './desktop-auto-update-environment.mjs'
+import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
-const ARTIFACTS_ROOT = join(APP_ROOT, '.desktop-build', 'artifacts')
-
 const TARGETS = {
-  'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac', metadata: 'latest-mac.yml' },
-  'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac', metadata: 'latest-mac.yml' },
-  'win-x64': { platform: 'win32', arch: 'x64', os: 'win', metadata: 'latest.yml' },
+  'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac' },
+  'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac' },
+  'win-x64': { platform: 'win32', arch: 'x64', os: 'win' },
 } as const satisfies Record<DesktopPackageTargetName, {
   readonly platform: NodeJS.Platform
   readonly arch: string
   readonly os: string
-  readonly metadata: string
 }>
 
 /** One local file and its final object metadata. */
@@ -181,7 +180,7 @@ export async function createDesktopUploadPlan(
   const environment = options.environment ?? process.env
   const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT
   const appRoot = options.appRoot ?? APP_ROOT
-  const artifactsRoot = options.artifactsRoot ?? ARTIFACTS_ROOT
+  const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts
   const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package')
   const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package')
   if (dshVersion !== desktopVersion) {
@@ -201,7 +200,8 @@ export async function createDesktopUploadPlan(
     throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`)
   }
 
-  const metadataPath = join(artifactsRoot, target.metadata)
+  const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform)
+  const metadataPath = join(artifactsRoot, metadataFilename)
   let metadataValue: unknown
   try {
     metadataValue = load(await readFile(metadataPath, 'utf8'))
@@ -209,18 +209,18 @@ export async function createDesktopUploadPlan(
   catch (error) {
     throw new Error(`desktop upload: cannot read update metadata at ${metadataPath}: ${error instanceof Error ? error.message : String(error)}`)
   }
-  const metadata = object(metadataValue, target.metadata)
-  const metadataVersion = stringField(metadata.version, `${target.metadata}.version`)
+  const metadata = object(metadataValue, metadataFilename)
+  const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`)
   if (metadataVersion !== dshVersion) {
-    throw new Error(`desktop upload: ${target.metadata} version ${metadataVersion} does not match current dsh version ${dshVersion}`)
+    throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`)
   }
   if (!Array.isArray(metadata.files) || metadata.files.length !== 1) {
-    throw new Error(`desktop upload: ${target.metadata}.files must contain exactly one target update file`)
+    throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`)
   }
 
   const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}`
   const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe'
-  const updaterInfo = updateFileInfo(metadata.files[0], `${target.metadata}.files[0]`, `${base}.${updaterExtension}`)
+  const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`)
   const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
   const artifacts: DesktopUploadArtifact[] = []
 
@@ -234,8 +234,8 @@ export async function createDesktopUploadPlan(
     )
   }
   else {
-    const blockMapSize = object(metadata.files[0], `${target.metadata}.files[0]`).blockMapSize
-    numberField(blockMapSize, `${target.metadata}.files[0].blockMapSize`)
+    const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize
+    numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`)
     artifacts.push(uploadArtifact(
       updaterPath,
       update.keyPrefix,

+ 15 - 13
apps/desktop/scripts/package-target.ts

@@ -8,13 +8,10 @@ import {
   desktopBuildRecordFilename,
   resolveDesktopAutoUpdateConfig,
 } from './desktop-auto-update-environment.mjs'
+import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
-const DSH_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm')
-const VENDOR_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm-vendor')
-const LANDLOCK_PACK_ROOT = join(REPOSITORY_ROOT, 'dist', 'npm-landlock')
-const ARTIFACTS_ROOT = join(APP_ROOT, '.desktop-build', 'artifacts')
 const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_'
 const WINDOWS_SIGNING_ENV_NAMES = [
   'DSH_DESKTOP_WINDOWS_CER_FILE',
@@ -97,14 +94,18 @@ function packageVersion(path: string, label: string): string {
   return manifest.version
 }
 
-function writeReleaseRecord(target: DesktopPackageTarget, environment: NodeJS.ProcessEnv): void {
+function writeReleaseRecord(
+  target: DesktopPackageTarget,
+  environment: NodeJS.ProcessEnv,
+  artifactsRoot: string,
+): void {
   const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
   const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package')
   if (desktopVersion !== dshVersion) {
     throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
   }
   const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
-  const recordPath = join(ARTIFACTS_ROOT, desktopBuildRecordFilename(target.name))
+  const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
   const temporaryPath = `${recordPath}.tmp`
   writeFileSync(temporaryPath, `${JSON.stringify({
     schemaVersion: 1,
@@ -237,7 +238,8 @@ function runPnpm(
 async function main(): Promise<void> {
   const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
   const { target } = invocation
-  const releaseRecordPath = join(ARTIFACTS_ROOT, desktopBuildRecordFilename(target.name))
+  const buildPaths = desktopTargetBuildPaths(target.name)
+  const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
   if (!invocation.prepareOnly) {
     rmSync(releaseRecordPath, { force: true })
     rmSync(`${releaseRecordPath}.tmp`, { force: true })
@@ -253,24 +255,24 @@ async function main(): Promise<void> {
     if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
   }
   await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
-  await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', DSH_PACK_ROOT], buildEnv, REPOSITORY_ROOT)
-  await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', VENDOR_PACK_ROOT], buildEnv, REPOSITORY_ROOT)
-  rmSync(LANDLOCK_PACK_ROOT, { recursive: true, force: true })
-  mkdirSync(LANDLOCK_PACK_ROOT, { recursive: true })
+  await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
+  await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
+  rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
+  mkdirSync(buildPaths.packedLandlock, { recursive: true })
   await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
   await runPnpm([
     '--dir',
     'native/landlock-run/packages/entry',
     'pack',
     '--pack-destination',
-    LANDLOCK_PACK_ROOT,
+    buildPaths.packedLandlock,
   ], buildEnv, REPOSITORY_ROOT)
   await runPnpm(['run', 'prepare:runtime'], targetEnv)
   await runPnpm(['run', 'prepare:packages'], targetEnv)
   await runPnpm(['run', 'prepare:seed'], targetEnv)
   if (invocation.prepareOnly) return
   await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
-  if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv)
+  if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }
 
 if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()

+ 7 - 5
apps/desktop/scripts/prepare-package-set.ts

@@ -22,15 +22,17 @@ import {
 } from '../src/core-package-set.ts'
 import { capture } from '../../../scripts/release/process.ts'
 import { tarballFiles } from '../../../scripts/release/tarball.ts'
+import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
 const DSH_PACKAGE = '@deepseek-ai/dsh'
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
-const OUTPUT_ROOT = join(APP_ROOT, '.desktop-build', 'package-set')
+const BUILD_PATHS = resolveDesktopTargetBuildPaths()
+const OUTPUT_ROOT = BUILD_PATHS.packageSet
 const DEFAULT_INPUTS = [
-  join(REPOSITORY_ROOT, 'dist', 'npm'),
-  join(REPOSITORY_ROOT, 'dist', 'npm-vendor'),
-  join(REPOSITORY_ROOT, 'dist', 'npm-landlock'),
+  BUILD_PATHS.packedDsh,
+  BUILD_PATHS.packedVendor,
+  BUILD_PATHS.packedLandlock,
 ]
 
 const REQUIRED_DEPENDENCY_SECTIONS = ['dependencies', 'peerDependencies'] as const
@@ -122,7 +124,7 @@ export function assertDesktopDshPackageFiles(files: readonly string[]): void {
   }
 }
 
-/** Prepare `.desktop-build/package-set` from release tarball directories. */
+/** Prepare the selected target's package set from its release tarball directories. */
 export function prepareDesktopPackageSet(inputs: readonly string[], output = OUTPUT_ROOT): void {
   const selected = selectDesktopPackageClosure(packedPackages(inputs))
   const dsh = selected.find(packed => packed.manifest.name === DSH_PACKAGE)

+ 6 - 6
apps/desktop/scripts/prepare-runtime.ts

@@ -5,16 +5,16 @@ import { spawnSync } from 'node:child_process'
 import { cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { chmod, readFile } from 'node:fs/promises'
 import { createRequire } from 'node:module'
-import { dirname, join, resolve } from 'node:path'
+import { dirname, join } from 'node:path'
 import { pipeline } from 'node:stream/promises'
 import extractZip from 'extract-zip'
 import { extract } from 'tar'
+import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
 const NODE_VERSION = '24.17.0'
-const APP_ROOT = resolve(import.meta.dirname, '..')
-const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
-const RUNTIME_ROOT = join(BUILD_ROOT, 'runtime')
-const DOWNLOAD_ROOT = join(BUILD_ROOT, 'downloads')
+const BUILD_PATHS = resolveDesktopTargetBuildPaths()
+const RUNTIME_ROOT = BUILD_PATHS.runtime
+const DOWNLOAD_ROOT = BUILD_PATHS.downloads
 
 type RuntimePlatform = 'darwin' | 'linux' | 'win'
 type RuntimeArch = 'arm64' | 'x64'
@@ -52,7 +52,7 @@ async function prepareNode(platform: RuntimePlatform, arch: RuntimeArch): Promis
   const actual = createHash('sha256').update(await readFile(archive)).digest('hex')
   if (actual !== expected) throw new Error(`desktop runtime: checksum mismatch for ${archiveName}`)
 
-  const extraction = join(BUILD_ROOT, 'node-extract')
+  const extraction = BUILD_PATHS.nodeExtract
   rmSync(extraction, { recursive: true, force: true })
   mkdirSync(extraction, { recursive: true })
   if (platform === 'win') await extractZip(archive, { dir: extraction })

+ 6 - 5
apps/desktop/scripts/prepare-seed.ts

@@ -28,15 +28,16 @@ import {
   signMacOSSeedStore,
   verifyMacOSSeedStore,
 } from './macos-seed-store.ts'
+import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
-const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
-const SEED_OUTPUT_ROOT = join(BUILD_ROOT, 'seed')
+const BUILD_PATHS = resolveDesktopTargetBuildPaths()
+const SEED_OUTPUT_ROOT = BUILD_PATHS.seed
 const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-'))
 const STORE_ROOT = join(SEED_ROOT, 'store')
-const RUNTIME_ROOT = join(BUILD_ROOT, 'runtime')
-const PNPM_BUILD_STATE = join(BUILD_ROOT, 'seed-pnpm')
-const PACKAGE_SET_ROOT = join(BUILD_ROOT, 'package-set')
+const RUNTIME_ROOT = BUILD_PATHS.runtime
+const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm
+const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
 const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
 const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
 

+ 9 - 0
apps/desktop/tests/desktop-auto-update-environment.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import {
   desktopBuildRecordFilename,
+  desktopUpdateMetadataFilename,
   resolveDesktopAutoUpdateConfig,
   resolveDesktopAutoUpdateEnvironment,
   resolveDesktopAutoUpdateTarget,
@@ -77,4 +78,12 @@ describe('desktop auto-update environment', () => {
     expect(() => resolveDesktopAutoUpdateTarget('linux', 'x64')).toThrow(/unsupported target/u)
     expect(() => desktopBuildRecordFilename('linux-x64' as 'mac-arm64')).toThrow(/unsupported target/u)
   })
+
+  it('matches electron-builder channel metadata names to the Desktop version', () => {
+    expect(desktopUpdateMetadataFilename('1.2.3', 'darwin')).toBe('latest-mac.yml')
+    expect(desktopUpdateMetadataFilename('1.2.3-alpha.4', 'darwin')).toBe('alpha-mac.yml')
+    expect(desktopUpdateMetadataFilename('1.2.3-beta.2', 'win32')).toBe('beta.yml')
+    expect(() => desktopUpdateMetadataFilename('not-semver', 'darwin')).toThrow(/invalid Desktop version/u)
+    expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u)
+  })
 })

+ 50 - 0
apps/desktop/tests/desktop-build-paths.spec.ts

@@ -0,0 +1,50 @@
+import { join, sep } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+  desktopTargetBuildPaths,
+  resolveDesktopBuildTarget,
+} from '../scripts/desktop-build-paths.mjs'
+
+describe('desktop build paths', () => {
+  it('isolates every mutable build directory by complete target', () => {
+    const arm64 = desktopTargetBuildPaths('mac-arm64')
+    const x64 = desktopTargetBuildPaths('mac-x64')
+    const windows = desktopTargetBuildPaths('win-x64')
+    const mutableKeys = [
+      'root',
+      'artifacts',
+      'runtime',
+      'packageSet',
+      'seed',
+      'seedPnpm',
+      'nodeExtract',
+      'packedDsh',
+      'packedVendor',
+      'packedLandlock',
+    ] as const
+
+    for (const key of mutableKeys) {
+      expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3)
+    }
+    expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts'))
+    expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed'))
+    expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
+  })
+
+  it('shares only the immutable upstream download cache', () => {
+    const arm64 = desktopTargetBuildPaths('mac-arm64')
+    const x64 = desktopTargetBuildPaths('mac-x64')
+    expect(arm64.downloads).toBe(x64.downloads)
+    expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
+  })
+
+  it('resolves environment overrides and rejects unsupported targets', () => {
+    expect(resolveDesktopBuildTarget({
+      DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
+      DSH_DESKTOP_TARGET_ARCH: 'x64',
+    }, 'darwin', 'arm64')).toBe('mac-x64')
+    expect(resolveDesktopBuildTarget({}, 'win32', 'x64')).toBe('win-x64')
+    expect(() => resolveDesktopBuildTarget({}, 'linux', 'x64')).toThrow(/unsupported target/u)
+    expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u)
+  })
+})

+ 14 - 2
apps/desktop/tests/desktop-upload-plan.spec.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
+import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs'
 import type { DesktopPackageTargetName } from '../scripts/package-target.ts'
 
 const temporaryDirectories: string[] = []
@@ -54,7 +55,7 @@ async function fixture(
     await writeFile(join(artifactsRoot, `${base}.zip`), zip)
     await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap')
     await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture')
-    await writeFile(join(artifactsRoot, 'latest-mac.yml'), `${JSON.stringify({
+    await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({
       version,
       files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }],
     })}\n`)
@@ -62,7 +63,7 @@ async function fixture(
   else {
     const executable = 'signed NSIS executable fixture'
     await writeFile(join(artifactsRoot, `${base}.exe`), executable)
-    await writeFile(join(artifactsRoot, 'latest.yml'), `${JSON.stringify({
+    await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({
       version,
       files: [{
         url: `${base}.exe`,
@@ -118,6 +119,17 @@ describe('desktop upload plan', () => {
     })
   })
 
+  it('uploads the prerelease channel metadata emitted by electron-builder', async () => {
+    const paths = await fixture('mac-arm64', '1.2.3-alpha.4')
+    const plan = await createDesktopUploadPlan('mac-arm64', paths)
+    expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([
+      'deepseek-harness-1.2.3-alpha.4-mac-arm64.dmg',
+      'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip',
+      'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip.blockmap',
+      'alpha-mac.yml',
+    ])
+  })
+
   it('validates the Windows installer with its embedded blockmap and production destination', async () => {
     const paths = await fixture('win-x64', '2.0.0', 'production')
     const plan = await createDesktopUploadPlan('win-x64', paths)

+ 6 - 0
apps/desktop/tests/macos-signature.spec.ts

@@ -33,6 +33,12 @@ describe('desktop macOS release signature', () => {
   it('loads release identifiers from the environment and requires code signing', async () => {
     const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
     const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
+    expect(config.directories.output).toContain('/.desktop-build/targets/mac-arm64/artifacts')
+    expect(config.extraResources).toHaveLength(2)
+    expect(config.extraResources[0]?.to).toBe('runtime')
+    expect(config.extraResources[1]?.to).toBe('seed')
+    expect(config.extraResources[0]?.from).toContain('/.desktop-build/targets/mac-arm64/runtime')
+    expect(config.extraResources[1]?.from).toContain('/.desktop-build/targets/mac-arm64/seed')
     expect(config).toMatchObject({
       appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
       mac: {

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů