修复桌面壳开发端口对齐

桌面壳 dev 命令显式锁定 Vite 3000 端口

dev 端口解析支持严格端口模式并避免显式端口被 Linux 端口段改写

补充端口错配回归测试和宿主壳文档记忆
This commit is contained in:
2026-06-21 21:57:00 +08:00
parent 768546fdc7
commit 15623a9eff
9 changed files with 93 additions and 8 deletions
+3 -3
View File
@@ -2341,8 +2341,8 @@ if (config.build?.beforeBuildCommand !== 'npm --prefix ../.. run build:raw && np
throw new Error('desktop shell build command must run from apps/desktop-shell');
}
if (config.build?.beforeDevCommand !== 'npm --prefix ../.. run dev:web') {
throw new Error('desktop shell dev command must run the root H5 Vite dev server');
if (config.build?.beforeDevCommand !== 'npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port') {
throw new Error('desktop shell dev command must run the root H5 Vite dev server on the Tauri devUrl port');
}
if (
@@ -2350,7 +2350,7 @@ if (
config.build?.devUrl !== 'http://127.0.0.1:3000/'
) {
throw new Error(
'desktop shell dev script must pin WEB_PORT=3000 to match Tauri devUrl',
'desktop shell dev script and beforeDevCommand must pin WEB_PORT=3000 to match Tauri devUrl',
);
}
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0",
"identifier": "world.genarrative.desktop",
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"beforeDevCommand": "npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port",
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
"devUrl": "http://127.0.0.1:3000/",
"frontendDist": "../../../dist"
@@ -3264,3 +3264,10 @@
- 决策:`scripts/check-native-shells.mjs` 必须把 HostBridge 模块分成三端共同、Expo / Tauri 原生 App 共同、移动端专属、桌面端专属和微信端专属五类,并从现有文件清单反推实际分类。新增、拆分或迁移桥接模块时,必须先更新分类归属,再同步目录清单、文档和能力流证据。
- 影响范围:`miniprogram/host-bridge/``apps/mobile-shell/src/host-bridge/``apps/desktop-shell/src-tauri/src/host_bridge/``scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-21 Tauri devUrl 与 Vite 端口必须显式对齐
- 背景:`npm run desktop-shell:dev` 通过 Tauri `devUrl` 固定加载 `http://127.0.0.1:3000/`,但 Linux dev 端口段逻辑会把未显式指定的 `dev:web` 主站端口映射到用户端口段,例如 `10000+`。只在桌面壳 package script 里设置 `WEB_PORT=3000` 不会让 `scripts/dev.mjs` 把 Web 端口视为显式 CLI 参数,结果 Tauri 仍打开 3000,而 Vite 实际监听其它端口。
- 决策:桌面壳 `beforeDevCommand` 必须执行 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,用 CLI 参数锁定主站 Vite 端口并禁止静默漂移;`devUrl` 继续固定 `http://127.0.0.1:3000/`。如果 3000 被占用,应该释放端口后再启动桌面壳,而不是让 Vite 漂移后继续由 Tauri 加载旧端口。
- 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json``apps/desktop-shell/scripts/check-config.mjs``scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -2198,6 +2198,14 @@
- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/jump_hop.rs`
## Tauri devUrl 不会自动跟随 dev:web 端口漂移
- 现象:运行 `npm run desktop-shell:dev` 时终端显示主站 Vite 实际启动在 `10000+` 端口,但 Tauri 窗口仍加载 `http://127.0.0.1:3000/`,桌面壳表现为白屏、连接失败或加载到旧页面。
- 原因:Linux dev 端口段只把 CLI `--web-port` 视为显式端口;桌面壳 package script 里的 `WEB_PORT=3000` 会被端口段映射覆盖。Tauri `devUrl` 是静态配置,不会读取 `scripts/dev.mjs` 最终解析出的漂移端口。
- 处理:桌面壳 `beforeDevCommand` 必须使用 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,让 Vite 实际监听端口和 Tauri `devUrl` 一致,并在 3000 被占用时直接失败。若 3000 被占用,先释放占用进程再启动桌面壳,不要依赖 Vite 漂移。
- 验证:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"``npm run desktop-shell:typecheck`、实际启动时终端应显示 `[dev] web: http://127.0.0.1:3000`
- 关联:`apps/desktop-shell/src-tauri/tauri.conf.json``apps/desktop-shell/scripts/check-config.mjs``scripts/dev.mjs`
## 自动试玩退出不要回到生成页
- 现象:拼图草稿生成完成后自动进入试玩,用户从试玩退出或使用系统返回时落回生成进度页,页面还暴露“重新生成”按钮。
@@ -560,6 +560,8 @@ GameBridge 禁止:
2026-06-18 追加:桌面壳 bundle 图标集从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生,补齐 `32x32.png``128x128.png``128x128@2x.png``icon.ico``icon.icns`Tauri `bundle.icon` 同时声明这些平台图标。没有引入外部素材或占位图;`apps/desktop-shell/scripts/check-config.mjs` 会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌素材。
2026-06-21 追加:桌面壳本地开发入口必须让 Tauri `devUrl` 和主站 Vite 实际监听端口保持一致。`apps/desktop-shell/src-tauri/tauri.conf.json``devUrl` 固定为 `http://127.0.0.1:3000/``beforeDevCommand` 必须执行 `npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port`,不能只依赖 `WEB_PORT=3000` 环境变量;Linux 用户端口段分配会改写未显式指定的 Web 端口,裸 `dev:web` 可能漂移到 `10000+`,导致 Tauri 窗口加载 3000 时白屏或连接失败。3000 被占用时必须直接失败并提示释放端口,不能静默漂移。`apps/desktop-shell/scripts/check-config.mjs``scripts/dev.test.ts` 会反查该口径。
2026-06-18 追加:移动壳生产包网络安全元数据显式收紧。Android `usesCleartextTraffic=false`iOS `NSAppTransportSecurity.NSAllowsArbitraryLoads=false`,并设置 `ITSAppUsesNonExemptEncryption=false` 作为当前未接入自定义加密能力的出口合规声明;开发联调本机 Vite 仍通过显式 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/` 进入 development build,不把任意明文流量开关带进默认包配置。`apps/mobile-shell/scripts/check-config.mjs` 会校验这些网络安全字段。
2026-06-19 追加:移动壳麦克风权限只服务同源 H5 实时玩法。汪汪声浪正式 `published` runtime 必须通过浏览器标准 `getUserMedia({ audio: true })` 采样真实音量;Expo 壳因此显式配置 iOS 麦克风用途文案、Android `RECORD_AUDIO` 权限和 `react-native-webview``mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"`。该权限不新增 HostBridge method,不开放后台录音、远程语音 SDK、任意文件系统或 AI H5 sandbox 直连宿主能力;只有通过同源主站 WebView 留壳规则的 H5 页面能触发系统授权。`expo-camera``expo-image-picker` 的麦克风用途文案必须保持同一条真实玩法说明,`android.blockedPermissions` 不得再移除 `RECORD_AUDIO`,最终 public config 只允许 `CAMERA``POST_NOTIFICATIONS``RECORD_AUDIO` 三类显式权限。移动拍照、扫码、相册、音频导入和本地通知仍按各自受控能力边界执行,不把麦克风权限当作通用录音入口。`apps/mobile-shell/scripts/check-config.mjs``check-expo-config.mjs` 会拒绝权限、文案或 WebView 媒体捕获策略漂移。
+14
View File
@@ -479,6 +479,7 @@ export async function findAvailablePort({
reservedPorts = new Set(),
maxAttempts = null,
portRange = null,
strict = false,
}) {
const range = normalizePortRange(portRange);
const startPort = normalizePort(preferredPort, 0);
@@ -501,6 +502,18 @@ export async function findAvailablePort({
throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label}`);
}
if (strict && startPort !== 0) {
if (reservedPorts.has(startPort)) {
throw new Error(`端口 ${host}:${startPort} 已被当前 dev 启动流程占用,无法严格使用该端口`);
}
if (await isPortAvailable({host, port: startPort})) {
return startPort;
}
throw new Error(`端口 ${host}:${startPort} 不可用,无法严格使用该端口`);
}
const boundedAttempts = range
? Number.isFinite(maxAttempts)
? Math.min(Math.max(0, maxAttempts), range.end - startPort)
@@ -565,6 +578,7 @@ export async function resolveDevStackPorts(config) {
preferredPort: portConfig.preferredPort,
reservedPorts,
portRange: portConfig.portRange,
strict: Boolean(portConfig.strict),
});
reservedPorts.add(resolvedPort);
result[name] = resolvedPort;
+17
View File
@@ -58,6 +58,23 @@ describe('dev stack port utils', () => {
}
});
it('严格端口模式在端口被占用时直接失败而不是漂移', async () => {
const server = await reservePort(0);
const port = server.address().port;
try {
await expect(
findAvailablePort({
host: '127.0.0.1',
preferredPort: port,
strict: true,
}),
).rejects.toThrow('无法严格使用该端口');
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
it('端口查找不会越过 Linux 用户端口段', async () => {
await expect(
findAvailablePort({
+11 -4
View File
@@ -132,6 +132,7 @@ function parseArgs(argv, baseEnv) {
migrationBootstrapSecretMode: 'auto',
watch: false,
interactive: true,
strictWebPort: false,
};
for (let index = 0; index < args.length; index += 1) {
@@ -167,6 +168,9 @@ function parseArgs(argv, baseEnv) {
options.webPort = normalizePort(readValue(), options.webPort);
explicitOptions.add('webPort');
break;
case '--strict-web-port':
options.strictWebPort = true;
break;
case '--admin-web-host':
options.adminWebHost = readValue();
explicitOptions.add('adminWebHost');
@@ -956,13 +960,15 @@ class DevRunner {
async resolvePorts(command) {
const {options} = this;
const portConfig = {};
const portRangeFor = (optionName) =>
this.explicitOptions.has(optionName) ? null : this.state.portRange;
if (command === 'all' || command === 'spacetime') {
if (!options.skipSpacetime && !this.state.spacetimeReused) {
portConfig.spacetime = {
host: options.spacetimeHost,
preferredPort: options.spacetimePort,
portRange: this.state.portRange,
portRange: portRangeFor('spacetimePort'),
};
}
}
@@ -971,7 +977,7 @@ class DevRunner {
portConfig.api = {
host: options.apiHost,
preferredPort: options.apiPort,
portRange: this.state.portRange,
portRange: portRangeFor('apiPort'),
};
}
@@ -979,7 +985,8 @@ class DevRunner {
portConfig.web = {
host: options.webHost,
preferredPort: options.webPort,
portRange: this.state.portRange,
portRange: portRangeFor('webPort'),
strict: options.strictWebPort,
};
}
@@ -987,7 +994,7 @@ class DevRunner {
portConfig.adminWeb = {
host: options.adminWebHost,
preferredPort: options.adminWebPort,
portRange: this.state.portRange,
portRange: portRangeFor('adminWebPort'),
};
}
+30
View File
@@ -139,6 +139,36 @@ describe('dev scheduler argument routing', () => {
}
});
linuxTest('Linux 桌面壳显式指定 web-port 时不被系统级端口段改写', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs(
['web', '--web-port', '3000', '--strict-web-port'],
{
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
},
);
const runner = new DevRunner(options, {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {