diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 00000000..feb0f469 --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,72 @@ +name: desktop-build + +on: + workflow_dispatch: + pull_request: + paths: + - 'src-tauri/**' + - 'tools/desktop/**' + - 'web-ui/**' + - 'cli.js' + - 'cli/**' + - 'lib/**' + - 'plugins/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/desktop-build.yml' + +permissions: + contents: read + +jobs: + tauri: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS + os: macos-latest + - name: Windows + os: windows-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + + - name: Install dependencies + run: npm ci + + - name: Verify npm package payload + run: npm pack --dry-run --json + + - name: Stage desktop runtime resources + run: npm run desktop:stage + + - name: Build desktop app + if: matrix.name != 'Windows' + run: npm run desktop:build + + - name: Build Windows installer + if: matrix.name == 'Windows' + run: npm run desktop:build -- --bundles nsis + + - name: Upload desktop bundles + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: codexmate-desktop-${{ matrix.name }} + path: | + src-tauri/target/release/bundle/** + src-tauri/target/release/bundle/nsis/** + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfee495c..e3e4d249 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: release +name: release run-name: "${{ github.event.repository.name }} ${{ inputs.tag || github.ref_name || 'auto' }}" on: workflow_dispatch: @@ -14,17 +14,28 @@ permissions: contents: write jobs: - release: + resolve: runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.resolve.outputs.release_tag }} + release_version: ${{ steps.resolve.outputs.release_version }} + release_mode: ${{ steps.resolve.outputs.release_mode }} + latest_tag: ${{ steps.resolve.outputs.latest_tag }} + package_version: ${{ steps.resolve.outputs.package_version }} + base_version: ${{ steps.resolve.outputs.base_version }} + base_source: ${{ steps.resolve.outputs.base_source }} + tag_exists: ${{ steps.resolve.outputs.tag_exists }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 fetch-tags: true + persist-credentials: false - name: Fetch tags run: git fetch --tags --force - - uses: actions/setup-node@v4 + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '18' cache: 'npm' @@ -131,18 +142,6 @@ jobs: baseSource = tagExists ? 'package_tag' : 'package_version'; } - const envLines = [ - `RELEASE_TAG=${resolvedTag}`, - `RELEASE_VERSION=${expectedVersion}`, - `RELEASE_MODE=${mode}`, - `LATEST_TAG=${latestTag}`, - `PACKAGE_VERSION=${pkgVersion}`, - `BASE_VERSION=${baseVersion}`, - `BASE_SOURCE=${baseSource}`, - `TAG_EXISTS=${tagExists ? 'true' : 'false'}` - ].join('\n') + '\n'; - fs.appendFileSync(process.env.GITHUB_ENV, envLines); - const outputLines = [ `release_tag=${resolvedTag}`, `release_version=${expectedVersion}`, @@ -170,23 +169,93 @@ jobs: fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryLines + '\n'); console.log(`::notice title=Resolved Tag::${resolvedTag}`); NODE + + desktop: + needs: resolve + name: desktop-${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS + os: macos-latest + - name: Windows + os: windows-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Fetch tags + run: git fetch --tags --force - name: Checkout target tag - if: ${{ steps.resolve.outputs.tag_exists == 'true' }} + if: ${{ needs.resolve.outputs.tag_exists == 'true' }} env: - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} run: | git rev-parse "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1 git checkout "${RELEASE_TAG}" - - name: Verify tag matches package.json version - if: ${{ steps.resolve.outputs.tag_exists == 'true' }} + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + - name: Verify package.json matches release tag env: - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + run: | + node -e "const pkg=require('./package.json'); const tag=process.env.RELEASE_TAG; const expected='v'+pkg.version; if(tag!==expected){ console.error('package.json '+expected+' does not match resolved release tag '+tag); process.exit(1);} console.log('Package matches '+expected);" + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + - name: Install dependencies + run: npm ci + - name: Verify npm package payload + run: npm pack --dry-run --json + - name: Stage desktop runtime resources + run: npm run desktop:stage + - name: Build desktop app + run: npm run desktop:build + - name: Upload desktop release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: codexmate-desktop-${{ matrix.name }} + path: | + src-tauri/target/release/bundle/dmg/*.dmg + src-tauri/target/release/bundle/msi/*.msi + src-tauri/target/release/bundle/nsis/*.exe + if-no-files-found: error + + release: + needs: + - resolve + - desktop + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Fetch tags + run: git fetch --tags --force + - name: Checkout target tag + if: ${{ needs.resolve.outputs.tag_exists == 'true' }} + env: + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} run: | - node -e "const pkg=require('./package.json'); const tag=process.env.RELEASE_TAG; const expected='v'+pkg.version; if(tag!==expected){ console.error('Tag '+tag+' does not match package.json version '+expected); process.exit(1);} console.log('Tag matches '+expected);" + git rev-parse "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1 + git checkout "${RELEASE_TAG}" + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '18' - name: Verify package.json matches release tag - if: ${{ steps.resolve.outputs.tag_exists != 'true' }} env: - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} run: | node -e "const pkg=require('./package.json'); const tag=process.env.RELEASE_TAG; const expected='v'+pkg.version; if(tag!==expected){ console.error('Current commit package.json '+expected+' does not match resolved release tag '+tag); process.exit(1);} console.log('Current package matches '+expected);" - name: Check if release already exists @@ -204,7 +273,7 @@ jobs: - name: Compute release name if: ${{ steps.release_exists.outputs.skip != 'true' }} env: - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} run: | node -e "const p=require('./package.json'); const tag=process.env.RELEASE_TAG; const name=p.name.includes('/')? p.name.split('/')[1]: p.name; const value=name+' '+tag; console.log('RELEASE_NAME='+value);" >> "$GITHUB_ENV" - name: Pack npm artifact @@ -224,12 +293,18 @@ jobs: cli.js cli/ lib/ plugins/ web-ui.html web-ui/ \ node_modules/ package.json LICENSE README.md README.zh.md echo "STANDALONE_TGZ=$name" >> "$GITHUB_ENV" + - name: Download desktop release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: codexmate-desktop-* + path: desktop-release-assets + merge-multiple: true - name: Fetch contributors from GitHub API if: ${{ steps.release_exists.outputs.skip != 'true' }} env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} - LATEST_TAG: ${{ steps.resolve.outputs.latest_tag }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + LATEST_TAG: ${{ needs.resolve.outputs.latest_tag }} CONTRIBUTORS_FILE: release-contributors.txt run: | if [ -z "${LATEST_TAG}" ]; then @@ -253,8 +328,7 @@ jobs: | sort -u \ | while read -r pr_number; do gh pr view "${pr_number}" --repo "${GITHUB_REPOSITORY}" --json author --jq '.author.login' 2>/dev/null || true - done \ - | sort -u > "${tmp_merged}" || true + done | sort -u > "${tmp_merged}" || true if [ -s "${tmp_merged}" ]; then grep -ivE '^anupamme$' "${tmp_merged}" > "${CONTRIBUTORS_FILE}" || true @@ -265,8 +339,8 @@ jobs: - name: Generate release notes from actual commit range if: ${{ steps.release_exists.outputs.skip != 'true' }} env: - RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }} - TAG_EXISTS: ${{ steps.resolve.outputs.tag_exists }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + TAG_EXISTS: ${{ needs.resolve.outputs.tag_exists }} RELEASE_CHANGELOG_FILE: release-changelog.md CONTRIBUTORS_FILE: release-contributors.txt run: | @@ -284,7 +358,7 @@ jobs: if: ${{ steps.release_exists.outputs.skip != 'true' }} uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.resolve.outputs.release_tag }} + tag_name: ${{ needs.resolve.outputs.release_tag }} target_commitish: ${{ github.sha }} name: ${{ env.RELEASE_NAME }} prerelease: false @@ -293,4 +367,7 @@ jobs: files: | ${{ env.PACKAGE_TGZ }} ${{ env.STANDALONE_TGZ }} + desktop-release-assets/**/*.dmg + desktop-release-assets/**/*.msi + desktop-release-assets/**/*.exe generate_release_notes: false diff --git a/.gitignore b/.gitignore index 3581375f..ddf9a500 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,3 @@ codex-switcher.exe log.txt tmp/ .gitnexus/ - diff --git a/doc/desktop.md b/doc/desktop.md new file mode 100644 index 00000000..a8998738 --- /dev/null +++ b/doc/desktop.md @@ -0,0 +1,98 @@ +# Codex Mate Desktop (Tauri) + +Codex Mate 的桌面版使用 Tauri 作为 Windows / macOS 外壳,复用现有 Node CLI 与 Web UI 服务。 + +## 架构 + +- Tauri 负责桌面窗口、系统打包和平台安装包。 +- 现有 `cli.js run --host 127.0.0.1 --no-browser` 继续提供本地 Web UI 与 `/api`。 +- 桌面窗口加载 `http://127.0.0.1:3737`,避免重写现有 Web UI API。 +- Rust / Tauri 源码只参与桌面构建阶段,不进入主 npm CLI 包。 +- `npm run desktop:stage` 会先生成稳定运行时目录 `dist/desktop/codexmate/`,再由 Tauri 把这个目录作为单一 resource 打进 app。 +- 打包产物内置构建机当前 Node.js runtime,release 启动后端时优先使用 bundled `node-runtime/node(.exe)`,不依赖用户系统 PATH 里的 `node`。 + +## Staging 布局 + +`tools/desktop/prepare-tauri-resources.js` 参考 Codex 的“先 stage、再打包、再校验”模型,生成的目录大致是: + +```text +dist/desktop/codexmate/ +├── codexmate-desktop.json +├── cli.js +├── cli/ +├── lib/ +├── plugins/ +├── web-ui/ +├── web-ui.html +├── package.json +├── package-lock.json +├── node-runtime/ # bundled Node.js runtime used by release desktop startup +└── node_modules/ # package-lock 中非 dev 的运行时依赖 +``` + +脚本会验证入口文件、Web UI、manifest、`node_modules`、bundled Node runtime 和直接运行时依赖是否存在。这样可以提前暴露资源缺失,而不是等 `tauri build` 通过后才在用户机器上启动失败。 + +## 命令 + +```bash +npm run desktop:stage +npm run desktop:prepare # desktop:stage 的兼容别名 +npm run desktop:dev +npm run desktop:build +``` + +## 本地要求 + +桌面构建需要: + +- Node.js 18+ +- Rust / Cargo +- Tauri 对应平台依赖 + +release 桌面包会内置 Node.js runtime 来启动打包进 resources 的 Codex Mate 后端;用户机器不需要预装 Node.js。调试或排障时仍可用 `CODEXMATE_NODE=/path/to/node` 显式覆盖 runtime。 + +## 启动诊断日志 + +Windows release 包仍使用 GUI subsystem,普通双击不会弹出黑色控制台。需要快速定位启动闪退时,可以从 PowerShell / CMD 显式启用控制台日志: + +```powershell +codexmate-desktop.exe --debug-console +``` + +也可以通过环境变量启用: + +```powershell +$env:CODEXMATE_DESKTOP_LOG = "1" +codexmate-desktop.exe +``` + +启用后,桌面壳会尝试附着父控制台,打印 Rust/Tauri 启动日志,并让内置 Node backend 的 stdout/stderr 继承到当前终端。无论是否启用控制台,桌面壳都会写入本地文件日志;未启用控制台时,backend stdout/stderr 会写入 `startup.log`: + +```text +%LOCALAPPDATA%\CodexMate\logs\desktop.log +%LOCALAPPDATA%\CodexMate\logs\startup.log +``` + +如需指定 `desktop.log` 位置: + +```powershell +$env:CODEXMATE_DESKTOP_LOG_FILE = "$env:TEMP\codexmate-desktop.log" +codexmate-desktop.exe --debug-console +``` + +## 端口占用与管理员残留进程 + +桌面端启动时会先探测 `127.0.0.1:3737`:已有健康后端会直接复用;端口被占用但后端尚未就绪时会短暂等待它恢复。如果端口仍被其他进程占用,应用会弹出“Codex Mate 启动失败”对话框并给出处置指引,不会强制结束任何进程、也不要求管理员权限启动。 + +如果残留进程是以管理员身份启动的(普通权限的任务管理器会结束失败),请右键任务管理器或 PowerShell 并选择“以管理员身份运行”,在其中结束旧的 Codex Mate / node 进程;或者直接重启电脑。由于桌面端不再获取管理员权限,它无法代替用户清理这类残留进程。 + +## CI + +`.github/workflows/desktop-build.yml` 会在 GitHub Actions 上: + +- `npm ci` 安装依赖 +- `npm pack --dry-run --json` 验证主 npm CLI 包 payload +- `npm run desktop:stage` 验证桌面运行时 staging +- 在 macOS / Windows 上执行 `npm run desktop:build` + +构建产物会以 `codexmate-desktop-macOS` / `codexmate-desktop-Windows` artifact 上传。 diff --git a/package-lock.json b/package-lock.json index f700ab7d..be5167eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,13 @@ "codexmate": "cli.js" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.2", "@vue/compiler-dom": "^3.5.34", "opencc-js": "^1.3.1", "vitepress": "^1.6.4" }, "engines": { - "node": ">=14" + "node": ">=16.14.0" } }, "node_modules/@algolia/abtesting": { @@ -1243,6 +1244,223 @@ "dev": true, "license": "MIT" }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.3.tgz", + "integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.3", + "@tauri-apps/cli-darwin-x64": "2.11.3", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.3", + "@tauri-apps/cli-linux-arm64-musl": "2.11.3", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3", + "@tauri-apps/cli-linux-x64-gnu": "2.11.3", + "@tauri-apps/cli-linux-x64-musl": "2.11.3", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.3", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.3", + "@tauri-apps/cli-win32-x64-msvc": "2.11.3" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz", + "integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz", + "integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz", + "integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz", + "integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz", + "integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz", + "integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz", + "integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz", + "integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz", + "integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz", + "integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz", + "integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index dc4ae790..4e021c52 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,11 @@ "test:e2e": "node tests/e2e/run.js", "setup:git": "git remote set-url origin https://github.com/SakuraByteCore/codexmate.git && gh auth setup-git", "reset:dev": "node tools/dev/reset-and-dev.js", - "pretest": "node tools/ci/ensure-test-deps.js" + "pretest": "node tools/ci/ensure-test-deps.js", + "desktop:prepare": "node tools/desktop/prepare-tauri-resources.js", + "desktop:stage": "node tools/desktop/prepare-tauri-resources.js", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build" }, "dependencies": { "@iarna/toml": "^2.2.5", @@ -52,7 +56,7 @@ "zip-lib": "^1.2.1" }, "engines": { - "node": ">=14" + "node": ">=16.14.0" }, "keywords": [ "codex", @@ -72,6 +76,7 @@ "author": "ymkiux", "license": "Apache-2.0", "devDependencies": { + "@tauri-apps/cli": "^2.11.2", "@vue/compiler-dom": "^3.5.34", "opencc-js": "^1.3.1", "vitepress": "^1.6.4" diff --git a/site/.vitepress/public/images/logo-v.png b/site/.vitepress/public/images/logo-v.png new file mode 100644 index 00000000..df7dac25 Binary files /dev/null and b/site/.vitepress/public/images/logo-v.png differ diff --git a/site/.vitepress/public/images/logo.png b/site/.vitepress/public/images/logo.png index f55f2a06..543df20d 100644 Binary files a/site/.vitepress/public/images/logo.png and b/site/.vitepress/public/images/logo.png differ diff --git a/site/.vitepress/public/images/logo.svg b/site/.vitepress/public/images/logo.svg deleted file mode 100644 index de9f8fce..00000000 --- a/site/.vitepress/public/images/logo.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - CM - diff --git a/site/.vitepress/public/images/web-ui-screenshot.png b/site/.vitepress/public/images/web-ui-screenshot.png deleted file mode 100644 index 008e87a9..00000000 Binary files a/site/.vitepress/public/images/web-ui-screenshot.png and /dev/null differ diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 00000000..502406b4 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +/gen/schemas diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 00000000..a44f69a5 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codexmate-desktop" +version = "0.0.55" +description = "Codex Mate desktop shell" +authors = ["ymkiux"] +license = "Apache-2.0" +repository = "https://github.com/SakuraByteCore/codexmate" +edition = "2021" +rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.6.2" } + +[dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +log = "0.4" +tauri = { version = "2.11.2" } +tauri-plugin-log = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 00000000..261851f6 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 00000000..c135d7f1 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 00000000..a0f1a31d Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..bc9e1ba2 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 00000000..baeac996 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 00000000..21296e9f Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 00000000..9e19fb35 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 00000000..47a00808 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 00000000..23415a8b Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 00000000..6df70fc6 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 00000000..d4a04e0b Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 00000000..63fa7ea0 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 00000000..fcdf4944 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 00000000..6e98c5e3 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 00000000..07d87d7f Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 00000000..0b856f97 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..2ffbf24b --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..8fa307e7 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..6545a7b2 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..58f97e0b Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..837dd105 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..8c2a10f4 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..d87036b7 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..05596269 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..059f76ba Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..86b09ecf Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..8e9c8d97 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..53640c02 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..d6c5e504 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..ba8cd84a Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..44112298 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..9edb8cb6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 00000000..ea9c223a --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 00000000..f042c6f0 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 00000000..eee92d90 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 00000000..535442de Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 00000000..69f9c204 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 00000000..08e3eeea Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 00000000..08e3eeea Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 00000000..018de6cd Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 00000000..ae72f38b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 00000000..10d8a7e4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 00000000..10d8a7e4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 00000000..2ffc3401 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 00000000..08e3eeea Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 00000000..600e0b55 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 00000000..600e0b55 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 00000000..02cf990c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 00000000..7ecfb4a0 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 00000000..02cf990c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 00000000..1f3fd0cc Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 00000000..805e71d6 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 00000000..03369bb3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 00000000..8c002427 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 00000000..219177a9 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,552 @@ +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + path::PathBuf, + process::{Child, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +use tauri::{Manager, WindowEvent}; + +struct BackendState(Mutex>); + +static DESKTOP_CONSOLE_LOGGING: AtomicBool = AtomicBool::new(false); + +#[cfg(windows)] +mod windows_console { + #[link(name = "kernel32")] + extern "system" { + fn AttachConsole(dw_process_id: u32) -> i32; + } + + const ATTACH_PARENT_PROCESS: u32 = 0xFFFF_FFFF; + + pub fn attach_parent_console() -> bool { + // SAFETY: AttachConsole is a process-wide Windows API. Passing the documented + // ATTACH_PARENT_PROCESS constant only asks Windows to connect this GUI-subsystem + // process to the launching console, when one exists. + unsafe { AttachConsole(ATTACH_PARENT_PROCESS) != 0 } + } +} + +#[cfg(windows)] +mod windows_dialog { + use std::{ffi::c_void, os::windows::ffi::OsStrExt, ptr}; + + #[link(name = "user32")] + extern "system" { + fn MessageBoxW(hwnd: *mut c_void, text: *const u16, caption: *const u16, kind: u32) -> i32; + } + + const MB_OK: u32 = 0x00000000; + const MB_ICONERROR: u32 = 0x00000010; + const MB_TOPMOST: u32 = 0x00040000; + + fn wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + pub fn show_error(caption: &str, message: &str) { + let caption = wide(caption); + let message = wide(message); + // SAFETY: MessageBoxW is called with null owner and valid null-terminated + // UTF-16 buffers that outlive the call. + unsafe { + MessageBoxW( + ptr::null_mut(), + message.as_ptr(), + caption.as_ptr(), + MB_OK | MB_ICONERROR | MB_TOPMOST, + ); + } + } +} + +fn desktop_debug_requested() -> bool { + let env_enabled = std::env::var("CODEXMATE_DESKTOP_LOG") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" | "trace" | "debug" + ) + }) + .unwrap_or(false); + if env_enabled { + return true; + } + + std::env::args().skip(1).any(|arg| { + matches!( + arg.as_str(), + "--debug-console" | "--console-log" | "--log-to-console" | "--verbose" | "--trace" + ) + }) +} + +fn desktop_log_file_path() -> PathBuf { + if let Ok(value) = std::env::var("CODEXMATE_DESKTOP_LOG_FILE") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + + desktop_default_logs_dir().join("desktop.log") +} + +fn desktop_default_logs_dir() -> PathBuf { + let base_dir = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir()); + base_dir.join("CodexMate").join("logs") +} + +fn backend_startup_log_file_path() -> PathBuf { + desktop_default_logs_dir().join("startup.log") +} + +fn now_epoch_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis()) + .unwrap_or(0) +} + +fn write_console_log(line: &str) { + #[cfg(windows)] + if let Ok(mut console) = OpenOptions::new().write(true).open("CONOUT$") { + let _ = console.write_all(line.as_bytes()); + return; + } + + let _ = std::io::stderr().write_all(line.as_bytes()); +} + +fn desktop_log(message: impl AsRef) { + let line = format!("[{}] {}\n", now_epoch_millis(), message.as_ref()); + if DESKTOP_CONSOLE_LOGGING.load(Ordering::Relaxed) { + write_console_log(&line); + } + + append_log_line(desktop_log_file_path(), &line); + append_log_line(backend_startup_log_file_path(), &line); +} + +fn show_startup_error(message: &str) { + desktop_log(format!("startup error shown to user: {message}")); + #[cfg(windows)] + windows_dialog::show_error("Codex Mate 启动失败", message); +} + +fn startup_error(message: impl Into) -> Result> { + let message = message.into(); + show_startup_error(&message); + Err(message.into()) +} + +fn append_log_line(log_path: PathBuf, line: &str) { + if let Some(parent) = log_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) { + let _ = file.write_all(line.as_bytes()); + } +} + +fn backend_startup_log_stdio() -> Stdio { + let log_path = backend_startup_log_file_path(); + if let Some(parent) = log_path.parent() { + let _ = fs::create_dir_all(parent); + } + OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()) +} + +fn backend_startup_log_excerpt() -> String { + let log_path = backend_startup_log_file_path(); + let Ok(bytes) = fs::read(&log_path) else { + return format!("startup.log not readable at {}", log_path.display()); + }; + if bytes.is_empty() { + return format!("startup.log is empty at {}", log_path.display()); + } + + let keep_from = bytes.len().saturating_sub(4096); + let text = String::from_utf8_lossy(&bytes[keep_from..]); + let excerpt = text + .lines() + .rev() + .take(30) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + if excerpt.trim().is_empty() { + format!("startup.log has no readable text at {}", log_path.display()) + } else { + format!("startup.log tail ({}):\n{}", log_path.display(), excerpt) + } +} + +fn configure_desktop_console_logging() -> bool { + if !desktop_debug_requested() { + DESKTOP_CONSOLE_LOGGING.store(false, Ordering::Relaxed); + return false; + } + + #[cfg(windows)] + let attached = windows_console::attach_parent_console(); + #[cfg(not(windows))] + let attached = true; + + DESKTOP_CONSOLE_LOGGING.store(attached, Ordering::Relaxed); + attached +} + +pub fn init_desktop_diagnostics() { + let console_attached = configure_desktop_console_logging(); + let log_path = desktop_log_file_path(); + std::panic::set_hook(Box::new(move |panic_info| { + desktop_log(format!("panic: {panic_info}")); + })); + + desktop_log(format!( + "codexmate desktop starting; console_logging={}; log_file={}; startup_log_file={}", + console_attached, + log_path.display(), + backend_startup_log_file_path().display() + )); + desktop_log(format!( + "args={}", + std::env::args().collect::>().join(" ") + )); +} + +fn health_check_ready() -> bool { + let addr: SocketAddr = match "127.0.0.1:3737".parse() { + Ok(value) => value, + Err(_) => return false, + }; + let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_millis(1000)) { + Ok(value) => value, + Err(_) => return false, + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(1500))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(1000))); + + let body = r#"{"action":"health-check","params":{}}"#; + let request = format!( + "POST /api HTTP/1.1\r\nHost: 127.0.0.1:3737\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.as_bytes().len(), + body + ); + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + + let mut response = String::new(); + if stream.read_to_string(&mut response).is_err() { + return false; + } + let status_ok = response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200"); + let identity_ok = response.contains("\"ok\":true"); + status_ok && identity_ok +} + +fn backend_port_occupied() -> bool { + let addr: SocketAddr = match "127.0.0.1:3737".parse() { + Ok(value) => value, + Err(_) => return false, + }; + TcpStream::connect_timeout(&addr, Duration::from_millis(1000)).is_ok() +} + +fn backend_port_occupied_message() -> String { + "端口 3737 已被其他进程占用,Codex Mate 无法启动后端。请先关闭旧的 Codex Mate / codexmate run 实例后重试;如果旧进程是以管理员身份启动的(普通方式无法结束时),请以管理员身份打开任务管理器或 PowerShell 结束它,或重启电脑;如果问题持续,请查看 startup.log。".to_string() +} + +fn wait_for_backend(timeout: Duration) -> bool { + let started = Instant::now(); + while started.elapsed() < timeout { + if health_check_ready() { + desktop_log("backend health check passed"); + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + desktop_log("backend health check timed out"); + false +} + +fn wait_for_spawned_backend(child: &mut Child, timeout: Duration) -> Result<(), String> { + let started = Instant::now(); + while started.elapsed() < timeout { + if health_check_ready() { + desktop_log("spawned backend health check passed"); + return Ok(()); + } + + match child.try_wait() { + Ok(Some(status)) => { + let message = format!( + "Codex Mate 后端进程已退出,未能完成启动。退出状态:{status}。请查看 startup.log。详情:codexmate backend exited before becoming ready on 127.0.0.1:3737\n\n{}", + backend_startup_log_excerpt() + ); + desktop_log(format!("backend exited before readiness; status={status}")); + return Err(message); + } + Ok(None) => {} + Err(err) => { + desktop_log(format!( + "backend readiness wait could not inspect child status: {err}" + )); + } + } + + std::thread::sleep(Duration::from_millis(200)); + } + + let message = format!( + "Codex Mate 后端启动后没有及时就绪。请关闭旧的 Codex Mate / codexmate run 实例后重试;如果问题持续,请查看 startup.log。详情:codexmate backend did not become ready on 127.0.0.1:3737\n\n{}", + backend_startup_log_excerpt() + ); + desktop_log("spawned backend health check timed out"); + Err(message) +} + +#[cfg(windows)] +fn configure_backend_process(command: &mut Command) { + if DESKTOP_CONSOLE_LOGGING.load(Ordering::Relaxed) { + return; + } + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn configure_backend_process(_command: &mut Command) {} + +fn find_cli_path(app: &tauri::App) -> Result> { + let mut candidates = Vec::new(); + + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join("codexmate").join("cli.js")); + candidates.push(resource_dir.join("cli.js")); + } + + #[cfg(debug_assertions)] + if let Ok(current_dir) = std::env::current_dir() { + candidates.push(current_dir.join("cli.js")); + } + + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| "unable to locate bundled codexmate cli.js".into()) +} + +fn strip_path_prefix(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + let raw = path.as_os_str().to_string_lossy(); + if let Some(rest) = raw.strip_prefix(r"\\?\") { + let mut chars = rest.chars(); + let is_drive_path = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) + && matches!(chars.next(), Some(':')); + if is_drive_path { + return PathBuf::from(rest.to_string()); + } + } + } + path +} + +fn bundled_node_executable_name() -> &'static str { + if cfg!(windows) { + "node.exe" + } else { + "node" + } +} + +fn find_node_runtime_path(app: &tauri::App) -> Result> { + if let Ok(value) = std::env::var("CODEXMATE_NODE") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + if let Ok(resource_dir) = app.path().resource_dir() { + let candidates = [ + resource_dir + .join("codexmate") + .join("node-runtime") + .join(bundled_node_executable_name()), + resource_dir + .join("node-runtime") + .join(bundled_node_executable_name()), + ]; + + if let Some(candidate) = candidates.into_iter().find(|candidate| candidate.is_file()) { + return Ok(candidate); + } + } + + #[cfg(debug_assertions)] + { + Ok(PathBuf::from("node")) + } + + #[cfg(not(debug_assertions))] + { + startup_error("Codex Mate 打包产物缺少内置 Node.js runtime,无法启动后端。请重新下载安装包;如果问题持续,请查看 startup.log。详情:bundled node-runtime/node is missing") + } +} + +fn spawn_backend(app: &tauri::App) -> Result, Box> { + if std::env::var("CODEXMATE_DESKTOP_SKIP_BACKEND") + .ok() + .as_deref() + == Some("1") + { + desktop_log("backend spawn skipped by CODEXMATE_DESKTOP_SKIP_BACKEND=1"); + return Ok(None); + } + + if health_check_ready() { + desktop_log("existing backend already ready; reusing 127.0.0.1:3737 listener"); + return Ok(None); + } + + if backend_port_occupied() { + desktop_log("backend port is occupied but not ready yet; waiting before surfacing occupied-port guidance"); + if wait_for_backend(Duration::from_secs(5)) { + desktop_log( + "existing backend became ready while waiting; reusing 127.0.0.1:3737 listener", + ); + return Ok(None); + } + let message = backend_port_occupied_message(); + desktop_log(format!( + "backend port remains occupied after grace wait; {message}" + )); + return startup_error(message); + } + + let cli_path = strip_path_prefix(find_cli_path(app)?); + let cli_dir = cli_path + .parent() + .ok_or_else(|| "unable to resolve codexmate cli directory")?; + let node_bin = strip_path_prefix(find_node_runtime_path(app)?); + let inherit_backend_stdio = DESKTOP_CONSOLE_LOGGING.load(Ordering::Relaxed); + + desktop_log(format!( + "spawning backend; node={}; cli={}; cwd={}; inherit_stdio={}", + node_bin.display(), + cli_path.display(), + cli_dir.display(), + inherit_backend_stdio + )); + + let mut command = Command::new(&node_bin); + command + .arg(&cli_path) + .arg("run") + .arg("--host") + .arg("127.0.0.1") + .arg("--no-browser") + .current_dir(cli_dir) + .env("CODEXMATE_NO_BROWSER", "1") + .env("CODEXMATE_HOST", "127.0.0.1") + .env("CODEXMATE_PORT", "3737") + .stdin(Stdio::null()); + + if inherit_backend_stdio { + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } else { + command + .stdout(backend_startup_log_stdio()) + .stderr(backend_startup_log_stdio()); + } + + configure_backend_process(&mut command); + + let mut child = command.spawn().map_err(|err| { + desktop_log(format!("backend spawn failed: {err}")); + format!("unable to start codexmate backend with Node.js: {err}") + })?; + + desktop_log(format!("backend process spawned; pid={}", child.id())); + + if let Err(message) = wait_for_spawned_backend(&mut child, Duration::from_secs(60)) { + let _ = child.kill(); + let _ = child.wait(); + desktop_log("backend killed after spawned readiness failure"); + return startup_error(message); + } + + Ok(Some(child)) +} + +fn stop_backend(window: &tauri::Window) { + let state = window.state::(); + let child = { + let mut guard = match state.0.lock() { + Ok(value) => value, + Err(_) => return, + }; + guard.take() + }; + + if let Some(mut child) = child { + desktop_log(format!("stopping backend process; pid={}", child.id())); + let _ = child.kill(); + let _ = child.wait(); + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + desktop_log("building tauri application"); + tauri::Builder::default() + .setup(|app| { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + if cfg!(debug_assertions) { + desktop_log("debug build: backend managed by beforeDevCommand"); + app.manage(BackendState(Mutex::new(None))); + } else { + let child = spawn_backend(app)?; + app.manage(BackendState(Mutex::new(child))); + } + Ok(()) + }) + .on_window_event(|window, event| { + if window.label() == "main" && matches!(event, WindowEvent::Destroyed) { + desktop_log("main window destroyed"); + stop_backend(window); + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 00000000..89a5f549 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,7 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::init_desktop_diagnostics(); + app_lib::run(); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 00000000..fb39ac1b --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,56 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Codex Mate", + "version": "0.0.55", + "identifier": "ai.codexmate.desktop", + "build": { + "frontendDist": "../web-ui", + "devUrl": "http://127.0.0.1:3737", + "beforeDevCommand": "npm run desktop:stage && node cli.js run --host 127.0.0.1 --no-browser", + "beforeBuildCommand": "npm run desktop:stage" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Codex Mate", + "width": 1280, + "height": 860, + "minWidth": 960, + "minHeight": 640, + "resizable": true, + "fullscreen": false, + "url": "http://127.0.0.1:3737" + } + ], + "security": { + "csp": "default-src 'self' http://127.0.0.1:3737; connect-src 'self' http://127.0.0.1:3737; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "android": { + "debugApplicationIdSuffix": ".debug" + }, + "resources": { + "../dist/desktop/codexmate": "codexmate" + }, + "windows": { + "allowDowngrades": true, + "wix": { + "upgradeCode": "e84da745-7b0b-5548-85ed-a4a0be7b55ae" + }, + "nsis": { + "installMode": "currentUser" + } + } + } +} diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 0605588e..b6e94cc8 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -25,7 +25,6 @@ const testHealthSpeed = require('./test-health-speed'); const testMessages = require('./test-messages'); const testMcp = require('./test-mcp'); const testWorkflow = require('./test-workflow'); -const testTaskOrchestration = require('./test-task-orchestration'); const testInvalidConfig = require('./test-invalid-config'); const testWebUiAssets = require('./test-web-ui-assets'); const testWebUiSessionBrowser = require('./test-web-ui-session-browser'); @@ -172,7 +171,6 @@ fs.writeFileSync(path.join(process.env.HOME, 'kilocode-launch.json'), JSON.strin await testMessages(ctx); await testMcp(ctx); await testWorkflow(ctx); - await testTaskOrchestration(ctx); await testInstallStatus(ctx); await testWebhook(ctx); await testWebUiAssets(ctx); diff --git a/tests/e2e/test-task-orchestration.js b/tests/e2e/test-task-orchestration.js deleted file mode 100644 index bec7ad8e..00000000 --- a/tests/e2e/test-task-orchestration.js +++ /dev/null @@ -1,725 +0,0 @@ -const { spawn } = require('child_process'); -const { assert, runSync, fs, path } = require('./helpers'); - - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function startOpenAiChatMock(tmpHome) { - const scriptPath = path.join(tmpHome, 'task-openai-chat-mock.cjs'); - const portFile = path.join(tmpHome, 'task-openai-chat-mock.port'); - const requestsFile = path.join(tmpHome, 'task-openai-chat-requests.jsonl'); - fs.writeFileSync(scriptPath, ` -const http = require('http'); -const fs = require('fs'); -const portFile = process.argv[2]; -const requestsFile = process.argv[3]; -let requestCount = 0; -const server = http.createServer((req, res) => { - const requestPath = String(req.url || '').split('?')[0]; - let rawBody = ''; - req.setEncoding('utf-8'); - req.on('data', chunk => { rawBody += chunk; }); - req.on('end', () => { - let parsedBody = null; - try { parsedBody = rawBody ? JSON.parse(rawBody) : null; } catch (_) {} - requestCount += 1; - fs.appendFileSync(requestsFile, JSON.stringify({ - n: requestCount, - method: req.method, - path: requestPath, - authorization: req.headers.authorization || '', - body: parsedBody - }) + '\\n'); - if (req.method === 'GET' && requestPath === '/v1/models') { - const body = JSON.stringify({ object: 'list', data: [ - { id: 'glm-5.2', object: 'model' }, - { id: 'glm-5.2-flash', object: 'model' } - ] }); - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body, 'utf-8') }); - res.end(body, 'utf-8'); - return; - } - if (req.method === 'POST' && requestPath === '/v1/chat/completions') { - const model = parsedBody && parsedBody.model ? parsedBody.model : 'unknown-model'; - const requestText = JSON.stringify(parsedBody || {}); - const content = requestText.includes('target-symlink-artifact-probe') - ? '输出文件:index.html\\n\`\`\`html\\n

