快速开始
一个 MPK = 你的文件 + 一个 manifest.json。manifest 只回答三个问题。
| 问题 | 写哪个字段 |
|---|---|
| 这是什么应用 | id、version、name、icon |
| 怎么启动 | runtime:native(跑二进制)或 docker(跑容器) |
| 浏览器怎么访问 | web.listen:port(占一个端口)或 socket(同源路径) |
其余字段都是这三个问题的细节,完整清单见 manifest 字段。
第 1 步:摆目录
my-app/ manifest.json ← 唯一手写的文件 assets/icon.png ← 图标(PNG / JPEG / WebP / SVG) bin/app ← 你的程序 config/ ← 其他资源,可有可无
第 2 步:写 manifest
先问自己:在命令行里我会怎么启动它?然后把这一行逐段翻译成 JSON —— 会随环境变的值换成 ${MDESK_*},其余原样抄。
./app --listen=0.0.0.0:8080 --data=/var/lib/app
| 命令行里 | manifest 里 |
|---|---|
./app | "executable": "bin/app" |
--listen=0.0.0.0:8080 | "--listen=0.0.0.0:${MDESK_PORT}" |
--data=/var/lib/app | "--data=${MDESK_DATA_DIR}" |
| 在哪个目录跑 | "workingDir": "." |
{
"schema": 1,
"id": "mdesk.example.myapp",
"version": "1.0.0",
"name": "示例应用",
"description": "我的第一个 MPK 应用。",
"architectures": ["amd64"],
"icon": "assets/icon.png",
"runtime": "native",
"web": { "listen": "port", "openPath": "/", "healthPath": "/healthz" },
"native": {
"executable": "bin/app",
"args": ["--listen=0.0.0.0:${MDESK_PORT}", "--data=${MDESK_DATA_DIR}"],
"workingDir": "."
}
}第 3 步:打包
下载 mpkpack(6 个平台,不用装 Go),在源目录的上一级执行:
mpkpack validate ../my-app mpkpack build ../my-app --output ../dist/mdesk.example.myapp-1.0.0.mpk mpkpack validate ../dist/mdesk.example.myapp-1.0.0.mpk
三条都过就得到合法的 .mpk。任何一条报错,命令会指出具体哪个字段不对。
第 4 步:装进去
M·Desk 后台 → 应用中心 → 安装应用 → 选刚生成的 .mpk。装完点启动:状态「运行中」说明程序起来了,能打开页面说明路径没问题。打不开看 排错。
安装时报 package contains too many json metadata files,是 M·Desk 客户端太旧(2.7.114 及更早的验证器缺陷),不是你的包有问题。
manifest 字段
唯一需要手写的文件。字段分四组:顶层、web、native / docker、store。
完整示例
{
"schema": 1,
"id": "mdesk.example.myapp",
"version": "1.0.0",
"name": "示例应用",
"description": "一句话说明这个应用。",
"architectures": ["amd64", "arm64"],
"icon": "assets/icon.png",
"runtime": "native",
"web": { "listen": "port", "openPath": "/", "healthPath": "/healthz" },
"native": {
"executable": "bin/app",
"args": ["--listen=0.0.0.0:${MDESK_PORT}"],
"workingDir": "."
}
}顶层字段
| 字段 | 必填 | 填什么 |
|---|---|---|
schema | 是 | 固定写数字 1 |
id | 是 | 应用唯一标识,匹配 ^[a-z0-9][a-z0-9._-]{2,63}$,建议反向域名 |
version | 是 | SemVer 三段式如 1.0.0。改了版本才能升级 |
name | 是 | 显示名称,1–80 字符,可用中文 |
runtime | 是 | native 或 docker,见 四条路线 |
web | 是 | 访问契约,见下 |
description | 否 | 最多 500 字符 |
architectures | 否 | 空数组表示不限制。写错会在别的机器上提示架构不匹配 |
icon | 否 | 包内相对路径,仅 PNG / JPEG / WebP / SVG |
native / docker | 条件 | 与 runtime 对应,二者只能有其一 |
wizard | 否 | 安装表单,见 安装向导 |
store | — | 商店展示信息。不填的字段打包时会自动补上。见 发布信息 |
web 段
| 字段 | 说明 |
|---|---|
listen | port 或 socket,决定注入哪个变量 |
preferredPort | 仅 port 模式。端口被占时 M·Desk 会改分配,这不是强制端口 |
openPath | 点图标时打开的页面,通常是 / |
healthPath | 健康检查路径。2xx/3xx 视为正常,401/403 也算服务活着 |
openMode | 可选。默认用哪种方式打开:new-tab(浏览器新标签页)或 in-app(桌面窗口内)。不写则按 listen 推断:port → new-tab,socket → in-app |
openMode:应用该在哪儿打开
默认行为是跟着 listen 走的,因为这两者本来就绑在一起:
socket 的应用和 M·Desk 同源,可以放进窗口里的 iframe;
port 的应用是另一个源(不同端口),浏览器不允许这样嵌,只能开新标签页。
如果你的应用情况特殊,可以显式声明 openMode 覆盖这个默认。常见两种情况:
- 端口应用但能在窗口里跑:程序自己允许被嵌入(不发
X-Frame-Options: DENY,也不限制frame-ancestors),那可以写in-app,用户体验更连贯 - 套接字应用但需要完整浏览器能力:比如要用剪贴板、文件下载、OAuth 跳转,写
new-tab更稳妥
"web": {
"listen": "port",
"openPath": "/",
"healthPath": "/healthz",
"openMode": "in-app"
}
这只是默认值,不是强制。用户建好桌面图标后,可以在图标的编辑对话框里改成另一种打开方式,用户的选择优先于这里声明的值。
用户没改过时,包升级后改了 openMode,已有图标会自动跟着更新。
只接受两个值。写错(比如 window、iframe、tab)会在 mpkpack validate 和安装时直接报错,报错信息里会点名 web.openMode。大小写和首尾空格会自动归一化,NEW-TAB 等同于 new-tab。
native 段
| 字段 | 说明 |
|---|---|
executable | 包内相对路径。构建时只有这个文件被设为 0755,其余一律 0644 |
args | 参数数组,一个参数一个字符串,见 参数与变量 |
workingDir | 工作目录,. 表示版本目录 |
docker 段
| 字段 | 说明 |
|---|---|
compose | 包内 Compose 文件路径,如 docker-compose.yml |
多架构怎么打包
商店按 id 建索引:一个 id 只对应一个条目、一个包文件。所以不能按架构拆成多个包 —— 同 id 再导入会报「已存在」,加 --force 会直接覆盖,先导入的架构就没了。
| 情况 | 怎么做 |
|---|---|
| native,多架构 | 各架构二进制都放进同一个包,executable 指向启动脚本,按 uname -m 分发 |
| docker,多架构 | 用多架构镜像,architectures 写全,Docker 会自动拉对应架构 |
| 只有一种架构 | architectures 只写那一个。其他架构的机器上这个应用直接不显示(不是灰掉,是整条看不到) |
"architectures": ["amd64", "arm64"],
"native": { "executable": "bin/run", "args": ["--listen=0.0.0.0:${MDESK_PORT}"] }#!/bin/sh # bin/run —— 构建时只有它被设为 0755,其余文件是 0644 case "$(uname -m)" in x86_64|amd64) BIN=app-amd64 ;; aarch64|arm64) BIN=app-arm64 ;; *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; esac DIR="$(dirname "$0")" chmod +x "$DIR/$BIN" 2>/dev/null || true # 否则 permission denied exec "$DIR/$BIN" "$@"
别把 architectures 写空来「兼容所有机器」。空数组等于不限制,会在跑不了的架构上装成功、启动才失败。
写 JSON 的三个坑
- 不能写注释。JSON 不支持
//和/* */。 - 最后一个字段后不能有逗号。
- 文件必须是 UTF-8。中文直接写,不用转
\uXXXX。
不确定就 mpkpack validate,它会指出哪一行有问题。
id 同时是安装目录名、/apps/<id>/ 路径段和 Compose 项目名的来源。定下来后不要改 —— 改了等于换一个应用,用户数据和快捷方式都对不上。
四条路线
两个独立选择组合成四条路:怎么跑(native / docker)× 怎么访问(port / socket)。
选择一:怎么跑
native | docker | |
|---|---|---|
| 跑什么 | 包内的可执行文件 | Compose 定义的容器 |
| 适合 | 单文件二进制、自带运行时 | 已有镜像、多服务、依赖复杂 |
选择二:怎么访问
port | socket | |
|---|---|---|
| 访问地址 | http://主机:端口/ | /apps/<id>/... |
| 占用端口 | 占一个宿主端口 | 不占端口 |
| Cookie | 与其他端口应用共享主机域 | 天然同源隔离 |
| 程序改动 | 无 | 要支持 Socket + 路径前缀 |
| 注入变量 | ${MDESK_PORT} | ${MDESK_SOCKET}、${MDESK_BASE_PATH} |
先用 port。只有程序本身支持 Socket 且能处理路径前缀时,才用 socket 换同源体验。
A · Native + Port
最简单的一条路,程序不用改。要求:能监听动态分配的端口,且监听 0.0.0.0。
"runtime": "native",
"web": { "listen": "port", "preferredPort": 18080, "openPath": "/", "healthPath": "/healthz" },
"native": {
"executable": "bin/app",
"args": ["--listen=0.0.0.0:${MDESK_PORT}", "--data=${MDESK_DATA_DIR}"],
"workingDir": "."
}只支持固定端口的程序走不通这条路 —— preferredPort 只是「首选」,端口冲突时会被改分配。
B · Native + Socket
要求:程序能监听 Unix Socket、承载 HTTP,并正确处理 /apps/<id>/ 前缀。
"runtime": "native",
"web": { "listen": "socket", "openPath": "/", "healthPath": "/healthz" },
"native": {
"executable": "bin/app",
"args": [
"--socket=${MDESK_SOCKET}",
"--base-path=${MDESK_BASE_PATH}",
"--data=${MDESK_DATA_DIR}"
],
"workingDir": "."
}Socket 路径不要自己写(如 /tmp/app.sock),一律用 ${MDESK_SOCKET}。代理只按 HTTP 使用,非 HTTP 的 Socket 跑不起来。
C · Docker + Port
已有 Compose 的项目首选。要求:用已发布镜像(不现场 build),主服务只有一个 HTTP 端口。
"runtime": "docker",
"web": { "listen": "port", "preferredPort": 18081, "openPath": "/", "healthPath": "/" },
"docker": { "compose": "docker-compose.yml" }services:
web:
image: example/webapp:1.0.0
ports:
- "${MDESK_PORT}:8080" # 宿主端口用变量,容器端口写死
volumes:
- "${MDESK_DATA_DIR}:/data" # 不挂这里,升级后数据会丢
restart: unless-stopped不要写 container_name(多实例会冲突)。Compose 里的 ${MDESK_*} 由 M·Desk 传给 docker compose 命令本身,写的是变量名。
D · Docker + Socket
要求:容器内服务能监听 Unix Socket,且能处理路径前缀。
"runtime": "docker",
"web": { "listen": "socket", "openPath": "/", "healthPath": "/healthz" },
"docker": { "compose": "docker-compose.yml" }services:
web:
image: example/webapp:1.0.0
command: ["--socket=/run/mdesk/app.sock", "--base-path=${MDESK_BASE_PATH}"]
volumes:
- "${MDESK_RUN_DIR}:/run/mdesk" # Socket 目录挂进容器
- "${MDESK_DATA_DIR}:/data"Compose 里不能写 ${field.x},只认环境变量名。写成字段引用会报 invalid interpolation format,表现为「包装不上」。
socket 必读:子路径适配
socket 模式下 M·Desk 把应用挂在 /apps/<id>/ 并且不剥离前缀。程序收到的请求自带前缀,任何写死 / 的地方都要改。
| 位置 | 常见错误 | 正确做法 |
|---|---|---|
| HTML 资源 | <script src="/app.js"> | 相对路径 ./app.js,或拼 base |
| 前端 Router | createWebHistory() 无 base | createWebHistory(window.__BASE__) |
| API 请求 | fetch('/api/x') | fetch(base + '/api/x') |
| 重定向 | Location: /login | Location: /apps/<id>/login |
| Cookie | Path=/ | Path=/apps/<id> |
| WebSocket | ws://host/ws | 由当前 location 推导 |
| Service Worker | scope 落在根 | scope 与注册路径都带前缀 |
| iframe | X-Frame-Options: DENY | 允许同源嵌入,否则窗口里空白 |
最省事的做法是从当前页面反推前缀,不要写死应用 ID:
const base = new URL(".", window.location.href);
function appUrl(path) {
return new URL(path.replace(/^\/+/, ""), base).toString();
}服务端要生成重定向或 HTML 时,把 ${MDESK_BASE_PATH} 传进去:"args": ["--base-path=${MDESK_BASE_PATH}"]。
改不动就别硬改。退回 port 模式能立刻可用,代价只是多占一个端口。
参数与变量
最容易写错的地方。一句话:把命令行搬进数组,会变的值换成占位符。
args 是数组,不是一整行
"args": ["--listen=0.0.0.0:${MDESK_PORT}", "--data=${MDESK_DATA_DIR}"]等价于 ./app "--listen=0.0.0.0:8080" "--data=/data/mpk/apps/xxx"。一个数组元素 = 一个参数,不要塞多个。
这里不经过 shell
| 你写的 | 结果 |
|---|---|
|、>、&&、$HOME | 不解释,原样传给程序,程序看不懂 |
$( 或反引号 | 直接报错,应用起不来:native argument N contains a shell expression |
需要 shell 逻辑(管道、多进程、条件判断)就写个启动脚本,把 executable 指向它。脚本里可以直接读 $MDESK_PORT —— M·Desk 把这些值作为环境变量传给子进程。
#!/bin/sh
exec "$(dirname "$0")/bin/app" --listen="0.0.0.0:${MDESK_PORT}" --data="${MDESK_DATA_DIR}"10 个占位符
| 占位符 | 值 | 典型用途 |
|---|---|---|
${MDESK_PORT} | 分配给你的 TCP 端口 | --listen=0.0.0.0:${MDESK_PORT} |
${MDESK_SOCKET} | Unix Socket 完整路径 | --socket=${MDESK_SOCKET} |
${MDESK_BASE_PATH} | 访问前缀,如 /apps/<id> | --base-path=${MDESK_BASE_PATH} |
${MDESK_DATA_DIR} | 持久数据目录 | --data=${MDESK_DATA_DIR} |
${MDESK_APP_ROOT} | 当前版本安装目录(升级会换) | 读包内只读资源 |
${MDESK_VAR_DIR} | 可重建的临时目录 | 缓存 |
${MDESK_RUN_DIR} | 运行时文件目录 | 放 pid、Socket |
${MDESK_LOG_DIR} | 日志目录 | --log=${MDESK_LOG_DIR}/app.log |
${MDESK_APP_ID} | 应用 ID | 日志前缀、上报 |
${MDESK_APP_VERSION} | 应用版本号 | 上报、缓存标记 |
两个变量互斥
web.listen | ${MDESK_PORT} | ${MDESK_BASE_PATH} |
|---|---|---|
port | 实际端口 | 空字符串 |
socket | 空字符串 | /apps/<id> |
不要同时依赖这两个。${MDESK_SOCKET} 则始终有值。
数据写哪里
| 存什么 | 写到 | 为什么 |
|---|---|---|
| 用户数据、数据库、配置 | ${MDESK_DATA_DIR} | 升级、停止、卸载保留数据时都不动 |
| 缓存、可重建的文件 | ${MDESK_VAR_DIR} | 丢了能重建 |
| 程序自带的只读资源 | ${MDESK_APP_ROOT} | 跟着版本走,升级后会变 |
不要写死端口(8080),端口冲突时直接起不来。不要写死路径(/tmp/app.sock),换环境就失效。
构建打包
下载 mpkpack,三条命令得到成品。不用装 Go,也不用源码。
下载 mpkpack
| 系统 | 下载 | 大小 | SHA256 前 16 位 |
|---|---|---|---|
| Windows(x64) | mpkpack-windows-amd64.exe | 4.9 MB | e8d256c5ef376b13 |
| Linux(x64) | mpkpack-linux-amd64 | 4.8 MB | 48bfb4ee204066e6 |
| Linux(ARM64,多数 NAS) | mpkpack-linux-arm64 | 4.5 MB | a6f79b7ea283b35f |
| Linux(ARMv7) | mpkpack-linux-arm | 4.8 MB | f1eab79716b50a71 |
| macOS(Apple 芯片) | mpkpack-darwin-arm64 | 4.5 MB | 25fb5e23489868c5 |
| macOS(Intel) | mpkpack-darwin-amd64 | 4.8 MB | 61269b15928f68ad |
完整校验和:checksums.txt,下载后可核对:
sha256sum -c checksums.txt
Linux / macOS 加执行权限即可:
chmod +x mpkpack-linux-amd64 ./mpkpack-linux-amd64 validate ../my-app # 想直接用 mpkpack 这个名字,放进 PATH sudo mv mpkpack-linux-amd64 /usr/local/bin/mpkpack
Windows 直接运行 mpkpack-windows-amd64.exe。
整份文档是单文件 HTML,可以存一份离线看:下载本文档 —— 断网也能查,样式和表格全保留。
生成骨架(可选)
mpkpack init ../my-app \ --id mdesk.example.myapp \ --name "示例应用" \ --runtime native \ --listen socket
生成最小骨架,之后把资源、启动器和图标放进去,再按 字段表 补 manifest.json。
三条命令
# 1. 校验源目录 mpkpack validate ../my-app # 2. 构建 mpkpack build ../my-app --output ../dist/mdesk.example.myapp-1.0.0.mpk # 3. 校验成品 mpkpack validate ../dist/mdesk.example.myapp-1.0.0.mpk
files= 是你的文件数,会不一样。checksums.json 由构建器生成,不要手改。覆盖已有输出要加 --force。
文件权限
构建时只有 native.executable 被设为 0755,其余一律 0644。脚本内部调用的其他程序要自己 chmod。
失败对照
| 提示 | 原因 | 处理 |
|---|---|---|
| manifest 解析失败 | 注释、多余逗号、非 UTF-8 | 按「写 JSON 的三个坑」检查 |
| unknown field | 字段名拼错,或用了旧版不认识的段 | 对照字段表 |
| icon not found | 图标路径与包内实际不符 | 改 icon 或放对文件 |
| executable not found | native.executable 指向不存在的文件 | 改路径 |
| contains a shell expression | args 里有 $( 或反引号 | 去掉,改用启动脚本 |
| unsupported file | 包内有符号链接、设备文件、FIFO | 换成普通文件 |
发布信息
在 manifest 里加一个 store 段,打包,交付。其余信息商店从包里自动读。
不写也行 —— 打包时会把没填的字段自动补上。写了就按你写的来。
第 1 步:写 store 段
三个字段决定商店里怎么展示你:
"store": {
"category": "工具",
"author": "M·Desk",
"developer": "示例团队"
}| 字段 | 不填时 | 说明 | 上限 |
|---|---|---|---|
category | 未分类 | 商店分组,如 工具、AI、媒体 | 160 字 |
author | M·Desk | 维护者,显示在商店页面 | 160 字 |
developer | 应用名称 | 开发者署名 | 160 字 |
introduction | 同 description | 长介绍 | 16000 字 |
summary | 同 description | 列表摘要 | 4000 字 |
介绍不用写两遍:introduction 与 summary 留空时自动用 description。
第 2 步:打包并交付
mpkpack build ../my-app --output ../dist/mdesk.example.myapp-1.0.0.mpk
把 .mpk 交给商店即可。名称、版本、ID、架构、图标、包大小、哈希商店都从包里取,不用另外说明。
发新版本:改 version(不改版本号商店认不出是更新)→ 重新打包 → 重新交付。
搭建自己的源进阶
三步:打包 → 生成目录 → 扔到网上。用户填一个网址,就能在应用中心看到并安装你的应用。
应用源就是一个普通静态目录:没有后端、没有数据库、不用登录、不用写一行服务端代码。Nginx、对象存储、GitHub Pages、内网文件服务器,随便哪个都行。
三分钟跑通
# 1. 打包 mpkpack build ./my-app -output ./dist/example.app-1.0.0.mpk # 2. 生成源目录 mpkpack store ./dist/example.app-1.0.0.mpk --store-dir ./my-store # 3. 把这个目录整个传到网上,比如放到 https://apps.example.com/mystore/
然后在 M·Desk 里打开 应用中心 → 三方订阅源 → 管理订阅,填上那个网址,保存。应用中心侧栏第二项就是你的源。
mpkpack store 会自动建好 index.json、每个应用的详情文件、icons/ 和 packages/。这些文件不要手写、不要手改,改了哈希就对不上。格式细节见 源目录格式。
用户在哪儿添加
在应用中心切到「三方订阅源」,点右上角 管理订阅,表单里填两样:
| 填什么 | 规则 | 例子 |
|---|---|---|
| 名称 | 最多 60 字,显示在管理列表里 | 我的应用源 |
| 地址 | http(s),指向放 index.json 的那个目录 | https://apps.example.com/mystore/ |
保存后立即拉取一次。拉取失败不影响管理列表,状态会显示「未加载」,应用中心点一次刷新即可重试。同一个地址重复添加会被拒绝。
应用 ID 会被自动加前缀
这是整套机制里唯一需要记住的一件事。mpkpack store 入库时统一加 mdesk.,M·Desk 再把它换成该源自己的前缀:
manifest.json 里你写的 id = "example.app" mpkpack store 入库后 id = "mdesk.example.app" ← index.json 里长这样 用户在应用中心看到的 id = "myco.example.app" ← 前缀由系统分配
你不用改 manifest,也不用管前缀。 好处是两个不同的源可以发同名应用,互不冲突:一个叫 myco.example.app,另一个叫 other.example.app,用户装哪个就是哪个。
系统给每个源分配前缀时会避开内置源占用的名字:
mdesk casaos 1panel official installed thirdparty online
所以你不用担心自己的应用跟官方源撞名 —— 撞名也是两个独立条目。
发新版本
# 先改 manifest 里的 version,重新打包 mpkpack build ./my-app -output ./dist/example.app-1.1.0.mpk # 再入库,覆盖旧条目 mpkpack store ./dist/example.app-1.1.0.mpk --store-dir ./my-store --force
然后让用户在应用中心点一次「刷新」。不点刷新就继续看缓存里的旧版本。
源之间互不干扰
每个源有自己的目录缓存、自己的图标缓存、自己的刷新时间:
<数据目录>/mdeskapps/
catalog-cache.json ← M·Desk 官方源
sources/
myco/catalog-cache.json ← 你的源
other/catalog-cache.json ← 别人的源一个源挂了、停了、返回乱数据,其他源照常显示。停用某个源只让它从列表消失,已安装的应用不会被卸载。
刷新与失效
| 情况 | 表现 |
|---|---|
| 刚添加 | 立即拉一次;网络慢也不卡住管理列表 |
| 平时 | 走磁盘缓存,只有用户点「刷新」才重新拉 |
| 源暂时打不开 | 继续用缓存,列表照常显示,带刷新警告 |
| 停用源 | 列表里消失,直接访问它的应用 ID 返回 404 |
| 删除源 | 同上;已安装的应用继续能跑 |
上线前自检
| 检查 | 怎么验 |
|---|---|
index.json 打得开 | 浏览器访问「你的地址 + index.json」,返回 JSON 而不是 404 |
| 图标能加载 | 访问某条应用的 icon 相对路径 |
| 包能下载 | 访问 package.path,大小与 package.size 一致 |
| 哈希对得上 | sha256sum 结果与 package.sha256 相同 |
| 详情文件都在 | 每个 detail 指向的 .json 存在,且 id 与清单一致 |
| 子目录可用 | 放在 /mystore/ 这类子路径下也没问题 |
常见卡住的地方
| 现象 | 原因 | 处理 |
|---|---|---|
| 状态一直「未加载」 | 地址少了结尾 /,或路径下没有 index.json | 用浏览器直接打开「地址 + index.json」确认能返回 |
| 列表是空的 | 只放了 index.json,没放 packages/ 和 icons/ | 整个目录一起传,别挑文件 |
| 图标裂开 | icons/ 没传,或打包时 manifest 没写 icon | manifest 写 icon 后重新 build + store |
| 安装报「包校验失败」 | 手改过 index.json 或 packages/ 里的文件 | 重新 mpkpack store --force,不要手改 |
| 提示「已存在」 | 别的源已经发过同一个 packageId | 改 manifest 的 id,同名就是同一个应用 |
上限
| 项目 | 上限 |
|---|---|
| 一台设备能加的源 | 20 个 |
| 单个源的应用数 | 1000 个 |
index.json 大小 | 2 MB |
| 单个图标大小 | 8 MB |
超出上限时那个源会直接报错、不显示,其他源不受影响。应用特别多就拆成多个源。
源目录格式
了解结构就够排查问题了。正常情况下这些文件全由 mpkpack store 生成,你不该手写。
目录结构
my-store/
index.json ← 清单:有哪些应用
thirdparty.demo.json ← 详情,文件名 = packageId + .json
icons/
thirdparty.demo.svg ← 图标,独立文件
packages/
thirdparty.demo-1.0.0.mpk ← 安装包本体四部分缺一不可。detail、icon、package.path 都是相对这个目录的路径。
文件名用的是 packageId(你在 manifest 里写的那个),不是带 mdesk. 前缀的 id。两者只差一个前缀,手写时最容易搞错。
index.json
只有清单,不含安装包信息。真实产物:
{
"schema": 1,
"name": "M·Desk 应用",
"updatedAt": "2026-10-22T19:01:54+08:00",
"apps": [
{
"schema": 1,
"id": "mdesk.thirdparty.demo",
"packageId": "thirdparty.demo",
"name": "三方示例",
"summary": "来自三方订阅源的示例应用",
"category": "工具",
"version": "1.0.0",
"author": "Third Party",
"developer": "第三方开发者",
"icon": "icons/thirdparty.demo.svg",
"detail": "thirdparty.demo.json",
"introduction": "来自三方订阅源的示例应用"
}
]
}| 字段 | 说明 |
|---|---|
schema | 固定 1 |
name | 源名称,仅供展示 |
updatedAt | 生成时间,RFC 3339 |
apps[].id | 对外 ID,必须以 mdesk. 开头,否则整个源被判非法。正常由工具自动加,手写要自己带 |
apps[].packageId | 你 manifest 里写的原始 ID,全局唯一 |
apps[].detail | 详情文件名,不是 id + .json,而是 packageId + .json |
apps[].icon | 图标相对路径;manifest 没写 icon 时这个字段不存在 |
apps[].id 缺了 mdesk. 前缀,程序会直接报 invalid application ID,整个 index.json 作废。这是手写源最容易踩的坑 —— 前缀不是你随便起的名字,它是硬性格式的一部分。
详情文件
比清单条目多一个 package 段,其余字段相同:
{
"schema": 1,
"id": "mdesk.thirdparty.demo",
"packageId": "thirdparty.demo",
"name": "三方示例",
"summary": "来自三方订阅源的示例应用",
"category": "工具",
"version": "1.0.0",
"author": "Third Party",
"developer": "第三方开发者",
"icon": "icons/thirdparty.demo.svg",
"detail": "thirdparty.demo.json",
"introduction": "来自三方订阅源的示例应用",
"package": {
"path": "packages/thirdparty.demo-1.0.0.mpk",
"sha256": "51b39a0b8e4ffc57443ffa80eb778a1d147f91c9a2759b5532bfb67028f2f478",
"size": 763
}
}| 字段 | 说明 |
|---|---|
package.path | 相对源目录的包路径 |
package.sha256 | 包的 SHA-256,安装前逐字节校验 |
package.size | 包的字节数 |
字段从哪来
清单和详情里的展示信息,全部来自你 manifest 的 store 段和顶层字段:
| 源目录字段 | 来自 manifest |
|---|---|
name | name |
version | version |
summary | store.summary,留空回退 description |
introduction | store.introduction,留空回退 description |
category | store.category,不填自动补 未分类 |
author | store.author,不填自动补 M·Desk |
developer | store.developer,不填自动用应用名称 |
icon | icon,从包里抽出来单独存 |
正常走 mpkpack build 就不会缺这些字段:打包时会把整段 store 连同三个必填项一起补齐(命令输出里会提示 filled store defaults)。只有绕过 build、手工改包导致 store 段整个不存在时,mpkpack store 才会拒绝并报「manifest.json has no store section」。
客户端怎么读
M·Desk 只做这几件事,你不需要提供任何接口:
GET <地址>index.json 读清单
GET <地址><detail> 读某个应用的详情
GET <地址><icon> 取图标
GET <地址><package.path> 下载安装包
↓
逐字节校验 sha256 与 size,通过才装下载回来的包会先算 SHA-256 与 package.sha256 比对。手改过任何文件都会在这里失败,报「包校验失败」。改内容请走 mpkpack store --force 重新生成。
格式约束
| 约束 | 说明 |
|---|---|
| 不支持源内分页 | 一次读完整个 index.json,最多 1000 条 |
| 不支持跨目录引用 | 所有路径必须落在源目录内,不能用 ../ 逃出去 |
| 不支持重定向 | 服务端不跟随 3xx,地址要直接可访问 |
| 图标类型 | PNG / JPEG / WebP / SVG |
| 包类型 | 只有 .mpk(tar + gzip),扩展名不参与判断 |
安装向导可选
让用户在安装时填端口、密码、主页。向导只是传参层,不执行任何代码。
什么时候需要
用户要自定义端口、用户名、密码、主页,或应用首次启动必须有某个值才能跑。不需要就别加 —— 没有 wizard 段的包,安装行为完全不变。
目录与 manifest
my-app/
wizard/
install.json ← 安装时展示(可选)
config.json ← 已安装后在「应用设置」里展示(可选)"wizard": {
"install": "wizard/install.json",
"config": "wizard/config.json",
"bindings": {
"preferredPort": "${field.port}",
"native": { "env": { "APP_HOME": "${field.home}" } }
}
}两个文件至少声明一个才叫「有向导」,都省略就是无向导包。
表单格式
顶层是一个对象,里面只有 steps 一个键;每一步用 stepTitle 起标题,用 items 装控件;每个控件用 field 声明变量名。就这三个键名,没有别的写法。
{
"steps": [
{
"stepTitle": "基本设置",
"items": [
{ "field": "port", "type": "number", "label": "端口",
"initValue": 7863, "min": 1024, "max": 65535 },
{ "field": "home", "type": "text", "label": "主页",
"initValue": "https://example.com/", "required": true }
]
}
]
}键名只有 steps / stepTitle / items / field 四个。写成 title、fields、name 会直接报错(json: unknown field "title"),解析器不认任何未知键 —— 它宁可报错也不猜你想写什么。
顶层也可以直接写数组,省略 {"steps": ...} 这层,两种写法等价。
| 键 | 位置 | 说明 |
|---|---|---|
steps | 顶层 | 步骤数组,1–16 步 |
stepTitle | 步骤 | 这一步的标题,最多 60 字,可省略 |
items | 步骤 | 控件数组,每步 1–32 个,整张表单最多 64 个 |
field | 控件 | 变量名,匹配 ^[a-z][a-z0-9_]{0,63}$;除 tips 外必填,同一张表单内不可重名 |
field 不能用 manifest 自己的键名(id、version、web、wizard 等),免得绑定写错时和清单属性混淆。
| type | 说明 | 可用属性 |
|---|---|---|
text | 单行文本 | initValue、min、max、len、pattern |
password | 敏感文本,API 恒返回 __MASKED__ | 同上,另加 required |
number | 整数 | initValue、min、max |
radio / select | 单选 | options、initValue |
checkbox | 多选 | options、initValue(数组) |
switch | 布尔开关 | initValue: true / false |
tips | 纯提示文本,不参与提交 | text |
len 是精确长度,不是上限。写 "len": 128 会让「下一步」永远点不动。要上限用 max,要区间用 min + max。
值怎么传给程序
| 写法 | 含义 | 能出现在 |
|---|---|---|
${MDESK_*} | M·Desk 注入的运行时上下文 | args、env |
${field.<name>} | 用户填的值 | args、bindings.*.env、bindings.preferredPort |
两个命名空间严格分离。用户值只展开一次,填 ${field.b} 也不会变成另一个变量的值。
字段为空时对应环境变量完全不注入(不是注入空串),所以程序里写 ${START_URL:-默认值} 能天然回落。
Docker 包怎么接住值
Compose 里插值的是环境变量名,不是 ${field.x}。写成字段引用会报 invalid interpolation format。
environment:
APP_HOST: "${APP_HOST}" # 对
# APP_HOST: "${field.host}" # 错,Compose 报 invalid interpolation format排错与验收
按阶段定位:装不上、起不来、打得开但白屏。交付前再过一遍验收清单。
安装阶段
| 现象 | 原因 | 处理 |
|---|---|---|
| 提示包非法 | 产物被手工改过 | 重新 mpkpack build,不要手改 checksums.json |
| too many json metadata files | M·Desk ≤ 2.7.114 的验证器缺陷 | 升级 M·Desk,与包无关 |
| 提示架构不匹配 | architectures 写错或本机不支持 | 改 architectures,或换目标机器 |
启动阶段
| 现象 | 原因 | 处理 |
|---|---|---|
| 一直「启动中」后失败 | 程序没监听 healthPath | 确认程序真在监听、路径写对 |
| 立刻退出 | 参数写错、端口没读到 | 看应用日志,确认 ${MDESK_PORT} 被替换 |
| contains a shell expression | args 里有 $( 或反引号 | 去掉,改用启动脚本 |
| permission denied | 可执行位没设 | 只有 executable 是 0755,脚本内部调用的文件自己 chmod |
| 端口冲突 | 程序写死端口 | 改成读 ${MDESK_PORT} |
页面阶段
| 现象 | 原因 | 处理 |
|---|---|---|
| 白屏、控制台 404 | 资源用了绝对路径 / | 改相对路径或拼 base,见 子路径适配 |
| 页面能开但接口失败 | API 前缀没带 | 同上 |
| 登录后立刻掉线 | Cookie Path 不对 | 设为 /apps/<id> |
| WebSocket 连不上 | URL 写死 ws://host/ws | 由当前 location 推导 |
| 窗口里空白 | 应用禁止 iframe | 允许同源嵌入 |
升级与卸载
- 升级后数据没了:数据写到了
${MDESK_APP_ROOT},应改到${MDESK_DATA_DIR}。 - 升级后起不来:M·Desk 会按字节回滚到旧版本,检查新版本启动参数是否兼容。
- 升级成功但停车:状态是
waitingConfig,补填必填项即可启动 —— 这不是失败。
先看日志再改包:应用目录下 logs/application.log 与 var/logs/。
验收清单
打包成功只是第一步。交付前逐项过一遍:
| 环节 | 检查 |
|---|---|
| 包结构 | mpkpack validate 通过;图标路径存在;启动器或 Compose 文件实际存在;无符号链接/特殊文件/路径穿越 |
| 运行 | 实际监听正确模式(socket 用 ${MDESK_SOCKET},TCP 用 ${MDESK_PORT} 并监听 0.0.0.0);healthPath 约 35 秒内返回可接受状态;停止重启后仍健康 |
| Web | 首页、CSS、JS、图片、字体都加载;API 与登录可用;Cookie 范围正确;重定向不跳错主机;WebSocket 能收发 |
| 生命周期 | 首次安装、停止启动、运行中升级、停止状态升级都成功;启动失败能回滚;普通卸载留数据、彻底卸载删数据;桌面图标创建/刷新/移除正常 |
| 向导 | 仅当声明了 wizard:两个 json 路径与格式合法;绑定字段真实存在;bindings 与 runtime 匹配;留空仍能启动;password 只出现在 secrets.json |
| 上架 | 有 store 段且三个必填都填;版本号已递增;architectures 与实际发布一致 |