diff --git a/.gitignore b/.gitignore
index 89710295af..c911887478 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
node_modules/
*.tsbuildinfo
.build/
+.local-cache/
dsh-plugin-desktop/dist/
dsh-plugin-desktop/lib/
diff --git a/README.en.md b/README.en.md
index 90333c72d9..94d0cc6c49 100644
--- a/README.en.md
+++ b/README.en.md
@@ -5,7 +5,7 @@
DSH Desktop
- An open-source desktop client for Windows and macOS, built on DeepSeek Harness.
+ An open-source desktop client for Windows, macOS and Linux, built on DeepSeek Harness.
@@ -26,7 +26,7 @@
-
+
DSH Desktop integrates the local Web UI, Host service, and plugin system from [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) into a native desktop application. It runs a pinned upstream version unchanged, while DSH Desktop provides the window, tray, terminal, updates, and work profiles through the plugin mechanism provided by DeepSeek Harness.
@@ -35,12 +35,15 @@ DSH Desktop integrates the local Web UI, Host service, and plugin system from [D
## Download and install
-Current release installers support Windows x64 and macOS Universal. No extra environment is needed — download, install, and start using it with one click.
+Current release installers support Windows x64, macOS Universal, and Linux x64 (AppImage and deb). No extra environment is needed — download, install, and start using it with one click.
| Platform | Download | Installation |
| --- | --- | --- |
| Windows x64 | [Download installer](https://www.dshdesktop.cn/api/downloads/windows) | Run the NSIS installer and follow its prompts |
| macOS Universal | [Download DMG](https://www.dshdesktop.cn/api/downloads/mac) | Open the DMG and drag DSH Desktop into Applications |
+| Linux x64 | Download the `.AppImage` or `.deb` from the [latest GitHub Release](https://github.com/anywhere-labs/deepseek-harness-desktop/releases/latest) | Run the AppImage directly; for deb run `sudo dpkg -i` or `sudo apt install ./` |
+
+On Linux the app currently provides compatibility mode (the official DSH web UI inside a native window). During development you can also run it straight from source; see [Development](#development).
See the [user guide](docs/user-guide.en.md) and [FAQ](docs/faq.en.md) for plugin commands, platform details, and troubleshooting.
@@ -149,7 +152,7 @@ The upstream project provides the core agent capabilities, plugin system, and We
- Desktop application packaging
- Starting, stopping, and recovering the local service
- Desktop window and system tray integration
-- macOS and Windows installer builds and releases
+- macOS, Windows, and Linux installer builds and releases
- An interface designed for desktop use
If you prefer to run DeepSeek Harness from the command line or contribute to its core functionality, refer to the upstream repository first.
diff --git a/README.i18n.yaml b/README.i18n.yaml
index c27f0c210f..d27d2917eb 100644
--- a/README.i18n.yaml
+++ b/README.i18n.yaml
@@ -1,5 +1,5 @@
# Bilingual-pair consistency record: the git blob hash of each side as of the last
# confirmed-consistent state. Both languages carry equal authority. Update both files
# and re-record their hashes after editing either side.
-README.md: 5679654969af439b4a7eb05449f741b008223291
-README.en.md: 90333c72d93a057af8c0c403eb95f573119178ac
+README.md: 9b9cfa91284841c6079ff7ff6de8cd10ec1393a2
+README.en.md: 94d0cc6c493249fcfdf105d6d01b52c617770733
diff --git a/README.md b/README.md
index 5679654969..9b9cfa9128 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
DSH Desktop
- 基于 DeepSeek Harness 构建的 Windows 和 macOS 开源桌面客户端。
+ 基于 DeepSeek Harness 构建的 Windows、macOS 和 Linux 开源桌面客户端。
@@ -26,7 +26,7 @@
-
+
DSH Desktop 将 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的本地 Web UI、Host 服务和插件系统集成到原生桌面应用中。项目固定并原样运行特定上游版本;DSH Desktop 提供窗口、托盘、终端、更新和工作配置,并通过 DeepSeek Harness 提供的插件机制与上游能力组合。
@@ -35,12 +35,15 @@ DSH Desktop 将 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harne
## 下载与安装
-当前正式安装包支持 Windows x64 和 macOS Universal。无需额外环境,下载安装,一键使用。
+当前正式安装包支持 Windows x64、macOS Universal 和 Linux x64(AppImage 与 deb)。无需额外环境,下载安装,一键使用。
| 平台 | 下载 | 安装方式 |
| --- | --- | --- |
| Windows x64 | [下载安装程序](https://www.dshdesktop.cn/api/downloads/windows) | 运行 NSIS 安装程序并按提示完成安装 |
| macOS Universal | [下载 DMG](https://www.dshdesktop.cn/api/downloads/mac) | 打开 DMG,将 DSH Desktop 拖入 Applications |
+| Linux x64 | 从 [最新 GitHub Release](https://github.com/anywhere-labs/deepseek-harness-desktop/releases/latest) 下载 `.AppImage` 或 `.deb` | AppImage 直接运行;deb 用 `sudo dpkg -i` 或 `sudo apt install ./` 安装 |
+
+Linux 上当前提供兼容模式(原生窗口内运行官方 DSH Web 界面)。开发阶段也可以直接从源码运行,见[开发](#开发)。
详细步骤、插件命令和故障排查见[用户指南](docs/user-guide.md)与[常见问题](docs/faq.md)。
@@ -149,7 +152,7 @@ DSH Desktop 是基于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
- 桌面应用封装
- 本地服务的启动、停止与恢复
- 桌面窗口和系统托盘集成
-- macOS、Windows 安装包构建与发布
+- macOS、Windows、Linux 安装包构建与发布
- 更适合桌面使用的界面体验
如果你希望通过命令行运行 DeepSeek Harness,或者参与其核心功能开发,请优先查看上游仓库。
diff --git a/docs/faq.en.md b/docs/faq.en.md
index ac31d1166e..19e8af74dc 100644
--- a/docs/faq.en.md
+++ b/docs/faq.en.md
@@ -14,7 +14,7 @@ No. DSH Desktop is an independent, community-maintained open-source project. It
## Which operating systems are supported?
-Current release installers support Windows x64 and universal macOS (Intel and Apple Silicon). There is currently no Linux installer. Cross-platform compatibility code in the source tree does not imply that an installer has been released for that platform.
+Current release installers support Windows x64, universal macOS (Intel and Apple Silicon), and Linux x64 (AppImage and deb). On Linux the app currently provides compatibility mode, running the official DSH web UI in a native window; custom window/material styles, the system terminal, and auto-updates remain macOS/Windows boundaries.
## Do I need to install Node.js, pnpm, or DSH?
diff --git a/docs/faq.md b/docs/faq.md
index b91db26edd..dd1d6c699b 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -14,7 +14,7 @@ DSH Desktop 是面向 Windows 和 macOS 的开源 DeepSeek Harness 桌面客户
## 支持哪些操作系统?
-当前正式安装包支持 Windows x64 和 universal macOS(Intel 与 Apple Silicon)。当前没有 Linux 安装包;不要根据源码中存在跨平台兼容代码推断已经发布了对应安装包。
+当前正式安装包支持 Windows x64、universal macOS(Intel 与 Apple Silicon)和 Linux x64(AppImage 与 deb)。Linux 上当前提供兼容模式,即在原生窗口中运行官方 DSH Web 界面;自定义窗口/材质、系统终端和自动更新仍以 macOS/Windows 为边界。
## 需要安装 Node.js、pnpm 或 DSH 吗?
diff --git a/docs/user-guide.en.md b/docs/user-guide.en.md
index 67a487ea57..15eefd5209 100644
--- a/docs/user-guide.en.md
+++ b/docs/user-guide.en.md
@@ -2,7 +2,9 @@
## Installation and first launch
-Download the macOS or Windows installer from the product download page. DSH Desktop includes Electron, Node, and its pinned DSH dependencies, so normal users do not need to install Node.js or pnpm separately.
+Download the macOS, Windows, or Linux installer from the product download page. DSH Desktop includes Electron, Node, and its pinned DSH dependencies, so normal users do not need to install Node.js or pnpm separately.
+
+On Linux the release provides `.AppImage` and `.deb` packages. The AppImage can be run directly (after `chmod +x`) or placed in a desktop shortcut; the deb can be installed with `sudo dpkg -i DSH-Desktop-*.deb` or `sudo apt install ./DSH-Desktop-*.deb`, then launched from the application menu. Linux currently provides compatibility mode only; extended/enhanced windows, the system terminal, and auto-updates remain macOS/Windows boundaries. To run from source, see the Development section of the repository README.
On first launch, the application prepares the default profile and starts the official DSH Web surface locally. Closing the window normally hides it; use **Quit** from the tray when you want to stop the application and Host process.
diff --git a/docs/user-guide.md b/docs/user-guide.md
index cb7e248b37..45da5ba2f0 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -2,7 +2,9 @@
## 安装与首次启动
-从产品下载入口获取 macOS 或 Windows 安装包。安装后的 DSH Desktop 自带运行所需的 Electron、Node 和 DSH 依赖,普通用户不需要另行安装 Node.js 或 pnpm。
+从产品下载入口获取 macOS、Windows 或 Linux 安装包。安装后的 DSH Desktop 自带运行所需的 Electron、Node 和 DSH 依赖,普通用户不需要另行安装 Node.js 或 pnpm。
+
+Linux 上提供 `.AppImage` 和 `.deb` 两种安装包。AppImage 可直接运行(`chmod +x` 后执行),也可放到桌面快捷方式;deb 可用 `sudo dpkg -i DSH-Desktop-*.deb` 或 `sudo apt install ./DSH-Desktop-*.deb` 安装,之后从应用菜单启动。Linux 目前只提供兼容模式,扩展窗口、增强模式、系统终端和自动更新仍以 macOS/Windows 为边界;从源码运行方式见仓库 README 的[开发](#)章节。
首次启动时,应用会准备默认 profile,并在本机启动官方 DSH Web surface。关闭窗口通常只会隐藏窗口;可以从托盘重新打开,选择 **退出** 才会结束应用和 Host 进程。
diff --git a/dsh-plugin-desktop/README.i18n.yaml b/dsh-plugin-desktop/README.i18n.yaml
index b00aab5ffc..a848b1537c 100644
--- a/dsh-plugin-desktop/README.i18n.yaml
+++ b/dsh-plugin-desktop/README.i18n.yaml
@@ -1,5 +1,5 @@
# Bilingual-pair consistency record: the git blob hash of each side as of the last
# confirmed-consistent state. Both languages carry equal authority; after editing
# either side, bring the other along and record both new Git blob hashes below.
-README.md: f38f398a9c6bf84fd1b6711963e87b83a43479cf
-README.zh.md: ec3de0797943e65e7647381d3ef5fb9329671d3f
+README.md: 5a4aeef0e11b764291b49789391660e079434570
+README.zh.md: bc89068a6c2f301956ece96f370a87eb0e7a9257
diff --git a/dsh-plugin-desktop/README.md b/dsh-plugin-desktop/README.md
index f38f398a9c..5a4aeef0e1 100644
--- a/dsh-plugin-desktop/README.md
+++ b/dsh-plugin-desktop/README.md
@@ -239,6 +239,18 @@ The output is `dsh-plugin-desktop\\dist\\DSH-Desktop-2.0.3-x64-Portable.zip`. Ex
`yarn dist:mac-smoke` builds one unsigned universal DMG on a native macOS host. The same package runs natively on Intel and Apple Silicon Macs. The command refuses non-macOS hosts and runs the complete product gate before packaging: repository layout and community-contract checks, the Market build and check, then the Desktop build, every TypeScript compiler face, the full unit-test suite, runtime-closure verification, CLI/Loader/profile headless smokes, and the license audit. This includes the real login-shell tests for each supported shell installed on the macOS runner. It then packages without code-signing material, mounts the DMG, and verifies the property list, executable bit, both `x86_64` and `arm64` slices, and `app.asar`. It mirrors `dist:win`'s secret discipline by stripping every Electron Builder macOS signing and notarization variable, sets `CSC_IDENTITY_AUTO_DISCOVERY=false`, disables notarization, and never publishes. The artifact has no Developer ID signature, so Gatekeeper will block it on other machines; it exists so packaging regressions fail in CI before a manual release. The signed and notarized universal release remains `yarn dist:mac` on a credentialed macOS machine and writes its artifact to `dsh-plugin-desktop/dist/mac-release/`.
+### Local Linux x64 AppImage and deb
+
+Use a Linux x64 machine with Node `22.19+` or `24.x` to build unsigned AppImage and deb artifacts:
+
+```bash
+git submodule update --init --recursive
+corepack yarn install --immutable
+corepack yarn dist:linux
+```
+
+`dist:linux` refuses non-Linux and non-x64 hosts, runs the Linux package gate (the Desktop build, every TypeScript compiler face, packaging and native-shell focused tests, and the runtime-closure verifier), then runs Electron Builder for the `AppImage` and `deb` targets and verifies both artifacts. It sets `CSC_IDENTITY_AUTO_DISCOVERY=false`, skips dependency rebuilds (`npmRebuild=false`), and never publishes. Version `2.0.3` is written to `dsh-plugin-desktop/dist/linux/DSH-Desktop-2.0.3-x86_64.AppImage` and `dsh-plugin-desktop/dist/linux/DSH-Desktop-2.0.3-amd64.deb`; the unpacked application remains at `dsh-plugin-desktop/dist/linux/linux-unpacked/dsh-desktop` for smoke testing. Linux artifacts are unsigned; the AppImage runs on desktop environments that can mount FUSE2 images (or with `--appimage-extract-and-run`), and the deb installs with `sudo dpkg -i`. A signed Linux release and upgrade/uninstall testing remain separate release gates.
+
## Model Experience
None. The desktop package changes application composition and native presentation; it does not add model-visible instructions, tools, events, or request fields.
@@ -258,4 +270,4 @@ None. The same DSH Host and client feature plugins assemble model requests.
- The update handoff validates the download container, not publisher identity. macOS still requires the user to replace the application from the opened DMG; Windows runs the downloaded NSIS installer but the local `dist:win` artifact is unsigned. Signed artifacts, Authenticode/publisher verification, SmartScreen reputation, and native upgrade testing remain release gates.
- The shared carrier is HTTP and WebSocket, not Electron IPC. It defaults to loopback and supports an explicitly confirmed all-interface LAN bind. Replacing the carrier requires transport extension points in upstream DSH and is outside this standalone package.
- This project pins both the published DSH `0.1.1-rc.2` family and the corresponding official `deepseek-harness/` release source. Product builds still resolve published package interfaces rather than linking the source checkout.
-- `package:dir` is an unpacked smoke artifact. `dist:win` adds an unsigned NSIS test installer but does not establish Authenticode identity or SmartScreen reputation. Installation and upgrade behavior, native notifications and terminals, the Windows ACL sandbox, and native-material appearance remain target-platform verification boundaries.
+- `package:dir` is an unpacked smoke artifact. `dist:win` adds an unsigned NSIS test installer but does not establish Authenticode identity or SmartScreen reputation; `dist:linux` adds unsigned AppImage and deb artifacts. Installation and upgrade behavior, native notifications and terminals, the Windows ACL sandbox, and native-material appearance remain target-platform verification boundaries.
diff --git a/dsh-plugin-desktop/README.zh.md b/dsh-plugin-desktop/README.zh.md
index ec3de07979..bc89068a6c 100644
--- a/dsh-plugin-desktop/README.zh.md
+++ b/dsh-plugin-desktop/README.zh.md
@@ -239,6 +239,18 @@ corepack.cmd yarn dist:win-portable
`yarn dist:mac-smoke` 会在原生 macOS 宿主机上构建一个未签名的 universal DMG,同一个安装包可以在 Intel 和 Apple Silicon Mac 上原生运行。该命令拒绝非 macOS 宿主,并在打包前运行完整产品 gate:仓库布局与社区契约检查、Market 的 build 与 check,然后再运行 Desktop build、全部 TypeScript compiler face、完整 unit-test suite、runtime-closure 验证、CLI/Loader/profile headless smoke 与 license audit;其中包括对 macOS runner 上已安装的每种受支持 shell 执行真实 login-shell 测试。随后它会在不接触任何签名材料的情况下打包,挂载 DMG,并检查属性列表、主程序执行权限、`x86_64` 与 `arm64` 两个架构切片,以及 `app.asar`。该命令与 `dist:win` 的密钥纪律一致:剥离 Electron Builder 能识别的全部 macOS 签名与公证变量、设置 `CSC_IDENTITY_AUTO_DISCOVERY=false`、关闭 notarization,且从不发布。产物没有 Developer ID 签名,因此 Gatekeeper 会在其他机器上拦截它;它的存在是为了让打包回归在人工发布之前就在 CI 中失败。签名并公证的 universal 正式发布仍是在持有凭证的 macOS 机器上执行 `yarn dist:mac`,产物写入 `dsh-plugin-desktop/dist/mac-release/`。
+### 本地 Linux x64 AppImage 与 deb
+
+在 Linux x64 机器上使用 Node `22.19+` 或 `24.x` 构建未签名的 AppImage 与 deb 产物:
+
+```bash
+git submodule update --init --recursive
+corepack yarn install --immutable
+corepack yarn dist:linux
+```
+
+`dist:linux` 会拒绝非 Linux 或非 x64 宿主,先执行 Linux 打包 gate(Desktop build、全部 TypeScript compiler face、打包与原生 shell 聚焦测试,以及 runtime-closure verifier),再为 `AppImage` 与 `deb` 两个 target 运行 Electron Builder,并校验两个产物。该命令设置 `CSC_IDENTITY_AUTO_DISCOVERY=false`、跳过依赖重建(`npmRebuild=false`),且从不发布。版本 `2.0.3` 会输出到 `dsh-plugin-desktop/dist/linux/DSH-Desktop-2.0.3-x86_64.AppImage` 与 `dsh-plugin-desktop/dist/linux/DSH-Desktop-2.0.3-amd64.deb`;用于 smoke 测试的未封装程序仍位于 `dsh-plugin-desktop/dist/linux/linux-unpacked/dsh-desktop`。Linux 产物未签名;AppImage 可在支持挂载 FUSE2 镜像的桌面环境直接运行(或使用 `--appimage-extract-and-run`),deb 可用 `sudo dpkg -i` 安装。签名后的 Linux 正式发布与升级/卸载测试仍是单独的 release gate。
+
## 模型体验
无。desktop package 只改变应用组合与原生呈现,不增加任何模型可见的指令、工具、事件或请求字段。
diff --git a/dsh-plugin-desktop/package.json b/dsh-plugin-desktop/package.json
index 28a44523f9..c0b487e9c8 100644
--- a/dsh-plugin-desktop/package.json
+++ b/dsh-plugin-desktop/package.json
@@ -1,6 +1,7 @@
{
"name": "dsh-plugin-desktop",
"version": "2.0.3",
+ "desktopName": "dsh-desktop",
"description": "DSH Desktop: an Electron shell composed as a DeepSeek Harness Cordis plugin",
"license": "MIT",
"publishConfig": {
@@ -135,6 +136,8 @@
"dist:mac-smoke": "node scripts/package-mac.ts",
"dist:win": "node scripts/package-win.ts",
"dist:win-portable": "node scripts/package-win-portable.ts",
+ "dist:linux": "node scripts/package-linux.ts",
+ "check:linux-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package-linux.spec.ts tests/verify-linux-package.spec.ts && yarn run verify:closure",
"prepack": "yarn run check"
},
"dependencies": {
@@ -371,9 +374,27 @@
},
"linux": {
"target": [
- "dir"
+ {
+ "target": "AppImage",
+ "arch": [
+ "x64"
+ ]
+ },
+ {
+ "target": "deb",
+ "arch": [
+ "x64"
+ ]
+ }
],
- "icon": "build/app-icon.png"
+ "icon": "build/app-icon.png",
+ "category": "Development",
+ "executableName": "dsh-desktop",
+ "maintainer": "DSH Desktop maintainers",
+ "syncDesktopName": true,
+ "synopsis": "DSH Desktop: DeepSeek Harness desktop client",
+ "description": "DSH Desktop packages the local DeepSeek Harness web UI, Host service and plugin system into a native desktop application.",
+ "artifactName": "DSH-Desktop-${version}-${arch}.${ext}"
}
}
}
diff --git a/dsh-plugin-desktop/scripts/package-linux.ts b/dsh-plugin-desktop/scripts/package-linux.ts
new file mode 100644
index 0000000000..8c2ef57633
--- /dev/null
+++ b/dsh-plugin-desktop/scripts/package-linux.ts
@@ -0,0 +1,154 @@
+/** Build unsigned Linux x64 AppImage and deb artifacts on a native Linux host. */
+
+import { spawnSync } from 'node:child_process'
+import { rmSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/** Injectable native Linux packaging boundary used by focused tests. */
+export interface LinuxPackageOptions {
+ /** Environment inherited by the packaging command. */
+ readonly env: NodeJS.ProcessEnv
+ /** Platform executing the package build. */
+ readonly platform: NodeJS.Platform
+ /** Node architecture executing the package build. */
+ readonly arch: string
+ /** Node version executing the package build. */
+ readonly nodeVersion: string
+ /** Repository root containing the Yarn workspace. */
+ readonly workspaceRoot: string
+ /** Desktop package root containing electron-builder configuration. */
+ readonly desktopRoot: string
+ /** Dedicated package output directory, isolated from other artifacts. */
+ readonly outputDir: string
+ /** Remove only the dedicated generated output before packaging. */
+ readonly resetOutput: () => void
+ /** Absolute electron-builder CLI module. */
+ readonly builderCli: string
+ /** Absolute packaged-artifact verification script. */
+ readonly verifier: string
+ /** Node executable used to run package-local scripts. */
+ readonly nodeExecutable: string
+ /** Execute one packaging command. */
+ readonly run: (
+ command: string,
+ args: readonly string[],
+ cwd: string,
+ env: NodeJS.ProcessEnv,
+ ) => void
+ /** Report non-secret packaging progress. */
+ readonly log: (message: string) => void
+}
+
+function run(
+ command: string,
+ args: readonly string[],
+ cwd: string,
+ env: NodeJS.ProcessEnv,
+): void {
+ const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' })
+ if (result.error !== undefined) throw result.error
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
+ }
+}
+
+function defaultOptions(): LinuxPackageOptions {
+ const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+ const workspaceRoot = resolve(desktopRoot, '..')
+ const require = createRequire(import.meta.url)
+ const outputDir = resolve(desktopRoot, 'dist', 'linux')
+ return {
+ env: process.env,
+ platform: process.platform,
+ arch: process.arch,
+ nodeVersion: process.versions.node,
+ workspaceRoot,
+ desktopRoot,
+ outputDir,
+ resetOutput: () => rmSync(outputDir, { recursive: true, force: true }),
+ builderCli: require.resolve('electron-builder/cli.js'),
+ verifier: fileURLToPath(new URL('./verify-linux-package.ts', import.meta.url)),
+ nodeExecutable: process.execPath,
+ run,
+ log: message => console.log(message),
+ }
+}
+
+/**
+ * Assert that the packaging host can produce Linux x64 artifacts.
+ * @param options - Process inputs for the Linux packaging boundary.
+ */
+function assertLinuxPackageHost(options: LinuxPackageOptions): void {
+ if (options.platform !== 'linux') {
+ throw new Error('Linux artifacts must be built on a native Linux host')
+ }
+ if (options.arch !== 'x64') {
+ throw new Error(`Linux x64 packaging requires x64 Node; received ${options.arch}`)
+ }
+ const versionMatch = /^(\d+)\.(\d+)\./u.exec(options.nodeVersion)
+ const major = Number(versionMatch?.[1])
+ const minor = Number(versionMatch?.[2])
+ if (!((major === 22 && minor >= 19) || major === 24)) {
+ throw new Error(
+ `Linux packaging requires Node 22.19+ or Node 24.x with bundled Corepack; received ${options.nodeVersion}`,
+ )
+ }
+}
+
+/**
+ * Run the headless release gates and package unsigned Linux x64 AppImage and deb artifacts.
+ * @param options - Process and command boundaries.
+ */
+export function packageLinuxArtifacts(options: LinuxPackageOptions = defaultOptions()): void {
+ assertLinuxPackageHost(options)
+
+ options.log('Building unsigned Linux x64 AppImage and deb artifacts; signing is a separate release step.')
+ if (options.env.DSH_PACKAGE_CHECK_ALREADY_RAN !== '1') {
+ options.run(
+ 'corepack',
+ ['yarn', 'workspace', 'dsh-plugin-desktop', 'check:linux-package'],
+ options.workspaceRoot,
+ options.env,
+ )
+ } else {
+ options.log('Skipping the Linux package preflight; the package gate already passed.')
+ }
+ options.resetOutput()
+ options.run(
+ options.nodeExecutable,
+ [
+ options.builderCli,
+ '--linux',
+ 'AppImage',
+ 'deb',
+ '--x64',
+ '--publish',
+ 'never',
+ '--config.npmRebuild=false',
+ `--config.directories.output=${options.outputDir}`,
+ ],
+ options.desktopRoot,
+ {
+ ...options.env,
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false',
+ },
+ )
+ options.run(
+ options.nodeExecutable,
+ [options.verifier, options.outputDir],
+ options.desktopRoot,
+ options.env,
+ )
+}
+
+const invokedPath = process.argv[1]
+if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
+ try {
+ packageLinuxArtifacts()
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exitCode = 1
+ }
+}
diff --git a/dsh-plugin-desktop/scripts/verify-linux-package.ts b/dsh-plugin-desktop/scripts/verify-linux-package.ts
new file mode 100644
index 0000000000..58f88e32a3
--- /dev/null
+++ b/dsh-plugin-desktop/scripts/verify-linux-package.ts
@@ -0,0 +1,132 @@
+/** Verify the unsigned Linux x64 AppImage and deb artifacts sealed by electron-builder. */
+
+import { readFileSync } from 'node:fs'
+import { existsSync, statSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const AR_ARCHIVE_MAGIC = '!\n'
+
+/** Injectable filesystem and command boundaries for Linux package verification. */
+export interface LinuxPackageVerificationOptions {
+ /** Directory containing the electron-builder Linux artifacts. */
+ readonly distDir: string
+ /** Product version expected in artifact filenames. */
+ readonly version: string
+ /** Candidate architectures that Linux electron-builder may emit. */
+ readonly archNames: readonly string[]
+ /** Main executable basename inside linux-unpacked. */
+ readonly executableName: string
+ /** Probe a physical path. */
+ readonly exists: (path: string) => boolean
+ /** Report file metadata for a physical path. */
+ readonly stat: (path: string) => { readonly size: number, readonly isFile: boolean, readonly mode: number }
+ /** Read the beginning of a physical file. */
+ readonly readPrefix: (path: string, length: number) => Buffer
+}
+
+function statInfo(path: string): { readonly size: number, readonly isFile: boolean, readonly mode: number } {
+ const result = statSync(path)
+ return { size: result.size, isFile: result.isFile(), mode: result.mode }
+}
+
+function readPrefix(path: string, length: number): Buffer {
+ return readFileSync(path).subarray(0, length)
+}
+
+function defaultOptions(): LinuxPackageVerificationOptions {
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+ const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as { version?: unknown }
+ if (typeof manifest.version !== 'string') throw new Error('dsh-plugin-desktop has no string version')
+ return {
+ distDir: process.argv[2] === undefined
+ ? join(packageRoot, 'dist', 'linux')
+ : resolve(process.argv[2]),
+ version: manifest.version,
+ executableName: 'dsh-desktop',
+ archNames: ['x86_64', 'amd64', 'x64'],
+ exists: existsSync,
+ stat: statInfo,
+ readPrefix,
+ }
+}
+
+/**
+ * Assert that an artifact is a regular, non-empty, executable file.
+ * @param options - Filesystem boundary.
+ * @param path - Absolute artifact path.
+ * @param label - Human-readable artifact description.
+ */
+function assertRegularFile(
+ options: LinuxPackageVerificationOptions,
+ path: string,
+ label: string,
+): void {
+ if (!options.exists(path)) {
+ throw new Error(`Linux package is missing the ${label}: ${path}`)
+ }
+ const info = options.stat(path)
+ if (!info.isFile || info.size === 0) {
+ throw new Error(`Linux package has an invalid ${label}: ${path}`)
+ }
+}
+
+/**
+ * Verify the unsigned Linux x64 artifacts produced by electron-builder.
+ * @param options - Filesystem boundary.
+ * @returns The verified artifact paths.
+ */
+export function verifyLinuxPackage(
+ options: LinuxPackageVerificationOptions = defaultOptions(),
+): { readonly appImage: string, readonly deb: string } {
+ const candidates = options.archNames.map(arch => ({
+ appImage: join(options.distDir, `DSH-Desktop-${options.version}-${arch}.AppImage`),
+ deb: join(options.distDir, `DSH-Desktop-${options.version}-${arch}.deb`),
+ }))
+ const appImage = candidates.find(candidate => options.exists(candidate.appImage))?.appImage
+ const deb = candidates.find(candidate => options.exists(candidate.deb))?.deb
+ if (appImage === undefined) {
+ throw new Error(`Linux package is missing the AppImage for ${options.version}`)
+ }
+ if (deb === undefined) {
+ throw new Error(`Linux package is missing the deb package for ${options.version}`)
+ }
+
+ assertRegularFile(options, appImage, 'AppImage')
+ const appImageStat = options.stat(appImage)
+ if ((appImageStat.mode & 0o111) === 0) {
+ throw new Error(`Linux AppImage is not executable: ${appImage}`)
+ }
+
+ assertRegularFile(options, deb, 'deb package')
+ const debPrefix = options.readPrefix(deb, AR_ARCHIVE_MAGIC.length).toString('ascii')
+ if (!debPrefix.startsWith(AR_ARCHIVE_MAGIC)) {
+ throw new Error(`Linux deb package is not an ar archive: ${deb}`)
+ }
+
+ const unpackedRoot = join(options.distDir, 'linux-unpacked')
+ if (!options.exists(unpackedRoot)) {
+ throw new Error(`Linux package is missing the unpacked application: ${unpackedRoot}`)
+ }
+ const executable = join(unpackedRoot, options.executableName)
+ assertRegularFile(options, executable, 'unpacked executable')
+ const executableStat = options.stat(executable)
+ if ((executableStat.mode & 0o111) === 0) {
+ throw new Error(`Linux unpacked executable is not executable: ${executable}`)
+ }
+ const appAsar = join(unpackedRoot, 'resources', 'app.asar')
+ assertRegularFile(options, appAsar, 'unpacked application archive')
+
+ return { appImage, deb }
+}
+
+const invokedPath = process.argv[1]
+if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
+ try {
+ const verified = verifyLinuxPackage()
+ console.log(`Linux package verification passed: ${verified.appImage}, ${verified.deb}`)
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exitCode = 1
+ }
+}
diff --git a/dsh-plugin-desktop/tests/package-linux.spec.ts b/dsh-plugin-desktop/tests/package-linux.spec.ts
new file mode 100644
index 0000000000..1cc2ebb6b2
--- /dev/null
+++ b/dsh-plugin-desktop/tests/package-linux.spec.ts
@@ -0,0 +1,143 @@
+import { describe, expect, it } from 'vitest'
+import {
+ packageLinuxArtifacts,
+ type LinuxPackageOptions,
+} from '../scripts/package-linux.ts'
+
+interface CommandCall {
+ readonly command: string
+ readonly args: readonly string[]
+ readonly cwd: string
+ readonly env: NodeJS.ProcessEnv
+}
+
+function options(calls: CommandCall[], logs: string[] = []): LinuxPackageOptions {
+ return {
+ env: {
+ PATH: '/usr/bin:/bin',
+ SAFE_VALUE: 'kept',
+ },
+ platform: 'linux',
+ arch: 'x64',
+ nodeVersion: '22.23.2',
+ workspaceRoot: '/repo',
+ desktopRoot: '/repo/dsh-plugin-desktop',
+ outputDir: '/repo/dsh-plugin-desktop/dist/linux',
+ resetOutput: () => logs.push('reset'),
+ builderCli: '/repo/node_modules/electron-builder/cli.js',
+ verifier: '/repo/dsh-plugin-desktop/scripts/verify-linux-package.ts',
+ nodeExecutable: '/usr/bin/node',
+ run: (command, args, cwd, env) => {
+ calls.push({ command, args: [...args], cwd, env: { ...env } })
+ },
+ log: message => logs.push(message),
+ }
+}
+
+describe('Linux x64 artifact packaging', () => {
+ it('checks, builds unsigned AppImage and deb targets, then verifies them', () => {
+ const calls: CommandCall[] = []
+ const logs: string[] = []
+
+ packageLinuxArtifacts(options(calls, logs))
+
+ expect(calls).toHaveLength(3)
+ expect(calls[0]).toEqual({
+ command: 'corepack',
+ args: ['yarn', 'workspace', 'dsh-plugin-desktop', 'check:linux-package'],
+ cwd: '/repo',
+ env: { PATH: '/usr/bin:/bin', SAFE_VALUE: 'kept' },
+ })
+ expect(calls[1]).toEqual({
+ command: '/usr/bin/node',
+ args: [
+ '/repo/node_modules/electron-builder/cli.js',
+ '--linux',
+ 'AppImage',
+ 'deb',
+ '--x64',
+ '--publish',
+ 'never',
+ '--config.npmRebuild=false',
+ '--config.directories.output=/repo/dsh-plugin-desktop/dist/linux',
+ ],
+ cwd: '/repo/dsh-plugin-desktop',
+ env: {
+ PATH: '/usr/bin:/bin',
+ SAFE_VALUE: 'kept',
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false',
+ },
+ })
+ expect(calls[2]).toEqual({
+ command: '/usr/bin/node',
+ args: ['/repo/dsh-plugin-desktop/scripts/verify-linux-package.ts', '/repo/dsh-plugin-desktop/dist/linux'],
+ cwd: '/repo/dsh-plugin-desktop',
+ env: { PATH: '/usr/bin:/bin', SAFE_VALUE: 'kept' },
+ })
+ expect(logs).toEqual([
+ 'Building unsigned Linux x64 AppImage and deb artifacts; signing is a separate release step.',
+ 'reset',
+ ])
+ })
+
+ it('reuses a completed CI package gate when explicitly requested', () => {
+ const calls: CommandCall[] = []
+ const logs: string[] = []
+ const value = {
+ ...options(calls, logs),
+ env: {
+ ...options(calls).env,
+ DSH_PACKAGE_CHECK_ALREADY_RAN: '1',
+ },
+ }
+
+ packageLinuxArtifacts(value)
+
+ expect(calls).toHaveLength(2)
+ expect(calls[0]?.args).toEqual([
+ '/repo/node_modules/electron-builder/cli.js',
+ '--linux',
+ 'AppImage',
+ 'deb',
+ '--x64',
+ '--publish',
+ 'never',
+ '--config.npmRebuild=false',
+ '--config.directories.output=/repo/dsh-plugin-desktop/dist/linux',
+ ])
+ expect(logs).toEqual([
+ 'Building unsigned Linux x64 AppImage and deb artifacts; signing is a separate release step.',
+ 'Skipping the Linux package preflight; the package gate already passed.',
+ 'reset',
+ ])
+ })
+
+ it.each([
+ ['darwin', 'x64', '22.23.2', 'native Linux host'],
+ ['linux', 'arm64', '22.23.2', 'requires x64 Node'],
+ ['linux', 'x64', '25.0.0', 'Node 22.19+ or Node 24.x'],
+ ] as const)(
+ 'rejects unsupported host %s/%s with Node %s before running commands',
+ (platform, arch, nodeVersion, message) => {
+ const calls: CommandCall[] = []
+ const value = { ...options(calls), platform, arch, nodeVersion }
+
+ expect(() => packageLinuxArtifacts(value)).toThrow(message)
+ expect(calls).toEqual([])
+ },
+ )
+
+ it('stops before packaging when the headless check fails', () => {
+ const calls: CommandCall[] = []
+ const value: LinuxPackageOptions = {
+ ...options(calls),
+ run: (command, args, cwd, env) => {
+ calls.push({ command, args: [...args], cwd, env: { ...env } })
+ throw new Error('headless check failed')
+ },
+ }
+
+ expect(() => packageLinuxArtifacts(value)).toThrow('headless check failed')
+ expect(calls).toHaveLength(1)
+ })
+})
diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts
index 10876a1ec1..306a6a5ead 100644
--- a/dsh-plugin-desktop/tests/package.spec.ts
+++ b/dsh-plugin-desktop/tests/package.spec.ts
@@ -21,6 +21,7 @@ const workspaceRoot = new URL('../', packageRoot)
const manifest = JSON.parse(readFileSync(new URL('package.json', packageRoot), 'utf8')) as {
name?: unknown
version?: unknown
+ desktopName?: unknown
bin?: Record
exports?: Record
files?: unknown
@@ -47,7 +48,7 @@ const manifest = JSON.parse(readFileSync(new URL('package.json', packageRoot), '
win?: { icon?: unknown; target?: unknown; artifactName?: unknown }
nsis?: Record
portable?: Record
- linux?: { icon?: unknown }
+ linux?: { icon?: unknown; target?: unknown; category?: unknown; executableName?: unknown; maintainer?: unknown; syncDesktopName?: unknown; artifactName?: unknown }
}
dependencies?: Record
optionalDependencies?: Record
@@ -858,6 +859,14 @@ describe('published package surface', () => {
artifactName: 'DSH-Desktop-${version}-${arch}-Setup.${ext}',
})
expect(manifest.build?.linux?.icon).toBe('build/app-icon.png')
+ expect(manifest.build?.linux?.category).toBe('Development')
+ expect(manifest.build?.linux?.executableName).toBe('dsh-desktop')
+ expect(manifest.build?.linux?.syncDesktopName).toBe(true)
+ expect(manifest.desktopName).toBe('dsh-desktop')
+ expect(manifest.build?.linux?.target).toEqual([
+ { target: 'AppImage', arch: ['x64'] },
+ { target: 'deb', arch: ['x64'] },
+ ])
})
it('separates unsigned smoke packaging from the signed macOS release', () => {
@@ -870,6 +879,13 @@ describe('published package surface', () => {
expect(manifest.scripts?.['dist:mac-smoke']).toBe('node scripts/package-mac.ts')
expect(manifest.scripts?.['dist:win']).toBe('node scripts/package-win.ts')
expect(manifest.scripts?.['dist:win-portable']).toBe('node scripts/package-win-portable.ts')
+ expect(manifest.scripts?.['dist:linux']).toBe('node scripts/package-linux.ts')
+ expect(manifest.scripts?.['check:linux-package']).toContain('yarn workspace dsh-community-market build')
+ expect(manifest.scripts?.['check:linux-package']).toContain('yarn run build')
+ expect(manifest.scripts?.['check:linux-package']).toContain('yarn run typecheck')
+ expect(manifest.scripts?.['check:linux-package']).toContain('tests/package-linux.spec.ts')
+ expect(manifest.scripts?.['check:linux-package']).toContain('tests/verify-linux-package.spec.ts')
+ expect(manifest.scripts?.['check:linux-package']).toContain('yarn run verify:closure')
expect(manifest.scripts?.['check:win-package']).toContain('yarn workspace dsh-community-market build')
expect(manifest.scripts?.['check:win-package']).toContain('yarn run build')
expect(manifest.scripts?.['check:win-package']).toContain('yarn run typecheck')
@@ -898,6 +914,8 @@ describe('published package surface', () => {
.toBe('yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:win')
expect(workspaceManifest.scripts?.['dist:win-portable'])
.toBe('yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:win-portable')
+ expect(workspaceManifest.scripts?.['dist:linux'])
+ .toBe('yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:linux')
expect(manifest.build?.afterPack).toBe('./scripts/verify-packaged-runtime.ts')
expect(manifest.build?.mac).toEqual(expect.objectContaining({
extendInfo: {
diff --git a/dsh-plugin-desktop/tests/verify-linux-package.spec.ts b/dsh-plugin-desktop/tests/verify-linux-package.spec.ts
new file mode 100644
index 0000000000..c4d399adb2
--- /dev/null
+++ b/dsh-plugin-desktop/tests/verify-linux-package.spec.ts
@@ -0,0 +1,108 @@
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { verifyLinuxPackage, type LinuxPackageVerificationOptions } from '../scripts/verify-linux-package.ts'
+
+const temporaryRoots: string[] = []
+
+const AR_MAGIC = Buffer.from('!\n')
+
+function fixture(version = '2.0.0'): { readonly root: string } {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-linux-package-'))
+ temporaryRoots.push(root)
+ const dist = join(root, 'dist', 'linux')
+ mkdirSync(join(dist, 'linux-unpacked', 'resources'), { recursive: true })
+ writeFileSync(join(dist, `DSH-Desktop-${version}-x86_64.AppImage`), 'appimage')
+ writeFileSync(join(dist, `DSH-Desktop-${version}-amd64.deb`), AR_MAGIC)
+ writeFileSync(join(dist, 'linux-unpacked', 'dsh-desktop'), 'binary')
+ writeFileSync(join(dist, 'linux-unpacked', 'resources', 'app.asar'), 'asar')
+ chmodSync(join(dist, `DSH-Desktop-${version}-x86_64.AppImage`), 0o755)
+ chmodSync(join(dist, 'linux-unpacked', 'dsh-desktop'), 0o755)
+ return { root }
+}
+
+function options(root: string, version = '2.0.0'): LinuxPackageVerificationOptions {
+ return {
+ distDir: join(root, 'dist', 'linux'),
+ version,
+ executableName: 'dsh-desktop',
+ archNames: ['x86_64', 'amd64', 'x64'],
+ exists: path => {
+ try {
+ statSync(path)
+ return true
+ } catch {
+ return false
+ }
+ },
+ stat: path => {
+ const result = statSync(path)
+ return { size: result.size, isFile: result.isFile(), mode: result.mode }
+ },
+ readPrefix: (path, length) => readFileSync(path).subarray(0, length),
+ }
+}
+
+afterEach(() => {
+ for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('Linux package artifact verification', () => {
+ it('accepts valid AppImage, deb, and unpacked application', () => {
+ const value = fixture()
+
+ const result = verifyLinuxPackage(options(value.root))
+
+ expect(result.appImage).toContain('DSH-Desktop-2.0.0-x86_64.AppImage')
+ expect(result.deb).toContain('DSH-Desktop-2.0.0-amd64.deb')
+ })
+
+ it('rejects a missing AppImage artifact', () => {
+ const value = fixture()
+ rmSync(join(value.root, 'dist', 'linux', 'DSH-Desktop-2.0.0-x86_64.AppImage'))
+
+ expect(() => verifyLinuxPackage(options(value.root)))
+ .toThrow('missing the AppImage')
+ })
+
+ it('rejects a non-executable AppImage artifact', () => {
+ const value = fixture()
+ chmodSync(join(value.root, 'dist', 'linux', 'DSH-Desktop-2.0.0-x86_64.AppImage'), 0o644)
+
+ expect(() => verifyLinuxPackage(options(value.root)))
+ .toThrow('not executable')
+ })
+
+ it('rejects a deb artifact that is not an ar archive', () => {
+ const value = fixture()
+ writeFileSync(join(value.root, 'dist', 'linux', 'DSH-Desktop-2.0.0-amd64.deb'), 'not-an-archive')
+
+ expect(() => verifyLinuxPackage(options(value.root)))
+ .toThrow('not an ar archive')
+ })
+
+ it('rejects an unpacked application without an executable', () => {
+ const value = fixture()
+ rmSync(join(value.root, 'dist', 'linux', 'linux-unpacked', 'dsh-desktop'))
+
+ expect(() => verifyLinuxPackage(options(value.root)))
+ .toThrow('missing the unpacked executable')
+ })
+
+ it('rejects an unpacked application without app.asar', () => {
+ const value = fixture()
+ rmSync(join(value.root, 'dist', 'linux', 'linux-unpacked', 'resources', 'app.asar'))
+
+ expect(() => verifyLinuxPackage(options(value.root)))
+ .toThrow('missing the unpacked application archive')
+ })
+})
diff --git a/package.json b/package.json
index fab8d8a8bb..7141e6228b 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"dist:mac-smoke": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:mac-smoke",
"dist:win": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:win",
"dist:win-portable": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:win-portable",
+ "dist:linux": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dist:linux",
"upstream:version": "cd deepseek-harness && corepack pnpm --version",
"upstream:install": "cd deepseek-harness && corepack pnpm install --frozen-lockfile",
"upstream:build": "cd deepseek-harness && corepack pnpm run build"