target symlink escape

\\n\`\`\`' - : requestText.includes('symlink-artifact-probe') - ? '输出文件:link/index.html\\n\`\`\`html\\n

symlink escape

\\n\`\`\`' - : requestText.includes('index.html') - ? '输出文件:index.html\\n\`\`\`html\\n2048 Probe

2048

2 4 8 16
\\n\`\`\`' - : 'openai-chat-e2e-ok model=' + model + ' request=' + requestCount; - const body = JSON.stringify({ - id: 'chatcmpl-task-e2e-' + requestCount, - object: 'chat.completion', - choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], - usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 } - }); - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body, 'utf-8') }); - res.end(body, 'utf-8'); - return; - } - const body = JSON.stringify({ error: { message: 'not found ' + req.method + ' ' + requestPath } }); - res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body, 'utf-8') }); - res.end(body, 'utf-8'); - }); -}); -server.listen(0, '127.0.0.1', () => { - fs.writeFileSync(portFile, String(server.address().port), 'utf-8'); -}); -process.on('SIGTERM', () => server.close(() => process.exit(0))); -process.on('SIGINT', () => server.close(() => process.exit(0))); -`, 'utf-8'); - - const child = spawn(process.execPath, [scriptPath, portFile, requestsFile], { - stdio: ['ignore', 'ignore', 'pipe'] - }); - let stderr = ''; - child.stderr.on('data', chunk => { stderr += chunk.toString(); }); - for (let i = 0; i < 80; i += 1) { - if (fs.existsSync(portFile)) { - const port = Number(fs.readFileSync(portFile, 'utf-8').trim()); - if (Number.isFinite(port) && port > 0) { - return { - port, - process: child, - readRequests() { - if (!fs.existsSync(requestsFile)) return []; - return fs.readFileSync(requestsFile, 'utf-8') - .split(/\r?\n/g) - .filter(Boolean) - .map(line => JSON.parse(line)); - }, - close() { - return new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode) return resolve(); - const timer = setTimeout(() => { - try { child.kill('SIGKILL'); } catch (_) {} - resolve(); - }, 2000); - child.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - try { child.kill('SIGTERM'); } catch (_) { resolve(); } - }); - } - }; - } - } - if (child.exitCode !== null) { - throw new Error(`OpenAI Chat mock exited early: ${stderr}`); - } - await sleep(100); - } - try { child.kill('SIGKILL'); } catch (_) {} - throw new Error(`OpenAI Chat mock did not start: ${stderr}`); -} - -function writeOpenAiChatConfig(tmpHome, baseUrl) { - const configDir = path.join(tmpHome, '.codex'); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, 'codexmate-init.json'), JSON.stringify({ version: 1, mode: 'task-openai-chat-e2e' }), 'utf-8'); - fs.writeFileSync(path.join(configDir, 'config.toml'), [ - 'model = "gpt-5.3-codex"', - 'model_provider = "local"', - 'task_openai_chat_provider = "new-api-chat"', - '', - '[model_providers.local]', - 'name = "Local Codex"', - 'base_url = "http://127.0.0.1:9/v1"', - 'wire_api = "responses"', - 'preferred_auth_method = "sk-codex-tab-secret"', - 'models = ["gpt-5.3-codex"]', - '', - '[model_providers.new-api-chat]', - 'name = "New API Chat"', - `base_url = "${baseUrl}/v1"`, - 'wire_api = "chat_completions"', - 'preferred_auth_method = "sk-task-e2e-secret"', - 'temperature = 0.7', - 'models = ["glm-5.2", "glm-5.2-flash"]', - '' - ].join('\n'), 'utf-8'); -} - -function assertOpenAiRunPayload(payload, label) { - assert(payload && payload.run && payload.run.status === 'success', `${label} should succeed`); - const nodes = Array.isArray(payload.run.nodes) ? payload.run.nodes : []; - assert(nodes.length > 0, `${label} should include nodes`); - assert(nodes.every(node => node.kind === 'openai-chat'), `${label} should use OpenAI Chat nodes`); - assert(nodes.every(node => node.status === 'success'), `${label} nodes should all succeed`); - assert(nodes.some(node => node.output && node.output.provider === 'new-api-chat'), `${label} should record provider`); - assert(JSON.stringify(payload).indexOf('sk-task-e2e-secret') === -1, `${label} must not leak api key`); -} - -function assertOpenAiRequests(mock, minCount, label) { - const chatRequests = mock.readRequests().filter(item => item.path === '/v1/chat/completions'); - assert(chatRequests.length >= minCount, `${label} should call /v1/chat/completions at least ${minCount} times, got ${chatRequests.length}`); - assert(!JSON.stringify(chatRequests).includes('sk-codex-tab-secret'), `${label} must not use the Codex tab provider key`); - for (const item of chatRequests) { - assert(item.method === 'POST', `${label} chat request should be POST`); - assert(item.authorization === 'Bearer sk-task-e2e-secret', `${label} should pass bearer auth`); - assert(item.body && item.body.model === 'glm-5.2', `${label} should pass selected model`); - assert(item.body.temperature === 0.7, `${label} should pass configured temperature`); - assert(Array.isArray(item.body.messages) && item.body.messages.length >= 2, `${label} should send chat messages`); - assert(item.body.messages.some(message => message.role === 'system'), `${label} should include system prompt`); - assert(item.body.messages.some(message => message.role === 'user'), `${label} should include user prompt`); - } -} - -function parseJsonOutput(rawText) { - const text = String(rawText || '').trim(); - if (!text) { - return {}; - } - try { - return JSON.parse(text); - } catch (_) { - const start = text.indexOf('{'); - const end = text.lastIndexOf('}'); - if (start >= 0 && end > start) { - return JSON.parse(text.slice(start, end + 1)); - } - throw new Error(`invalid json output: ${text.slice(0, 200)}`); - } -} - -module.exports = async function testTaskOrchestration(ctx) { - const { api, node, cliPath, env, tmpHome } = ctx; - - const planResult = runSync(node, [ - cliPath, - 'task', - 'plan', - '--target', - '检查当前配置并输出摘要', - '--follow-up', - '整理结论', - '--json' - ], { env }); - assert(planResult.status === 0, `task plan failed: ${planResult.stderr || planResult.stdout}`); - const planPayload = parseJsonOutput(planResult.stdout); - assert(planPayload.ok === true, 'task plan should validate'); - assert(planPayload.plan && Array.isArray(planPayload.plan.nodes), 'task plan should include nodes'); - assert(planPayload.plan.nodes.length >= 2, 'task plan should include multiple nodes'); - assert(planPayload.plan.engine === 'openai-chat', 'default task plan should use OpenAI Chat engine'); - assert(planPayload.plan.nodes.every((node) => node.kind === 'openai-chat'), 'default task plan nodes should be OpenAI Chat nodes'); - - const invalidWorkflowPlanResult = runSync(node, [ - cliPath, - 'task', - 'plan', - '--target', - 'plain target', - '--workflow-id', - 'missing-workflow', - '--engine', - 'workflow', - '--json' - ], { env }); - assert(invalidWorkflowPlanResult.status !== 0, 'task plan should fail for unknown workflow ids'); - const invalidWorkflowPlanPayload = parseJsonOutput(invalidWorkflowPlanResult.stdout); - assert(invalidWorkflowPlanPayload.ok === false, 'invalid workflow plan should be rejected'); - assert(Array.isArray(invalidWorkflowPlanPayload.issues), 'invalid workflow plan should include issues'); - assert(invalidWorkflowPlanPayload.issues.some((item) => String(item.message || '').includes('unknown workflow')), 'invalid workflow plan should mention unknown workflow'); - - const runResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--target', - '诊断当前配置', - '--workflow-id', - 'diagnose-config', - '--engine', - 'workflow', - '--json' - ], { env }); - assert(runResult.status === 0, `task run failed: ${runResult.stderr || runResult.stdout}`); - const runPayload = parseJsonOutput(runResult.stdout); - assert(runPayload.run && runPayload.run.status === 'success', 'task run should succeed with diagnose-config workflow'); - assert(typeof runPayload.runId === 'string' && runPayload.runId, 'task run should return runId'); - assert(typeof runPayload.taskId === 'string' && runPayload.taskId, 'task run should return taskId'); - - const runsResult = runSync(node, [cliPath, 'task', 'runs', '--limit', '10', '--json'], { env }); - assert(runsResult.status === 0, `task runs failed: ${runsResult.stderr || runsResult.stdout}`); - const runsPayload = parseJsonOutput(runsResult.stdout); - assert(Array.isArray(runsPayload.runs), 'task runs should return runs array'); - assert(runsPayload.runs.some((item) => item.runId === runPayload.runId), 'task runs should include latest run'); - - const queueAddResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'add', - '--target', - '再次诊断当前配置', - '--workflow-id', - 'diagnose-config', - '--engine', - 'workflow', - '--json' - ], { env }); - assert(queueAddResult.status === 0, `task queue add failed: ${queueAddResult.stderr || queueAddResult.stdout}`); - const queueAddPayload = parseJsonOutput(queueAddResult.stdout); - assert(queueAddPayload.ok === true, 'task queue add should succeed'); - assert(queueAddPayload.task && queueAddPayload.task.taskId, 'queue add should return task'); - - const queueShowResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'show', - queueAddPayload.task.taskId - ], { env }); - assert(queueShowResult.status === 0, `task queue show failed: ${queueShowResult.stderr || queueShowResult.stdout}`); - const queueShowPayload = parseJsonOutput(queueShowResult.stdout); - assert(queueShowPayload.taskId === queueAddPayload.task.taskId, 'task queue show should resolve task'); - - const queueStartResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'start', - queueAddPayload.task.taskId, - '--json' - ], { env }); - assert(queueStartResult.status === 0, `task queue start failed: ${queueStartResult.stderr || queueStartResult.stdout}`); - const queueStartPayload = parseJsonOutput(queueStartResult.stdout); - assert(queueStartPayload.ok === true, 'task queue start should succeed'); - assert(queueStartPayload.detail && queueStartPayload.detail.run && queueStartPayload.detail.run.status === 'success', 'queued task should complete successfully'); - - const logsResult = runSync(node, [ - cliPath, - 'task', - 'logs', - queueStartPayload.detail.runId, - '--json' - ], { env }); - assert(logsResult.status === 0, `task logs failed: ${logsResult.stderr || logsResult.stdout}`); - const logsPayload = parseJsonOutput(logsResult.stdout); - assert(typeof logsPayload.logs === 'string', 'task logs should return log text'); - assert(logsPayload.logs.includes('# workflow-01') || logsPayload.logs.includes('# diagnose-config') || logsPayload.logs.includes('# workflow'), 'task logs should include node heading'); - - const queueListResult = runSync(node, [cliPath, 'task', 'queue', 'list', '--json'], { env }); - assert(queueListResult.status === 0, `task queue list failed: ${queueListResult.stderr || queueListResult.stdout}`); - const queueListPayload = parseJsonOutput(queueListResult.stdout); - assert(Array.isArray(queueListPayload.tasks), 'task queue list should return tasks array'); - assert(queueListPayload.tasks.some((item) => item.taskId === queueAddPayload.task.taskId), 'task queue list should include queued task record'); - - const taskRunsFile = path.join(tmpHome, '.codex', 'codexmate-task-runs.jsonl'); - const taskQueueFile = path.join(tmpHome, '.codex', 'codexmate-task-queue.json'); - assert(fs.existsSync(taskRunsFile), 'task runs file should be created'); - assert(fs.existsSync(taskQueueFile), 'task queue file should be created'); - - const apiOverview = await api('task-overview'); - assert(Array.isArray(apiOverview.queue), 'task-overview API should return queue'); - assert(Array.isArray(apiOverview.runs), 'task-overview API should return runs'); - - const apiPlan = await api('task-plan', { - target: '检查配置后输出摘要', - followUps: ['整理结果'] - }); - assert(apiPlan.ok === true, 'task-plan API should validate'); - assert(apiPlan.plan && Array.isArray(apiPlan.plan.waves), 'task-plan API should return waves'); - - const apiQueueAdd = await api('task-queue-add', { - target: '排队执行配置诊断', - workflowIds: ['diagnose-config'], - engine: 'workflow' - }); - assert(apiQueueAdd.ok === true, 'task-queue-add API should succeed'); - - const apiQueueStart = await api('task-queue-start', { - taskId: apiQueueAdd.task.taskId, - detach: false - }); - assert(apiQueueStart.ok === true, 'task-queue-start API should succeed'); - assert(apiQueueStart.detail && apiQueueStart.detail.run && apiQueueStart.detail.run.status === 'success', 'API queue start should execute task'); - - const apiRunDetail = await api('task-run-detail', { runId: apiQueueStart.detail.runId }); - assert(apiRunDetail && apiRunDetail.runId === apiQueueStart.detail.runId, 'task-run-detail API should return detail'); - - const openAiMock = await startOpenAiChatMock(tmpHome); - try { - writeOpenAiChatConfig(tmpHome, `http://codex-user:codex-secret@127.0.0.1:${openAiMock.port}`); - - const openAiPlanResult = runSync(node, [ - cliPath, - 'task', - 'plan', - '--target', - 'OpenAI Chat provider 端到端模拟', - '--cwd', - path.join(tmpHome, 'task-plan-workspace'), - '--thread-id', - 'thread-cli-plan', - '--follow-up', - '输出风险说明', - '--engine', - 'openai-chat', - '--json' - ], { env }); - assert(openAiPlanResult.status === 0, `OpenAI Chat task plan failed: ${openAiPlanResult.stderr || openAiPlanResult.stdout}`); - const openAiPlanPayload = parseJsonOutput(openAiPlanResult.stdout); - assert(openAiPlanPayload.ok === true, 'OpenAI Chat task plan should validate'); - assert(openAiPlanPayload.plan && openAiPlanPayload.plan.engine === 'openai-chat', 'OpenAI Chat plan should keep engine'); - assert(openAiPlanPayload.plan.threadId === 'thread-cli-plan', 'OpenAI Chat plan should preserve CLI thread id'); - assert(openAiPlanPayload.plan.cwd === path.join(tmpHome, 'task-plan-workspace'), 'OpenAI Chat plan should preserve CLI cwd'); - assert(openAiPlanPayload.plan.nodes.every((node) => node.kind === 'openai-chat'), 'OpenAI Chat plan should produce OpenAI Chat nodes'); - - const openAiRunCwd = path.join(tmpHome, 'task-run-workspace'); - fs.mkdirSync(openAiRunCwd, { recursive: true }); - const openAiRunResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--target', - 'OpenAI Chat provider CLI 运行链路', - '--follow-up', - '输出验证摘要', - '--engine', - 'openai-chat', - '--cwd', - openAiRunCwd, - '--thread-id', - 'thread-cli-run', - '--concurrency', - '2', - '--json' - ], { env }); - assert(openAiRunResult.status === 0, `OpenAI Chat task run failed: ${openAiRunResult.stderr || openAiRunResult.stdout}`); - const openAiRunPayload = parseJsonOutput(openAiRunResult.stdout); - assertOpenAiRunPayload(openAiRunPayload, 'OpenAI Chat CLI run'); - assert(openAiRunPayload.threadId === 'thread-cli-run', 'OpenAI Chat CLI run should preserve thread id'); - assert(openAiRunPayload.cwd === openAiRunCwd, 'OpenAI Chat CLI run should preserve cwd'); - - const openAiLogsResult = runSync(node, [ - cliPath, - 'task', - 'logs', - openAiRunPayload.runId, - '--json' - ], { env }); - assert(openAiLogsResult.status === 0, `OpenAI Chat task logs failed: ${openAiLogsResult.stderr || openAiLogsResult.stdout}`); - const openAiLogsPayload = parseJsonOutput(openAiLogsResult.stdout); - assert(String(openAiLogsPayload.logs || '').includes('OpenAI Chat request provider=new-api-chat'), 'OpenAI Chat logs should include provider request'); - - const rawPlanDefaultCwdPath = path.join(tmpHome, 'task-raw-plan-default-cwd.json'); - const rawPlanDefaultCwd = path.join(tmpHome, 'task-raw-plan-default-cwd-workspace'); - fs.mkdirSync(rawPlanDefaultCwd, { recursive: true }); - fs.writeFileSync(rawPlanDefaultCwdPath, JSON.stringify({ - id: 'task-raw-plan-default-cwd', - title: 'Raw plan default cwd', - target: 'Keep the implicit cwd stable', - engine: 'openai-chat', - nodes: [ - { - id: 'raw-default-cwd-node', - title: 'Raw default cwd node', - kind: 'openai-chat', - prompt: 'No artifact write needed.', - dependsOn: [] - } - ] - }, null, 2), 'utf-8'); - const rawPlanDefaultCwdResult = runSync(node, [ - cliPath, - 'task', - 'plan', - '--plan', - `@${rawPlanDefaultCwdPath}`, - '--json' - ], { env, cwd: rawPlanDefaultCwd }); - assert(rawPlanDefaultCwdResult.status === 0, `OpenAI Chat raw plan default cwd failed: ${rawPlanDefaultCwdResult.stderr || rawPlanDefaultCwdResult.stdout}`); - const rawPlanDefaultCwdPayload = parseJsonOutput(rawPlanDefaultCwdResult.stdout); - assert(rawPlanDefaultCwdPayload.plan && rawPlanDefaultCwdPayload.plan.cwd === rawPlanDefaultCwd, 'OpenAI Chat raw plans should default cwd to the invoking process cwd'); - - const directPlanPath = path.join(tmpHome, 'task-direct-openai-plan.json'); - const directPlanCwd = path.join(tmpHome, 'task-direct-plan-workspace'); - fs.mkdirSync(directPlanCwd, { recursive: true }); - fs.writeFileSync(directPlanPath, JSON.stringify({ - id: 'task-direct-openai-plan', - title: 'Direct OpenAI Chat plan', - target: 'Create index.html for direct OpenAI Chat plan execution', - notes: 'Write index.html only inside the provided cwd.', - cwd: directPlanCwd, - threadId: 'thread-direct-plan', - engine: 'openai-chat', - allowWrite: true, - dryRun: false, - concurrency: 1, - nodes: [ - { - id: 'direct-openai-node', - title: 'Direct OpenAI node', - kind: 'openai-chat', - prompt: 'Create index.html with a tiny 2048 probe page and return the full file in an html fenced block.', - dependsOn: [] - } - ] - }, null, 2), 'utf-8'); - const directPlanRunResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--plan', - `@${directPlanPath}`, - '--allow-write', - '--json' - ], { env }); - assert(directPlanRunResult.status === 0, `OpenAI Chat direct plan run failed: ${directPlanRunResult.stderr || directPlanRunResult.stdout}`); - const directPlanRunPayload = parseJsonOutput(directPlanRunResult.stdout); - assertOpenAiRunPayload(directPlanRunPayload, 'OpenAI Chat direct plan run'); - assert(Array.isArray(directPlanRunPayload.plan && directPlanRunPayload.plan.waves), 'OpenAI Chat direct plan run should compute waves'); - assert(directPlanRunPayload.threadId === 'thread-direct-plan', 'OpenAI Chat direct plan run should preserve plan thread id'); - assert(directPlanRunPayload.cwd === directPlanCwd, 'OpenAI Chat direct plan run should preserve plan cwd'); - const directPlanIndexPath = path.join(directPlanCwd, 'index.html'); - assert(fs.existsSync(directPlanIndexPath), 'OpenAI Chat direct plan run should materialize index.html when allow-write is enabled'); - assert(fs.readFileSync(directPlanIndexPath, 'utf-8').includes('2048'), 'materialized index.html should contain generated page content'); - const materializedFiles = directPlanRunPayload.run.nodes.flatMap(item => item && item.output && Array.isArray(item.output.materializedFiles) ? item.output.materializedFiles : []); - assert(materializedFiles.some(item => item.relativePath === 'index.html'), 'OpenAI Chat direct plan run should report materialized index.html'); - const directNodeOutputs = JSON.stringify(directPlanRunPayload.run.nodes.map(item => item && item.output ? item.output : {})); - assert(!directNodeOutputs.includes('codex-secret'), 'OpenAI Chat node output should redact endpoint URL userinfo secrets'); - assert(directNodeOutputs.includes('***'), 'OpenAI Chat node output should keep a redacted endpoint marker'); - - const symlinkPlanPath = path.join(tmpHome, 'task-symlink-openai-plan.json'); - const symlinkPlanCwd = path.join(tmpHome, 'task-symlink-plan-workspace'); - const symlinkEscapeDir = path.join(tmpHome, 'task-symlink-escape-target'); - fs.mkdirSync(symlinkPlanCwd, { recursive: true }); - fs.mkdirSync(symlinkEscapeDir, { recursive: true }); - fs.symlinkSync(symlinkEscapeDir, path.join(symlinkPlanCwd, 'link'), process.platform === 'win32' ? 'junction' : 'dir'); - fs.writeFileSync(symlinkPlanPath, JSON.stringify({ - id: 'task-symlink-openai-plan', - title: 'Symlink OpenAI Chat plan', - target: 'symlink-artifact-probe', - notes: 'The model will try to write link/index.html; this must be rejected because link is a symlink.', - cwd: symlinkPlanCwd, - threadId: 'thread-symlink-plan', - engine: 'openai-chat', - allowWrite: true, - dryRun: false, - concurrency: 1, - nodes: [ - { - id: 'symlink-openai-node', - title: 'Symlink OpenAI node', - kind: 'openai-chat', - prompt: 'symlink-artifact-probe: return link/index.html in an html fenced block.', - dependsOn: [] - } - ] - }, null, 2), 'utf-8'); - const symlinkPlanRunResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--plan', - `@${symlinkPlanPath}`, - '--allow-write', - '--json' - ], { env }); - assert(symlinkPlanRunResult.status === 0, `OpenAI Chat symlink plan run failed: ${symlinkPlanRunResult.stderr || symlinkPlanRunResult.stdout}`); - const symlinkPlanRunPayload = parseJsonOutput(symlinkPlanRunResult.stdout); - assertOpenAiRunPayload(symlinkPlanRunPayload, 'OpenAI Chat symlink plan run'); - assert(!fs.existsSync(path.join(symlinkEscapeDir, 'index.html')), 'OpenAI Chat materialization must not follow symlinked parents outside cwd'); - const symlinkMaterializedFiles = symlinkPlanRunPayload.run.nodes.flatMap(item => item && item.output && Array.isArray(item.output.materializedFiles) ? item.output.materializedFiles : []); - assert(symlinkMaterializedFiles.length === 0, 'OpenAI Chat symlink materialization should not report escaped files'); - const symlinkLogs = JSON.stringify(symlinkPlanRunPayload.run.nodes.flatMap(item => Array.isArray(item && item.logs) ? item.logs : [])); - assert(symlinkLogs.includes('artifact parent is a symlink'), 'OpenAI Chat symlink rejection should be visible in run logs'); - - if (process.platform !== 'win32') { - const targetSymlinkPlanPath = path.join(tmpHome, 'task-target-symlink-openai-plan.json'); - const targetSymlinkCwd = path.join(tmpHome, 'task-target-symlink-workspace'); - const targetSymlinkEscapeDir = path.join(tmpHome, 'task-target-symlink-escape-target'); - fs.mkdirSync(targetSymlinkCwd, { recursive: true }); - fs.mkdirSync(targetSymlinkEscapeDir, { recursive: true }); - fs.symlinkSync(path.join(targetSymlinkEscapeDir, 'index.html'), path.join(targetSymlinkCwd, 'index.html')); - fs.writeFileSync(targetSymlinkPlanPath, JSON.stringify({ - id: 'task-target-symlink-openai-plan', - title: 'Target symlink OpenAI Chat plan', - target: 'target-symlink-artifact-probe', - notes: 'The model will try to write index.html; this must be rejected because index.html is a symlink.', - cwd: targetSymlinkCwd, - threadId: 'thread-target-symlink-plan', - engine: 'openai-chat', - allowWrite: true, - dryRun: false, - concurrency: 1, - nodes: [ - { - id: 'target-symlink-openai-node', - title: 'Target symlink OpenAI node', - kind: 'openai-chat', - prompt: 'target-symlink-artifact-probe: return index.html in an html fenced block.', - dependsOn: [] - } - ] - }, null, 2), 'utf-8'); - const targetSymlinkRunResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--json', - '--allow-write', - '--plan', - `@${targetSymlinkPlanPath}` - ], { env }); - assert(targetSymlinkRunResult.status === 0, `OpenAI Chat target symlink plan run failed: ${targetSymlinkRunResult.stderr || targetSymlinkRunResult.stdout}`); - const targetSymlinkPayload = parseJsonOutput(targetSymlinkRunResult.stdout); - assertOpenAiRunPayload(targetSymlinkPayload, 'OpenAI Chat target symlink plan run'); - assert(!fs.existsSync(path.join(targetSymlinkEscapeDir, 'index.html')), 'OpenAI Chat materialization must not follow final target symlinks outside cwd'); - const targetSymlinkFiles = targetSymlinkPayload.run.nodes.flatMap(item => item && item.output && Array.isArray(item.output.materializedFiles) ? item.output.materializedFiles : []); - assert(targetSymlinkFiles.length === 0, 'OpenAI Chat target symlink materialization should not report escaped files'); - const targetSymlinkLogs = JSON.stringify(targetSymlinkPayload.run.nodes.flatMap(item => Array.isArray(item && item.logs) ? item.logs : [])); - assert(targetSymlinkLogs.includes('artifact target is a symlink'), 'OpenAI Chat target symlink rejection should be visible in run logs'); - } - - const openAiQueueAddResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'add', - '--target', - 'OpenAI Chat provider CLI 队列链路', - '--engine', - 'openai-chat', - '--cwd', - path.join(tmpHome, 'task-queue-workspace'), - '--thread-id', - 'thread-cli-queue', - '--json' - ], { env }); - assert(openAiQueueAddResult.status === 0, `OpenAI Chat queue add failed: ${openAiQueueAddResult.stderr || openAiQueueAddResult.stdout}`); - const openAiQueueAddPayload = parseJsonOutput(openAiQueueAddResult.stdout); - assert(openAiQueueAddPayload.ok === true && openAiQueueAddPayload.task && openAiQueueAddPayload.task.engine === 'openai-chat', 'OpenAI Chat queue add should persist engine'); - assert(openAiQueueAddPayload.task.threadId === 'thread-cli-queue', 'OpenAI Chat queue add should persist thread id'); - assert(openAiQueueAddPayload.task.cwd === path.join(tmpHome, 'task-queue-workspace'), 'OpenAI Chat queue add should persist cwd'); - - const openAiQueueStartResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'start', - openAiQueueAddPayload.task.taskId, - '--json' - ], { env }); - assert(openAiQueueStartResult.status === 0, `OpenAI Chat queue start failed: ${openAiQueueStartResult.stderr || openAiQueueStartResult.stdout}`); - const openAiQueueStartPayload = parseJsonOutput(openAiQueueStartResult.stdout); - assert(openAiQueueStartPayload.ok === true, 'OpenAI Chat queue start should succeed'); - assertOpenAiRunPayload(openAiQueueStartPayload.detail, 'OpenAI Chat CLI queue start'); - assert(openAiQueueStartPayload.detail.threadId === 'thread-cli-queue', 'OpenAI Chat queue start should preserve thread id'); - assert(openAiQueueStartPayload.detail.cwd === path.join(tmpHome, 'task-queue-workspace'), 'OpenAI Chat queue start should preserve cwd'); - - const apiOpenAiPlan = await api('task-plan', { - target: 'OpenAI Chat Web API 计划链路', - engine: 'openai-chat', - cwd: path.join(tmpHome, 'api-plan-workspace'), - threadId: 'thread-api-plan', - followUps: ['输出结论'] - }); - assert(apiOpenAiPlan.ok === true, 'OpenAI Chat task-plan API should validate'); - assert(apiOpenAiPlan.plan && apiOpenAiPlan.plan.engine === 'openai-chat', 'OpenAI Chat task-plan API should keep engine'); - assert(apiOpenAiPlan.plan.threadId === 'thread-api-plan', 'OpenAI Chat task-plan API should preserve thread id'); - assert(apiOpenAiPlan.plan.cwd === path.join(tmpHome, 'api-plan-workspace'), 'OpenAI Chat task-plan API should preserve cwd'); - assert(apiOpenAiPlan.plan.nodes.every((node) => node.kind === 'openai-chat'), 'OpenAI Chat task-plan API should produce OpenAI Chat nodes'); - - const apiRunCwd = path.join(tmpHome, 'api-run-workspace'); - fs.mkdirSync(apiRunCwd, { recursive: true }); - const apiOpenAiRun = await api('task-run', { - target: 'OpenAI Chat Web API 同步运行链路', - engine: 'openai-chat', - cwd: apiRunCwd, - threadId: 'thread-api-run', - concurrency: 1 - }, 15000); - assertOpenAiRunPayload(apiOpenAiRun, 'OpenAI Chat API run'); - assert(apiOpenAiRun.threadId === 'thread-api-run', 'OpenAI Chat API run should preserve thread id'); - assert(apiOpenAiRun.cwd === apiRunCwd, 'OpenAI Chat API run should preserve cwd'); - - const apiOpenAiDetail = await api('task-run-detail', { runId: apiOpenAiRun.runId }); - assert(apiOpenAiDetail && apiOpenAiDetail.run && apiOpenAiDetail.run.status === 'success', 'OpenAI Chat task-run-detail API should return run detail'); - assert(apiOpenAiDetail.threadId === 'thread-api-run', 'OpenAI Chat task-run-detail API should expose thread id'); - assert(apiOpenAiDetail.cwd === apiRunCwd, 'OpenAI Chat task-run-detail API should expose cwd'); - assert(apiOpenAiDetail.run.nodes.every((node) => node.kind === 'openai-chat'), 'OpenAI Chat task-run-detail API should expose OpenAI Chat nodes'); - - const apiOpenAiQueueAdd = await api('task-queue-add', { - target: 'OpenAI Chat Web API 队列链路', - engine: 'openai-chat', - cwd: path.join(tmpHome, 'api-queue-workspace'), - threadId: 'thread-api-queue' - }); - assert(apiOpenAiQueueAdd.ok === true && apiOpenAiQueueAdd.task && apiOpenAiQueueAdd.task.engine === 'openai-chat', 'OpenAI Chat task-queue-add API should persist engine'); - assert(apiOpenAiQueueAdd.task.threadId === 'thread-api-queue', 'OpenAI Chat task-queue-add API should persist thread id'); - assert(apiOpenAiQueueAdd.task.cwd === path.join(tmpHome, 'api-queue-workspace'), 'OpenAI Chat task-queue-add API should persist cwd'); - const apiOpenAiQueueStart = await api('task-queue-start', { - taskId: apiOpenAiQueueAdd.task.taskId, - detach: false - }, 15000); - assert(apiOpenAiQueueStart.ok === true, 'OpenAI Chat task-queue-start API should succeed'); - assertOpenAiRunPayload(apiOpenAiQueueStart.detail, 'OpenAI Chat API queue start'); - assert(apiOpenAiQueueStart.detail.threadId === 'thread-api-queue', 'OpenAI Chat task-queue-start API should preserve thread id'); - assert(apiOpenAiQueueStart.detail.cwd === path.join(tmpHome, 'api-queue-workspace'), 'OpenAI Chat task-queue-start API should preserve cwd'); - - const apiOpenAiOverview = await api('task-overview'); - assert(Array.isArray(apiOpenAiOverview.runs), 'OpenAI Chat task-overview API should return runs after execution'); - assert(apiOpenAiOverview.runs.some((item) => item.runId === apiOpenAiRun.runId), 'OpenAI Chat task-overview API should include API run'); - - assertOpenAiRequests(openAiMock, 6, 'OpenAI Chat full chain'); - } finally { - await openAiMock.close(); - } - - const missingQueueStartResult = runSync(node, [ - cliPath, - 'task', - 'queue', - 'start', - 'missing-task', - '--json' - ], { env }); - assert(missingQueueStartResult.status !== 0, 'task queue start should fail for missing task'); - const missingQueueStartPayload = parseJsonOutput(missingQueueStartResult.stdout); - assert(typeof missingQueueStartPayload.error === 'string' && missingQueueStartPayload.error.includes('task not found'), 'missing task queue start should report not found'); - - const invalidRunIdResult = runSync(node, [ - cliPath, - 'task', - 'run', - '--target', - '诊断当前配置', - '--workflow-id', - 'diagnose-config', - '--engine', - 'workflow', - '--run-id', - '../escaped-run', - '--json' - ], { env }); - assert(invalidRunIdResult.status !== 0, 'task run should reject unsafe run ids'); - const invalidRunIdPayload = parseJsonOutput(invalidRunIdResult.stdout); - assert(typeof invalidRunIdPayload.error === 'string' && invalidRunIdPayload.error.includes('unsupported characters'), 'unsafe run id should report validation error'); - - const apiRetry = await api('task-retry', { - runId: apiQueueStart.detail.runId, - detach: false - }); - assert(apiRetry && apiRetry.run && apiRetry.run.status === 'success', 'task-retry API should rerun task'); - - const apiLogs = await api('task-logs', { runId: apiRetry.runId }); - assert(typeof apiLogs.logs === 'string', 'task-logs API should return logs'); - - const apiCancelQueued = await api('task-queue-add', { - target: '待取消任务', - workflowIds: ['diagnose-config'], - engine: 'workflow' - }); - assert(apiCancelQueued.ok === true, 'second task-queue-add API should succeed'); - const apiCancel = await api('task-cancel', { taskId: apiCancelQueued.task.taskId }); - assert(apiCancel.ok === true, 'task-cancel API should cancel queued task'); - const canceledTask = await api('task-queue-show', { taskId: apiCancelQueued.task.taskId }); - assert(canceledTask && canceledTask.status === 'cancelled', 'task-cancel API should mark queued task as cancelled'); -}; diff --git a/tests/unit/desktop-diagnostics-contract.test.mjs b/tests/unit/desktop-diagnostics-contract.test.mjs new file mode 100644 index 00000000..d86f5fdf --- /dev/null +++ b/tests/unit/desktop-diagnostics-contract.test.mjs @@ -0,0 +1,128 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); + +function readSource(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + +test('desktop release diagnostics expose console and file logging paths', () => { + const mainSource = readSource('src-tauri/src/main.rs'); + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(mainSource, /app_lib::init_desktop_diagnostics\(\)/); + assert.match(libSource, /CODEXMATE_DESKTOP_LOG/); + assert.match(libSource, /CODEXMATE_DESKTOP_LOG_FILE/); + assert.match(libSource, /--debug-console/); + assert.match(libSource, /--log-to-console/); + assert.match(libSource, /AttachConsole/); + assert.match(libSource, /ATTACH_PARENT_PROCESS/); + assert.match(libSource, /CONOUT\$/); + assert.match(libSource, /fn desktop_default_logs_dir\(\) -> PathBuf[\s\S]*CodexMate[\s\S]*logs/); + assert.match(libSource, /desktop_default_logs_dir\(\)\.join\("desktop\.log"\)/); + assert.match(libSource, /desktop_default_logs_dir\(\)\.join\("startup\.log"\)/); + assert.match(libSource, /startup_log_file=/); + assert.match(libSource, /std::panic::set_hook/); +}); + +test('desktop backend startup diagnostics use fixed startup log for child stdio', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /let inherit_backend_stdio = DESKTOP_CONSOLE_LOGGING\.load/); + assert.match(libSource, /command\.stdout\(Stdio::inherit\(\)\)\.stderr\(Stdio::inherit\(\)\)/); + assert.match(libSource, /fn backend_startup_log_file_path\(\) -> PathBuf/); + assert.match(libSource, /fn backend_startup_log_stdio\(\) -> Stdio/); + assert.match(libSource, /fn backend_startup_log_excerpt\(\) -> String/); + assert.match(libSource, /startup\.log tail/); + assert.match(libSource, /command[\s\S]*\.stdout\(backend_startup_log_stdio\(\)\)[\s\S]*\.stderr\(backend_startup_log_stdio\(\)\)/); + assert.match(libSource, /append_log_line\(backend_startup_log_file_path\(\), &line\)/); + assert.match(libSource, /if DESKTOP_CONSOLE_LOGGING\.load[\s\S]*return;[\s\S]*CREATE_NO_WINDOW/); +}); + +test('desktop release backend uses bundled Node runtime instead of requiring system PATH node', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + const stageSource = readSource('tools/desktop/prepare-tauri-resources.js'); + + assert.match(stageSource, /function copyNodeRuntime\(\)/); + assert.match(stageSource, /process\.execPath/); + assert.match(stageSource, /node-runtime/); + assert.match(stageSource, /nodeRuntime/); + assert.match(libSource, /fn find_node_runtime_path\(app: &tauri::App\)/); + assert.match(libSource, /CODEXMATE_NODE/); + assert.match(libSource, /node-runtime/); + assert.match(libSource, /bundled_node_executable_name\(\)/); + assert.match(libSource, /let node_bin = strip_path_prefix\(find_node_runtime_path\(app\)\?\)/); + assert.match(libSource, /Command::new\(&node_bin\)/); + assert.doesNotMatch(libSource, /unwrap_or_else\(\|_\| "node"\.to_string\(\)\)/); +}); + +test('desktop startup reuses healthy backend without killing occupied ports', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /if health_check_ready\(\)[\s\S]*existing backend already ready[\s\S]*return Ok\(None\)/); + assert.match(libSource, /backend port is occupied but not ready yet; waiting before surfacing occupied-port guidance/); + assert.match(libSource, /if backend_port_occupied\(\)[\s\S]*wait_for_backend\(Duration::from_secs\(5\)\)[\s\S]*existing backend became ready while waiting[\s\S]*return Ok\(None\)/); + assert.match(libSource, /backend port remains occupied after grace wait/); + assert.doesNotMatch(libSource, /fn release_stale_backend_port\(\) -> usize/); + assert.doesNotMatch(libSource, /fn is_managed_backend_command\(command_line: &str\) -> bool/); + assert.doesNotMatch(libSource, /taskkill[\s\S]*\/PID[\s\S]*\/F/); + assert.doesNotMatch(libSource, /kill[\s\S]*-9/); + assert.doesNotMatch(libSource, /ShellExecuteW/); + assert.doesNotMatch(libSource, /runas/); +}); + +test('desktop backend spawn strips verbatim path prefix for node entrypoint', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /fn strip_path_prefix\(path: PathBuf\) -> PathBuf/); + assert.match(libSource, /raw\.strip_prefix\(r"\\\\\?\\"\)/); + assert.match(libSource, /let cli_path = strip_path_prefix\(find_cli_path\(app\)\?\)/); + assert.match(libSource, /let node_bin = strip_path_prefix\(find_node_runtime_path\(app\)\?\)/); +}); + +test('desktop Windows package does not require administrator privileges', () => { + const buildSource = readSource('src-tauri/build.rs'); + const workflowSource = readSource('.github/workflows/desktop-build.yml'); + + assert.match(buildSource, /tauri_build::build\(\)/); + assert.doesNotMatch(buildSource, /app_manifest/); + assert.doesNotMatch(workflowSource, /Verify Windows app UAC manifest/); + assert.doesNotMatch(workflowSource, /requireAdministrator/); +}); + +test('desktop startup surfaces occupied backend port guidance instead of killing processes', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /fn MessageBoxW/); + assert.match(libSource, /MB_ICONERROR/); + assert.match(libSource, /MB_TOPMOST/); + assert.match(libSource, /fn show_startup_error\(message: &str\)/); + assert.match(libSource, /Codex Mate 启动失败/); + assert.match(libSource, /fn backend_port_occupied\(\) -> bool/); + assert.match(libSource, /fn wait_for_spawned_backend\(child: &mut Child, timeout: Duration\) -> Result<\(\), String>/); + assert.match(libSource, /backend exited before readiness/); + assert.match(libSource, /Duration::from_secs\(60\)/); + assert.match(libSource, /backend_startup_log_excerpt\(\)/); + assert.match(libSource, /fn backend_port_occupied_message\(\) -> String/); + assert.match(libSource, /端口 3737 已被其他进程占用/); + assert.match(libSource, /请先关闭旧的 Codex Mate \/ codexmate run 实例后重试/); + assert.match(libSource, /以管理员身份打开任务管理器/); + assert.match(libSource, /startup\.log/); + assert.match(libSource, /if backend_port_occupied\(\)[\s\S]*wait_for_backend\(Duration::from_secs\(5\)\)[\s\S]*return startup_error\(message\)/); + assert.match(libSource, /backend port remains occupied after grace wait/); +}); + +test('desktop windows installer uses current-user install without force-closing running apps', () => { + const configSource = readSource('src-tauri/tauri.conf.json'); + + assert.match(configSource, /"windows"\s*:/); + assert.match(configSource, /"allowDowngrades"\s*:\s*true/); + assert.match(configSource, /"upgradeCode"\s*:\s*"e84da745-7b0b-5548-85ed-a4a0be7b55ae"/); + assert.match(configSource, /"installMode"\s*:\s*"currentUser"/); + assert.doesNotMatch(configSource, /installerHooks/); +}); diff --git a/tests/unit/desktop-stage.test.mjs b/tests/unit/desktop-stage.test.mjs new file mode 100644 index 00000000..12bfb541 --- /dev/null +++ b/tests/unit/desktop-stage.test.mjs @@ -0,0 +1,172 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); +} + +function readPngSize(filePath) { + const buffer = fs.readFileSync(filePath); + assert.strictEqual(buffer.toString('latin1', 0, 8), '\x89PNG\r\n\x1A\n', `${filePath} must be a PNG`); + return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)]; +} + +function readIcoSizes(filePath) { + const buffer = fs.readFileSync(filePath); + assert.strictEqual(buffer.readUInt16LE(0), 0, `${filePath} ico reserved field must be zero`); + assert.strictEqual(buffer.readUInt16LE(2), 1, `${filePath} must be an icon resource`); + const count = buffer.readUInt16LE(4); + const sizes = []; + for (let index = 0; index < count; index += 1) { + const offset = 6 + index * 16; + const width = buffer[offset] || 256; + const height = buffer[offset + 1] || 256; + sizes.push(`${width}x${height}`); + } + return sizes.sort((a, b) => Number(a.split('x')[0]) - Number(b.split('x')[0])); +} + +function readIcnsTypes(filePath) { + const buffer = fs.readFileSync(filePath); + assert.strictEqual(buffer.toString('latin1', 0, 4), 'icns', `${filePath} must be an ICNS file`); + assert.strictEqual(buffer.readUInt32BE(4), buffer.length, `${filePath} ICNS length header must match file size`); + const types = []; + for (let offset = 8; offset + 8 <= buffer.length;) { + const type = buffer.toString('latin1', offset, offset + 4); + const size = buffer.readUInt32BE(offset + 4); + assert.ok(size >= 8, `${filePath} ICNS entry ${type} has invalid size`); + types.push(type); + offset += size; + } + return types.sort(); +} + +test('desktop staging creates validated runtime resource layout', () => { + const result = spawnSync(process.execPath, ['tools/desktop/prepare-tauri-resources.js'], { + cwd: projectRoot, + encoding: 'utf8' + }); + + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + + const stageRoot = path.join(projectRoot, 'dist', 'desktop', 'codexmate'); + const requiredEntries = [ + 'codexmate-desktop.json', + 'cli.js', + 'cli', + 'lib', + 'plugins', + 'web-ui', + 'web-ui.html', + 'package.json', + 'package-lock.json', + 'node_modules', + 'node-runtime' + ]; + + for (const entry of requiredEntries) { + assert.ok(fs.existsSync(path.join(stageRoot, entry)), `missing staged desktop resource: ${entry}`); + } + + const pkg = readJson(path.join(projectRoot, 'package.json')); + const manifest = readJson(path.join(stageRoot, 'codexmate-desktop.json')); + assert.strictEqual(manifest.layoutVersion, 1); + assert.strictEqual(manifest.version, pkg.version); + assert.strictEqual(manifest.entrypoint, 'cli.js'); + assert.match(manifest.nodeRuntime, /^node-runtime\/node(\.exe)?$/); + assert.ok(fs.existsSync(path.join(stageRoot, manifest.nodeRuntime)), 'manifest should point at the bundled Node.js runtime'); + assert.ok(manifest.copiedRuntimeModules > 0, 'manifest should record copied runtime node modules'); + + for (const dependencyName of Object.keys(pkg.dependencies || {})) { + const dependencyPath = path.join(stageRoot, 'node_modules', ...dependencyName.split('/')); + assert.ok(fs.existsSync(dependencyPath), `missing staged runtime dependency: ${dependencyName}`); + } + + const tauriConfig = readJson(path.join(projectRoot, 'src-tauri', 'tauri.conf.json')); + assert.strictEqual(tauriConfig.bundle.resources['../dist/desktop/codexmate'], 'codexmate'); + assert.match(tauriConfig.app.security.csp, /default-src 'self'/); + assert.match(tauriConfig.app.security.csp, /http:\/\/127\.0\.0\.1:3737/); +}); + +test('desktop icons are sized correctly and referenced by Tauri bundle config', () => { + const tauriConfig = readJson(path.join(projectRoot, 'src-tauri', 'tauri.conf.json')); + const bundleIcons = Array.isArray(tauriConfig.bundle && tauriConfig.bundle.icon) + ? tauriConfig.bundle.icon + : []; + assert.deepStrictEqual(bundleIcons, [ + 'icons/32x32.png', + 'icons/128x128.png', + 'icons/128x128@2x.png', + 'icons/icon.icns', + 'icons/icon.ico' + ]); + + const expectedPngSizes = { + '32x32.png': [32, 32], + '64x64.png': [64, 64], + '128x128.png': [128, 128], + '128x128@2x.png': [256, 256], + 'icon.png': [512, 512], + 'Square30x30Logo.png': [30, 30], + 'Square44x44Logo.png': [44, 44], + 'Square71x71Logo.png': [71, 71], + 'Square89x89Logo.png': [89, 89], + 'Square107x107Logo.png': [107, 107], + 'Square142x142Logo.png': [142, 142], + 'Square150x150Logo.png': [150, 150], + 'Square284x284Logo.png': [284, 284], + 'Square310x310Logo.png': [310, 310], + 'StoreLogo.png': [50, 50] + }; + for (const [fileName, expectedSize] of Object.entries(expectedPngSizes)) { + assert.deepStrictEqual( + readPngSize(path.join(projectRoot, 'src-tauri', 'icons', fileName)), + expectedSize, + `${fileName} should have the expected generated icon dimensions` + ); + } + + for (const icon of bundleIcons) { + assert.ok(fs.existsSync(path.join(projectRoot, 'src-tauri', icon)), `bundle icon is missing: ${icon}`); + } + assert.deepStrictEqual(readIcoSizes(path.join(projectRoot, 'src-tauri', 'icons', 'icon.ico')), [ + '16x16', + '24x24', + '32x32', + '48x48', + '64x64', + '256x256' + ]); + assert.deepStrictEqual(readIcnsTypes(path.join(projectRoot, 'src-tauri', 'icons', 'icon.icns')), [ + 'ic07', + 'ic08', + 'ic09', + 'ic10', + 'ic11', + 'ic12', + 'ic13', + 'ic14', + 'il32', + 'is32', + 'l8mk', + 's8mk' + ]); +}); + +test('desktop workflow builds a current-user Windows installer instead of portable package', () => { + const workflowSource = fs.readFileSync(path.join(projectRoot, '.github', 'workflows', 'desktop-build.yml'), 'utf8'); + const tauriConfig = readJson(path.join(projectRoot, 'src-tauri', 'tauri.conf.json')); + + assert.match(workflowSource, /Build Windows installer/); + assert.match(workflowSource, /npm run desktop:build -- --bundles nsis/); + assert.match(workflowSource, /src-tauri\/target\/release\/bundle\/nsis\/\*\*/); + assert.doesNotMatch(workflowSource, /portable/i); + assert.strictEqual(tauriConfig.bundle.windows.nsis.installMode, 'currentUser'); +}); diff --git a/tests/unit/npm-package-files.test.mjs b/tests/unit/npm-package-files.test.mjs index 36235624..285736e2 100644 --- a/tests/unit/npm-package-files.test.mjs +++ b/tests/unit/npm-package-files.test.mjs @@ -17,3 +17,10 @@ test('npm package includes plugins directory for Web UI runtime imports', () => assert.ok(files.includes('plugins/'), 'package.json files must include plugins/'); }); +test('npm package excludes desktop build-only sources', () => { + const pkg = readJson(path.join(projectRoot, 'package.json')); + const files = Array.isArray(pkg.files) ? pkg.files : []; + assert.ok(!files.includes('src-tauri/'), 'package.json files must not include src-tauri/'); + assert.ok(!files.includes('tools/desktop/'), 'package.json files must not include tools/desktop/'); +}); + diff --git a/tests/unit/release-workflow-contract.test.mjs b/tests/unit/release-workflow-contract.test.mjs new file mode 100644 index 00000000..46635a66 --- /dev/null +++ b/tests/unit/release-workflow-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); + +test('release workflow uploads desktop installers as release assets', () => { + const releaseWorkflow = fs.readFileSync(path.join(projectRoot, '.github', 'workflows', 'release.yml'), 'utf8'); + + assert.match(releaseWorkflow, /\n resolve:\n[\s\S]*?\n desktop:\n/m); + assert.match(releaseWorkflow, /\n desktop:\n[\s\S]*?runs-on:\s*\$\{\{ matrix\.os \}\}/m); + assert.match(releaseWorkflow, /name:\s*codexmate-desktop-\$\{\{ matrix\.name \}\}/m); + assert.match(releaseWorkflow, /src-tauri\/target\/release\/bundle\/dmg\/\*\.dmg/); + assert.match(releaseWorkflow, /src-tauri\/target\/release\/bundle\/msi\/\*\.msi/); + assert.match(releaseWorkflow, /src-tauri\/target\/release\/bundle\/nsis\/\*\.exe/); + assert.match(releaseWorkflow, /pattern:\s*codexmate-desktop-\*/); + assert.match(releaseWorkflow, /merge-multiple:\s*true/); + assert.match(releaseWorkflow, /desktop-release-assets\/\*\*\/\*\.dmg/); + assert.match(releaseWorkflow, /desktop-release-assets\/\*\*\/\*\.msi/); + assert.match(releaseWorkflow, /desktop-release-assets\/\*\*\/\*\.exe/); +}); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 25c5353c..57c94f76 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -69,8 +69,11 @@ await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs') await import(pathToFileURL(path.join(__dirname, 'release-changelog.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'update-version-status.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'ci-workflow-contract.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'release-workflow-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'lint-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'npm-package-files.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'desktop-stage.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'desktop-diagnostics-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-tab-switch-performance.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-trash-state.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'web-ui-restart.test.mjs'))); diff --git a/tools/desktop/prepare-tauri-resources.js b/tools/desktop/prepare-tauri-resources.js new file mode 100644 index 00000000..487aa2f9 --- /dev/null +++ b/tools/desktop/prepare-tauri-resources.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const LAYOUT_VERSION = 1; +const rootDir = path.resolve(__dirname, '..', '..'); +const packagePath = path.join(rootDir, 'package.json'); +const packageLockPath = path.join(rootDir, 'package-lock.json'); +const tauriConfigPath = path.join(rootDir, 'src-tauri', 'tauri.conf.json'); +const cargoTomlPath = path.join(rootDir, 'src-tauri', 'Cargo.toml'); +const stageRelativePath = path.join('dist', 'desktop', 'codexmate'); +const stageDir = path.join(rootDir, stageRelativePath); +const stageNodeModulesDir = path.join(stageDir, 'node_modules'); +const stageNodeRuntimeDir = path.join(stageDir, 'node-runtime'); +const TAURI_CSP = "default-src 'self' http://127.0.0.1:3737; connect-src 'self' http://127.0.0.1:3737; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"; + +const runtimeEntries = [ + 'cli.js', + 'package.json', + 'package-lock.json', + 'cli', + 'lib', + 'plugins', + 'web-ui', + 'web-ui.html' +]; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function assertExists(relativePath) { + const resolved = path.join(rootDir, relativePath); + if (!fs.existsSync(resolved)) { + throw new Error(`desktop resource is missing: ${relativePath}`); + } + return resolved; +} + +function copyPath(sourcePath, destinationPath) { + const stat = fs.statSync(sourcePath); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + if (stat.isDirectory()) { + fs.cpSync(sourcePath, destinationPath, { + recursive: true, + force: true, + dereference: false, + filter: (source) => !source.split(path.sep).includes('.git') + }); + return; + } + fs.copyFileSync(sourcePath, destinationPath); + if (path.basename(destinationPath) === 'cli.js') { + fs.chmodSync(destinationPath, stat.mode | 0o755); + } +} + +function copyRuntimeEntries() { + for (const entry of runtimeEntries) { + const source = assertExists(entry); + const destination = path.join(stageDir, entry); + copyPath(source, destination); + } +} + +function packageLockRuntimeModulePaths(lockFile) { + const packages = lockFile && typeof lockFile === 'object' ? lockFile.packages : null; + if (!packages || typeof packages !== 'object') { + throw new Error('package-lock.json is missing packages metadata; run npm install with a lockfileVersion that records package paths'); + } + + return Object.entries(packages) + .filter(([packagePathInLock, metadata]) => { + if (!packagePathInLock.startsWith('node_modules/')) return false; + if (!metadata || typeof metadata !== 'object') return false; + return metadata.dev !== true; + }) + .map(([packagePathInLock]) => packagePathInLock) + .sort((a, b) => a.localeCompare(b)); +} + +function copyRuntimeNodeModules(pkg, lockFile) { + assertExists('node_modules'); + fs.mkdirSync(stageNodeModulesDir, { recursive: true }); + + const copied = []; + for (const modulePath of packageLockRuntimeModulePaths(lockFile)) { + const source = path.join(rootDir, modulePath); + if (!fs.existsSync(source)) { + throw new Error(`runtime dependency is missing from root install: ${modulePath}; run npm ci first`); + } + const destination = path.join(stageDir, modulePath); + copyPath(source, destination); + copied.push(modulePath); + } + + const dependencies = Object.keys(pkg.dependencies || {}); + for (const dependencyName of dependencies) { + const dependencyPath = path.join(stageNodeModulesDir, ...dependencyName.split('/')); + if (!fs.existsSync(dependencyPath)) { + throw new Error(`staged runtime dependency is missing: ${dependencyName}`); + } + } + + return copied; +} + +function nodeExecutableName() { + return process.platform === 'win32' ? 'node.exe' : 'node'; +} + +function copyNodeRuntime() { + const source = process.execPath; + if (!source || !fs.existsSync(source)) { + throw new Error('unable to locate current Node.js executable for desktop packaging'); + } + + fs.mkdirSync(stageNodeRuntimeDir, { recursive: true }); + const executableName = nodeExecutableName(); + const destination = path.join(stageNodeRuntimeDir, executableName); + fs.copyFileSync(source, destination); + const sourceMode = fs.statSync(source).mode; + fs.chmodSync(destination, sourceMode | 0o755); + return path.join('node-runtime', executableName).replace(/\\/g, '/'); +} + +function writeStageManifest(pkg, copiedModules, nodeRuntime) { + writeJson(path.join(stageDir, 'codexmate-desktop.json'), { + layoutVersion: LAYOUT_VERSION, + productName: 'Codex Mate', + version: pkg.version, + entrypoint: 'cli.js', + nodeRuntime, + nodeModules: 'node_modules', + webUi: 'web-ui', + copiedRuntimeModules: copiedModules.length + }); +} + +function validateStagedResources(pkg) { + const requiredStageEntries = [ + 'cli.js', + 'package.json', + 'package-lock.json', + 'cli', + 'lib', + 'plugins', + 'web-ui', + 'web-ui.html', + 'node_modules', + 'node-runtime', + 'codexmate-desktop.json' + ]; + + for (const entry of requiredStageEntries) { + const stagedPath = path.join(stageDir, entry); + if (!fs.existsSync(stagedPath)) { + throw new Error(`staged desktop resource is missing: ${entry}`); + } + } + + const stagedPackage = readJson(path.join(stageDir, 'package.json')); + if (stagedPackage.name !== pkg.name || stagedPackage.version !== pkg.version) { + throw new Error(`staged package metadata mismatch: expected ${pkg.name}@${pkg.version}`); + } + + const manifest = readJson(path.join(stageDir, 'codexmate-desktop.json')); + if (manifest.layoutVersion !== LAYOUT_VERSION || manifest.entrypoint !== 'cli.js') { + throw new Error('staged desktop manifest is invalid'); + } + if (!manifest.nodeRuntime || !fs.existsSync(path.join(stageDir, manifest.nodeRuntime))) { + throw new Error('staged desktop Node.js runtime is missing'); + } +} + +function stageDesktopResources(pkg, lockFile) { + fs.rmSync(stageDir, { recursive: true, force: true }); + fs.mkdirSync(stageDir, { recursive: true }); + copyRuntimeEntries(); + const copiedModules = copyRuntimeNodeModules(pkg, lockFile); + const nodeRuntime = copyNodeRuntime(); + writeStageManifest(pkg, copiedModules, nodeRuntime); + validateStagedResources(pkg); + return copiedModules.length; +} + +function updateTauriConfig(pkg) { + const config = readJson(tauriConfigPath); + + config.productName = 'Codex Mate'; + config.version = pkg.version; + config.identifier = config.identifier && config.identifier !== 'com.tauri.dev' + ? config.identifier + : 'ai.codexmate.desktop'; + + config.build = { + ...(config.build || {}), + devUrl: 'http://127.0.0.1:3737', + frontendDist: '../web-ui', + beforeDevCommand: 'npm run desktop:stage && node cli.js run --host 127.0.0.1 --no-browser', + beforeBuildCommand: 'npm run desktop:stage' + }; + + config.app = { + ...(config.app || {}), + windows: [ + { + label: 'main', + title: 'Codex Mate', + width: 1280, + height: 860, + minWidth: 960, + minHeight: 640, + resizable: true, + fullscreen: false, + url: 'http://127.0.0.1:3737' + } + ], + security: { + ...(config.app && config.app.security ? config.app.security : {}), + csp: TAURI_CSP + } + }; + + config.bundle = { + ...(config.bundle || {}), + active: true, + targets: 'all', + resources: { + '../dist/desktop/codexmate': 'codexmate' + } + }; + + writeJson(tauriConfigPath, config); +} + +function updateCargoVersion(pkg) { + if (!fs.existsSync(cargoTomlPath)) return; + const cargoToml = fs.readFileSync(cargoTomlPath, 'utf8'); + const nextCargoToml = cargoToml.replace( + /(\[package\][\s\S]*?\nversion\s*=\s*")([^"]+)(")/, + `$1${pkg.version}$3` + ); + fs.writeFileSync(cargoTomlPath, nextCargoToml); +} + +function main() { + const pkg = readJson(packagePath); + const lockFile = readJson(packageLockPath); + + runtimeEntries.forEach(assertExists); + const copiedModuleCount = stageDesktopResources(pkg, lockFile); + updateTauriConfig(pkg); + updateCargoVersion(pkg); + + console.log(`desktop resources staged at ${path.relative(rootDir, stageDir)} for Codex Mate ${pkg.version}`); + console.log(`desktop stage includes ${copiedModuleCount} production node_modules package(s)`); +} + +main();