diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 00000000..ce9f22e0 --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,97 @@ +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 + run: npm run desktop:build + + - name: Verify Windows app UAC manifest + if: matrix.name == 'Windows' + shell: pwsh + run: | + $exe = Join-Path $PWD 'src-tauri/target/release/codexmate-desktop.exe' + if (!(Test-Path $exe)) { + throw "Built app exe not found: $exe" + } + + $mt = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter mt.exe | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (!$mt) { + throw 'Windows manifest tool mt.exe not found' + } + + $manifest = Join-Path $env:RUNNER_TEMP 'codexmate-desktop.manifest.xml' + & $mt.FullName -nologo "-inputresource:$exe;#1" "-out:$manifest" + if ($LASTEXITCODE -ne 0) { + throw "mt.exe failed to extract manifest from $exe" + } + + $text = Get-Content $manifest -Raw + Write-Host $text + if ($text -notmatch 'requestedExecutionLevel\s+level="requireAdministrator"\s+uiAccess="false"') { + throw 'Windows app manifest does not require administrator privileges' + } + Copy-Item $manifest (Join-Path (Split-Path $exe) 'codexmate-desktop.manifest.xml') -Force + + - 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/codexmate-desktop.exe + src-tauri/target/release/codexmate-desktop.manifest.xml + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 829483ce..0ccf3ba7 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 || 'auto' }}" on: workflow_dispatch: @@ -11,17 +11,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' @@ -113,18 +124,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}`, @@ -152,28 +151,98 @@ 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: ${{ 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: ${{ 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('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);" + 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: Compute release name 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 @@ -191,11 +260,17 @@ 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 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 @@ -213,22 +288,13 @@ jobs: trap 'rm -f "${tmp_logins}"' EXIT # Fetch PR authors in range using base...head comparison - gh pr list \ - --repo "${GITHUB_REPOSITORY}" \ - --limit 500 \ - --json author \ - --jq '.[].author.login' 2>/dev/null | sort -u > "${tmp_logins}" || true + gh pr list --repo "${GITHUB_REPOSITORY}" --limit 500 --json author --jq '.[].author.login' 2>/dev/null | sort -u > "${tmp_logins}" || true # Fetch merged PRs in range using commits tmp_merged=$(mktemp) - git log "${LATEST_TAG}...${RELEASE_TAG}" --pretty=format:%s \ - | grep -oE '#[0-9]+' \ - | sed 's/^#//' \ - | sort -u \ - | while read -r pr_number; do + git log "${LATEST_TAG}...${RELEASE_TAG}" --pretty=format:%s | grep -oE '#[0-9]+' | sed 's/^#//' | 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 cat "${tmp_merged}" > "${CONTRIBUTORS_FILE}" @@ -240,8 +306,8 @@ jobs: fi - name: Generate release notes from actual commit range 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: | @@ -256,9 +322,9 @@ jobs: node tools/release/changelog.js test -s "${RELEASE_CHANGELOG_FILE}" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 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 @@ -267,4 +333,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..3c8bd119 --- /dev/null +++ b/doc/desktop.md @@ -0,0 +1,92 @@ +# 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 +``` + +## 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 00705df7..49f4fe4c 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.30", "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 939bde63..e50f2f0d 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.30", "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/app.manifest b/src-tauri/app.manifest new file mode 100644 index 00000000..bb86d44b --- /dev/null +++ b/src-tauri/app.manifest @@ -0,0 +1,23 @@ + + + Codex Mate + + + + + + + + + + + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 00000000..86bd0b07 --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,6 @@ +fn main() { + let windows = tauri_build::WindowsAttributes::new() + .app_manifest(include_str!("app.manifest")); + let attrs = tauri_build::Attributes::new().windows_attributes(windows); + tauri_build::try_build(attrs).expect("failed to run tauri build script"); +} 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..3454a809 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,722 @@ +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 无法启动后端。Windows 桌面版启动时会请求管理员权限以停止旧的 Codex Mate / codexmate run 实例;如果仍失败,请手动关闭占用 3737 的进程后重试。详情见 startup.log。".to_string() +} + +fn is_managed_backend_command(command_line: &str) -> bool { + let normalized = command_line + .replace('\\', "/") + .replace('\"', "") + .replace('\'', "") + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase(); + let padded = format!(" {normalized} "); + padded.contains(" cli.js run ") + || padded.contains("/cli.js run ") + || padded.contains(" codexmate run ") + || padded.contains("/codexmate run ") + || padded.contains(" codexmate.cmd run ") + || padded.contains("/codexmate.cmd run ") + || padded.contains(" codexmate.exe run ") + || padded.contains("/codexmate.exe run ") +} + +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 command_output(mut command: Command) -> std::io::Result { + configure_backend_process(&mut command); + command.output() +} + +#[cfg(not(windows))] +fn command_output(mut command: Command) -> std::io::Result { + command.output() +} + +#[cfg(windows)] +fn windows_command_line_for_pid(pid: u32) -> Option { + let output = command_output({ + let mut command = Command::new("powershell"); + let script = format!( + "$p = Get-CimInstance Win32_Process -Filter \"ProcessId = {}\"; if ($p) {{ $p.CommandLine }}", + pid + ); + command.arg("-NoProfile").arg("-Command").arg(script); + command + }) + .ok()?; + if !output.status.success() { + return None; + } + let command_line = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if command_line.is_empty() { + None + } else { + Some(command_line) + } +} + +#[cfg(windows)] +fn release_stale_backend_port() -> usize { + let output = match command_output({ + let mut command = Command::new("netstat"); + command.args(["-ano", "-p", "tcp"]); + command + }) { + Ok(value) => value, + Err(err) => { + desktop_log(format!("backend port cleanup skipped; netstat failed: {err}")); + return 0; + } + }; + + let mut released = 0usize; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 5 || parts[3] != "LISTENING" || !parts[1].ends_with(":3737") { + continue; + } + let local_address = parts[1]; + if !(local_address.starts_with("127.0.0.1:") || local_address.starts_with("[::1]:")) { + desktop_log(format!( + "backend port cleanup skipped pid={}; non-local listener={}", + parts[4], local_address + )); + continue; + } + let Ok(pid) = parts[4].parse::() else { + continue; + }; + let command_line = windows_command_line_for_pid(pid) + .unwrap_or_else(|| "".to_string()); + if !is_managed_backend_command(&command_line) { + desktop_log(format!( + "backend port cleanup skipped pid={pid}; unmanaged listener on 127.0.0.1:3737; command_line={command_line}" + )); + continue; + } + desktop_log(format!( + "backend port cleanup killing managed loopback listener pid={pid}; command_line={command_line}" + )); + if command_output({ + let mut command = Command::new("taskkill"); + command.arg("/PID").arg(pid.to_string()).arg("/F"); + command + }) + .map(|output| output.status.success()) + .unwrap_or(false) + { + released += 1; + } else { + desktop_log(format!("backend port cleanup failed to kill loopback listener pid={pid}")); + } + } + if released > 0 { + desktop_log(format!("backend port cleanup released {released} stale listener(s)")); + std::thread::sleep(Duration::from_millis(500)); + } + released +} + +#[cfg(not(windows))] +fn process_command_line_for_pid(pid: u32) -> Option { + let output = command_output({ + let mut command = Command::new("ps"); + command.arg("-p").arg(pid.to_string()).arg("-o").arg("args="); + command + }) + .ok()?; + if !output.status.success() { + return None; + } + let command_line = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if command_line.is_empty() { + None + } else { + Some(command_line) + } +} + +#[cfg(not(windows))] +fn release_stale_backend_port() -> usize { + let output = match command_output({ + let mut command = Command::new("lsof"); + command.args(["-nP", "-iTCP:3737", "-sTCP:LISTEN", "-t"]); + command + }) { + Ok(value) => value, + Err(err) => { + desktop_log(format!("backend port cleanup skipped; lsof failed: {err}")); + return 0; + } + }; + + let mut released = 0usize; + for token in String::from_utf8_lossy(&output.stdout).split_whitespace() { + let Ok(pid) = token.parse::() else { + continue; + }; + let command_line = process_command_line_for_pid(pid) + .unwrap_or_else(|| "".to_string()); + if !is_managed_backend_command(&command_line) { + desktop_log(format!( + "backend port cleanup skipped pid={pid}; unmanaged listener on 127.0.0.1:3737; command_line={command_line}" + )); + continue; + } + desktop_log(format!( + "backend port cleanup killing managed loopback listener pid={pid}; command_line={command_line}" + )); + if command_output({ + let mut command = Command::new("kill"); + command.arg("-9").arg(pid.to_string()); + command + }) + .map(|output| output.status.success()) + .unwrap_or(false) + { + released += 1; + } else { + desktop_log(format!("backend port cleanup failed to kill loopback listener pid={pid}")); + } + } + if released > 0 { + desktop_log(format!("backend port cleanup released {released} stale listener(s)")); + std::thread::sleep(Duration::from_millis(500)); + } + released +} + +#[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 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 stale cleanup"); + 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); + } + } + + release_stale_backend_port(); + + if health_check_ready() { + desktop_log("backend became ready after stale port cleanup; reusing 127.0.0.1:3737 listener"); + return Ok(None); + } + + if backend_port_occupied() { + desktop_log("backend port remains occupied after cleanup; waiting once more before surfacing occupied-port error"); + if wait_for_backend(Duration::from_secs(5)) { + desktop_log("backend became ready after occupied-port grace wait; reusing 127.0.0.1:3737 listener"); + return Ok(None); + } + let message = backend_port_occupied_message(); + desktop_log(format!("backend port remains occupied after cleanup and grace wait; {message}")); + return startup_error(message); + } + + let cli_path = find_cli_path(app)?; + let cli_dir = cli_path + .parent() + .ok_or_else(|| "unable to resolve codexmate cli directory")?; + let node_bin = 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..be78e5ac --- /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..1a647f4a --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,57 @@ +{ + "$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": "both", + "installerHooks": "windows/installer-hooks.nsh" + } + } + } +} diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 00000000..888eadad --- /dev/null +++ b/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,4 @@ +!macro NSIS_HOOK_PREINSTALL + DetailPrint "Closing running Codex Mate before installing..." + nsExec::ExecToLog 'taskkill /IM "codexmate-desktop.exe" /F' +!macroend diff --git a/tests/unit/desktop-diagnostics-contract.test.mjs b/tests/unit/desktop-diagnostics-contract.test.mjs new file mode 100644 index 00000000..86550d3c --- /dev/null +++ b/tests/unit/desktop-diagnostics-contract.test.mjs @@ -0,0 +1,146 @@ +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\.stdout\(backend_startup_log_stdio\(\)\)\.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 = 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 before clearing stale port listeners', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /fn release_stale_backend_port\(\) -> usize/); + 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 stale cleanup/); + 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, /release_stale_backend_port\(\);[\s\S]*if health_check_ready\(\)[\s\S]*backend became ready after stale port cleanup[\s\S]*return Ok\(None\)/); + assert.match(libSource, /backend port remains occupied after cleanup; waiting once more before surfacing occupied-port error/); + assert.match(libSource, /backend became ready after occupied-port grace wait[\s\S]*return Ok\(None\)/); + assert.match(libSource, /local_address\.starts_with\("127\.0\.0\.1:"\)/); + assert.match(libSource, /local_address\.starts_with\("\[::1\]:"\)/); + assert.match(libSource, /non-local listener/); + assert.match(libSource, /fn is_managed_backend_command\(command_line: &str\) -> bool/); + assert.match(libSource, /cli\.js run/); + assert.match(libSource, /codexmate\.exe run/); + assert.match(libSource, /command_line=/); + assert.match(libSource, /taskkill[\s\S]*\/PID[\s\S]*\/F/); + assert.match(libSource, /kill[\s\S]*-9/); + assert.match(libSource, /backend port cleanup killing managed loopback listener/); + assert.match(libSource, /unmanaged listener on 127\.0\.0\.1:3737/); + assert.doesNotMatch(libSource, /ShellExecuteW/); + assert.doesNotMatch(libSource, /runas/); +}); + +test('desktop Windows package manifest requires administrator privileges', () => { + const buildSource = readSource('src-tauri/build.rs'); + const manifestSource = readSource('src-tauri/app.manifest'); + + assert.match(buildSource, /tauri_build::WindowsAttributes::new\(\)/); + assert.match(buildSource, /\.app_manifest\(include_str!\("app\.manifest"\)\)/); + assert.match(buildSource, /tauri_build::try_build\(attrs\)/); + assert.match(manifestSource, /assemblyIdentity[\s\S]*name="ai\.codexmate\.desktop"/); + assert.match(manifestSource, /requestedExecutionLevel\s+level="requireAdministrator"\s+uiAccess="false"/); + assert.match(manifestSource, /Microsoft\.Windows\.Common-Controls/); +}); + +test('desktop build workflow verifies the final Windows exe UAC manifest', () => { + const workflowSource = readSource('.github/workflows/desktop-build.yml'); + + assert.match(workflowSource, /Verify Windows app UAC manifest/); + assert.match(workflowSource, /codexmate-desktop\.exe/); + assert.match(workflowSource, /mt\.exe/); + assert.match(workflowSource, /Copy-Item \$manifest/); + assert.match(workflowSource, /codexmate-desktop\.manifest\.xml/); + assert.match(workflowSource, /src-tauri\/target\/release\/codexmate-desktop\.exe/); + assert.match(workflowSource, /requestedExecutionLevel\\s\+level=\"requireAdministrator\"/); +}); + +test('desktop startup surfaces occupied backend port guidance instead of waiting for readiness timeout', () => { + 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, /Windows 桌面版启动时会请求管理员权限/); + 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 cleanup and grace wait/); +}); + +test('desktop windows installer supports overwrite-style reinstall flow', () => { + const configSource = readSource('src-tauri/tauri.conf.json'); + const hookSource = readSource('src-tauri/windows/installer-hooks.nsh'); + + 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*"both"/); + assert.match(configSource, /"installerHooks"\s*:\s*"windows\/installer-hooks\.nsh"/); + assert.match(hookSource, /NSIS_HOOK_PREINSTALL/); + assert.match(hookSource, /taskkill \/IM "codexmate-desktop\.exe" \/F/); +}); diff --git a/tests/unit/desktop-stage.test.mjs b/tests/unit/desktop-stage.test.mjs new file mode 100644 index 00000000..68bb6efb --- /dev/null +++ b/tests/unit/desktop-stage.test.mjs @@ -0,0 +1,161 @@ +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' + ]); +}); 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 8549ba91..8637689c 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -62,8 +62,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();