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 c98f4d1d..0bb94044 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,99 @@ 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('Tag '+tag+' does not match package.json version '+expected); process.exit(1);} console.log('Tag 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: 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: | + 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' + cache: 'npm' - 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,10 +261,16 @@ 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: 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 @@ -202,4 +278,7 @@ jobs: files: | ${{ env.PACKAGE_TGZ }} ${{ env.STANDALONE_TGZ }} + desktop-release-assets/**/*.dmg + desktop-release-assets/**/*.msi + desktop-release-assets/**/*.exe generate_release_notes: true diff --git a/cli.js b/cli.js index e69eaaee..3ce92f93 100644 --- a/cli.js +++ b/cli.js @@ -162,7 +162,8 @@ const { extractSessionDetailPreviewFromTailText, extractSessionDetailPreviewFromFileFast } = require('./lib/cli-sessions'); -const { listSessionUsageCore } = require('./cli/session-usage'); +const { listSessionUsageCore, exportSessionUsageCore } = require('./cli/session-usage'); +const { parseAnalyticsExportArgs } = require('./cli/analytics-export-args'); const { readBundledWebUiCss, readBundledWebUiHtml, @@ -5204,6 +5205,12 @@ async function listSessionUsage(params = {}) { }); } +async function exportSessionUsage(params = {}) { + return exportSessionUsageCore(params, { + listSessionUsage + }); +} + function listSessionPaths(params = {}) { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { @@ -9796,6 +9803,47 @@ async function cmdExportSession(args = []) { console.log(); } +function printAnalyticsUsage() { + console.log('\n用法:'); + console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model ] [--source ] [--output ] [-o ]'); + console.log(''); +} + +async function cmdAnalytics(args = []) { + const subcommand = args[0]; + if (subcommand !== 'export') { + printAnalyticsUsage(); + process.exit(subcommand ? 1 : 0); + } + const parsed = parseAnalyticsExportArgs(args.slice(1)); + if (parsed.options.help) { + printAnalyticsUsage(); + process.exit(0); + } + if (parsed.error) { + console.error('错误:', parsed.error); + printAnalyticsUsage(); + process.exit(1); + } + + const result = await exportSessionUsage(parsed.options); + if (result && result.error) { + console.error('导出失败:', result.error); + process.exit(1); + } + const output = parsed.options.output || (result && result.fileName) || `usage-export.${parsed.options.format}`; + if (output === '-') { + process.stdout.write(result && result.content ? result.content : ''); + return; + } + const outputPath = path.resolve(process.cwd(), output); + ensureDir(path.dirname(outputPath)); + fs.writeFileSync(outputPath, result && result.content ? result.content : '', 'utf-8'); + console.log(`\n✓ Usage 已导出: ${outputPath}`); + console.log(` 格式: ${result.format}; rows: ${Array.isArray(result.rows) ? result.rows.length : 0}`); + console.log(); +} + function parseStartOptions(args = []) { const options = { host: '', noBrowser: false }; if (!Array.isArray(args)) { @@ -11077,6 +11125,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser } } break; + case 'export-sessions-usage': + { + const usageParams = isPlainObject(params) ? params : {}; + const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : ''; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + } else { + result = await exportSessionUsage({ + ...usageParams, + source: source || 'all' + }); + } + } + break; case 'list-session-paths': { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; @@ -15960,6 +16022,7 @@ function printMainHelp() { console.log(' codexmate delete-model <模型> 删除模型'); console.log(' codexmate workflow MCP 工作流中心'); console.log(' codexmate task 本地任务编排'); + console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model ] [--output ] [-o ] 导出 Usage 数据'); console.log(' codexmate run [--host ] [--no-browser] 启动 Web 界面'); console.log(' codexmate update [--check] 检查并快速更新工具'); console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo'); @@ -16052,6 +16115,7 @@ async function main() { case 'proxy': await cmdProxy(args.slice(1)); break; case 'workflow': await cmdWorkflow(args.slice(1)); break; case 'task': await cmdTask(args.slice(1)); break; + case 'analytics': await cmdAnalytics(args.slice(1)); break; case 'run': cmdStart(parseStartOptions(args.slice(1))); break; case 'update': await cmdToolUpdate(args.slice(1)); break; case 'start': diff --git a/cli/analytics-export-args.js b/cli/analytics-export-args.js new file mode 100644 index 00000000..f1057b59 --- /dev/null +++ b/cli/analytics-export-args.js @@ -0,0 +1,68 @@ +function parseAnalyticsExportArgs(args = []) { + const options = { + format: 'csv', + source: 'all', + output: '' + }; + const errors = []; + for (let index = 0; index < args.length; index += 1) { + const token = String(args[index] || ''); + const readValue = (flag) => { + if (token.startsWith(`${flag}=`)) { + return token.slice(flag.length + 1); + } + const value = args[index + 1]; + index += 1; + return value; + }; + if (token === '--format' || token.startsWith('--format=')) { + options.format = String(readValue('--format') || '').trim().toLowerCase(); + continue; + } + if (token === '--from' || token.startsWith('--from=')) { + options.from = String(readValue('--from') || '').trim(); + continue; + } + if (token === '--to' || token.startsWith('--to=')) { + options.to = String(readValue('--to') || '').trim(); + continue; + } + if (token === '--model' || token.startsWith('--model=')) { + options.model = String(readValue('--model') || '').trim(); + continue; + } + if (token === '--source' || token.startsWith('--source=')) { + options.source = String(readValue('--source') || '').trim().toLowerCase(); + continue; + } + if (token === '--output' || token === '-o' || token.startsWith('--output=')) { + options.output = String(readValue(token === '-o' ? '-o' : '--output') || '').trim(); + continue; + } + if (token === '--force-refresh') { + options.forceRefresh = true; + continue; + } + if (token === '--help' || token === '-h') { + options.help = true; + continue; + } + if (token) { + errors.push(`未知参数 ${token}`); + } + } + if (options.format !== 'csv' && options.format !== 'json') { + errors.push('--format 必须是 csv 或 json'); + } + if (options.source && !['codex', 'claude', 'gemini', 'codebuddy', 'all'].includes(options.source)) { + errors.push('--source 必须是 codex、claude、gemini、codebuddy 或 all'); + } + return { + options, + error: errors.join(';') + }; +} + +module.exports = { + parseAnalyticsExportArgs +}; diff --git a/cli/session-usage.js b/cli/session-usage.js index 7cd2055b..ab53091c 100644 --- a/cli/session-usage.js +++ b/cli/session-usage.js @@ -113,6 +113,192 @@ async function listSessionUsageCore(params = {}, deps = {}) { return normalizedSessions.filter(Boolean); } +function readNonNegativeInteger(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return Math.floor(numeric); +} + +function parseUsageExportDate(value, boundary) { + if (value === undefined || value === null || value === '') { + return null; + } + if (value instanceof Date) { + const time = value.getTime(); + return Number.isFinite(time) ? time : NaN; + } + const raw = String(value).trim(); + if (!raw) { + return null; + } + const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dateOnly) { + const year = Number(dateOnly[1]); + const month = Number(dateOnly[2]) - 1; + const day = Number(dateOnly[3]); + const start = Date.UTC(year, month, day); + const normalized = new Date(start); + if (!Number.isFinite(start) + || normalized.getUTCFullYear() !== year + || normalized.getUTCMonth() !== month + || normalized.getUTCDate() !== day) { + return NaN; + } + return boundary === 'end' ? start + 24 * 60 * 60 * 1000 : start; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : NaN; +} + +function formatUsageExportDay(timestamp) { + return new Date(timestamp).toISOString().slice(0, 10); +} + +function normalizeUsageExportFormat(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'json' ? 'json' : 'csv'; +} + +function normalizeUsageExportModelFilters(params = {}) { + const raw = []; + const push = (value) => { + if (Array.isArray(value)) { + value.forEach(push); + return; + } + if (typeof value !== 'string') { + return; + } + value.split(',').forEach((item) => { + const normalized = item.trim().toLowerCase(); + if (normalized) raw.push(normalized); + }); + }; + push(params.model); + push(params.models); + // API-facing alias: callers may pass modelType when they reuse usage filters + // outside the CLI flag surface. + push(params.modelType); + return [...new Set(raw)]; +} + +function sessionMatchesUsageExportModelFilters(session, filters) { + if (!filters.length) { + return true; + } + const models = []; + if (typeof session.model === 'string') models.push(session.model); + if (Array.isArray(session.models)) models.push(...session.models.filter(item => typeof item === 'string')); + const normalizedModels = models.map(item => item.trim().toLowerCase()).filter(Boolean); + return filters.some(filter => normalizedModels.some(model => model === filter || model.includes(filter))); +} + +function escapeUsageCsvCell(value) { + const raw = value === undefined || value === null ? '' : String(value); + if (!/[",\n\r]/.test(raw)) { + return raw; + } + return `"${raw.replace(/"/g, '""')}"`; +} + +function serializeUsageExportRowsToCsv(rows) { + const columns = ['date', 'model', 'tokens', 'sessions']; + const lines = [columns.join(',')]; + for (const row of rows) { + lines.push(columns.map(column => escapeUsageCsvCell(row[column])).join(',')); + } + return lines.join('\r\n') + '\r\n'; +} + +function buildUsageExportRows(sessions = [], params = {}) { + const fromTime = parseUsageExportDate(params.from ?? params.startDate, 'start'); + const toTime = parseUsageExportDate(params.to ?? params.endDate, 'end'); + if (Number.isNaN(fromTime)) { + return { error: 'Invalid from date' }; + } + if (Number.isNaN(toTime)) { + return { error: 'Invalid to date' }; + } + if (fromTime !== null && toTime !== null && fromTime >= toTime) { + return { error: 'from date must be before to date' }; + } + + const modelFilters = normalizeUsageExportModelFilters(params); + const groups = new Map(); + for (const session of Array.isArray(sessions) ? sessions : []) { + if (!session || typeof session !== 'object' || Array.isArray(session)) { + continue; + } + if (!sessionMatchesUsageExportModelFilters(session, modelFilters)) { + continue; + } + const timestamp = Date.parse(session.updatedAt || session.createdAt || ''); + if (!Number.isFinite(timestamp)) { + continue; + } + if (fromTime !== null && timestamp < fromTime) { + continue; + } + if (toTime !== null && timestamp >= toTime) { + continue; + } + const model = typeof session.model === 'string' && session.model.trim() + ? session.model.trim() + : (Array.isArray(session.models) && typeof session.models[0] === 'string' ? session.models[0].trim() : 'unknown'); + if (!model) { + continue; + } + const date = formatUsageExportDay(timestamp); + const key = `${date}\u0000${model}`; + const current = groups.get(key) || { date, model, tokens: 0, sessions: 0 }; + current.tokens += readNonNegativeInteger(session.totalTokens ?? session.tokens); + current.sessions += 1; + groups.set(key, current); + } + + const rows = [...groups.values()].sort((a, b) => { + const dateCompare = a.date.localeCompare(b.date); + if (dateCompare !== 0) return dateCompare; + return a.model.localeCompare(b.model); + }); + return { rows }; +} + +async function exportSessionUsageCore(params = {}, deps = {}) { + const listSessionUsage = typeof deps.listSessionUsage === 'function' + ? deps.listSessionUsage + : (options) => listSessionUsageCore(options, deps); + const sessions = Array.isArray(params.sessions) + ? params.sessions + : await listSessionUsage({ + source: params.source, + limit: params.limit, + forceRefresh: !!params.forceRefresh + }); + const built = buildUsageExportRows(sessions, params); + if (built.error) { + return { error: built.error }; + } + const format = normalizeUsageExportFormat(params.format); + const rows = built.rows; + const content = format === 'json' + ? JSON.stringify({ rows }, null, 2) + '\n' + : serializeUsageExportRowsToCsv(rows); + const extension = format === 'json' ? 'json' : 'csv'; + return { + format, + mimeType: format === 'json' ? 'application/json' : 'text/csv', + fileName: `usage-export.${extension}`, + rows, + content + }; +} + module.exports = { - listSessionUsageCore + listSessionUsageCore, + buildUsageExportRows, + exportSessionUsageCore, + serializeUsageExportRowsToCsv }; diff --git a/doc/desktop.md b/doc/desktop.md new file mode 100644 index 00000000..34a45686 --- /dev/null +++ b/doc/desktop.md @@ -0,0 +1,91 @@ +# 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 也会追加到同一个日志文件: + +```text +%LOCALAPPDATA%\CodexMate\logs\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 3aebd6ba..40c87e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,11 @@ "codexmate": "cli.js" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.2", "vitepress": "^1.6.4" }, "engines": { - "node": ">=14" + "node": ">=16.14.0" } }, "node_modules/@algolia/abtesting": { @@ -1238,6 +1239,223 @@ "dev": true, "license": "MIT" }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "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.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "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.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "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 1a55c2a4..f09b8116 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,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": { - "vitepress": "^1.6.4" + "vitepress": "^1.6.4", + "@tauri-apps/cli": "^2.11.2" } } 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..60eb5b36 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codexmate-desktop" +version = "0.0.37" +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..326fb299 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,638 @@ +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 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(300)) { + Ok(value) => value, + Err(_) => return false, + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + + 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(300)).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 +} + +#[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); + } + + release_stale_backend_port(); + + if backend_port_occupied() { + let message = backend_port_occupied_message(); + desktop_log(format!("backend port remains occupied after cleanup; {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 !wait_for_backend(Duration::from_secs(15)) { + let _ = child.kill(); + let _ = child.wait(); + desktop_log("backend killed after readiness timeout"); + return startup_error("Codex Mate 后端启动后没有及时就绪。请关闭旧的 Codex Mate / codexmate run 实例后重试;如果问题持续,请查看 startup.log。详情:codexmate backend did not become ready on 127.0.0.1:3737"); + } + + 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..860f79b0 --- /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.37", + "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/analytics-export-args.test.mjs b/tests/unit/analytics-export-args.test.mjs new file mode 100644 index 00000000..70e65b43 --- /dev/null +++ b/tests/unit/analytics-export-args.test.mjs @@ -0,0 +1,56 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); +const { parseAnalyticsExportArgs } = require(path.join(__dirname, '..', '..', 'cli', 'analytics-export-args.js')); + +test('parseAnalyticsExportArgs accepts equals-style values and output shorthand', () => { + const parsed = parseAnalyticsExportArgs([ + '--format=json', + '--from=2026-05-01', + '--to', '2026-05-06', + '--model', 'gpt-5.3', + '--source=codex', + '-o', '-', + '--force-refresh' + ]); + + assert.strictEqual(parsed.error, ''); + assert.deepStrictEqual(parsed.options, { + format: 'json', + source: 'codex', + output: '-', + from: '2026-05-01', + to: '2026-05-06', + model: 'gpt-5.3', + forceRefresh: true + }); +}); + +test('parseAnalyticsExportArgs accepts long output assignment and help flag', () => { + const parsed = parseAnalyticsExportArgs(['--output=usage.csv', '--help']); + + assert.strictEqual(parsed.error, ''); + assert.deepStrictEqual(parsed.options, { + format: 'csv', + source: 'all', + output: 'usage.csv', + help: true + }); +}); + +test('parseAnalyticsExportArgs reports unknown tokens and invalid choices', () => { + const parsed = parseAnalyticsExportArgs([ + '--format', 'xml', + '--source', 'openai', + '--surprise' + ]); + + assert.match(parsed.error, /未知参数 --surprise/); + assert.match(parsed.error, /--format 必须是 csv 或 json/); + assert.match(parsed.error, /--source 必须是 codex、claude、gemini、codebuddy 或 all/); +}); diff --git a/tests/unit/desktop-diagnostics-contract.test.mjs b/tests/unit/desktop-diagnostics-contract.test.mjs new file mode 100644 index 00000000..d76f6479 --- /dev/null +++ b/tests/unit/desktop-diagnostics-contract.test.mjs @@ -0,0 +1,137 @@ +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, /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 force-cleans managed backend port listeners before spawning', () => { + const libSource = readSource('src-tauri/src/lib.rs'); + + assert.match(libSource, /fn release_stale_backend_port\(\) -> usize/); + assert.match(libSource, /release_stale_backend_port\(\);[\s\S]*if backend_port_occupied\(\)/); + assert.doesNotMatch(libSource, /existing backend already ready/); + assert.doesNotMatch(libSource, /backend became ready after stale port cleanup/); + 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 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]*return startup_error\(message\)/); + assert.match(libSource, /backend port remains occupied after cleanup/); +}); + +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 31a7a19e..56e9bc1b 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -27,6 +27,7 @@ await import(pathToFileURL(path.join(__dirname, 'web-ui-source-bundle.test.mjs') await import(pathToFileURL(path.join(__dirname, 'startup-claude-star-prompt.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'install-methods.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'cli-help.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'analytics-export-args.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'cli-network-utils.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'config-health-module.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'openclaw-core.test.mjs'))); @@ -52,8 +53,11 @@ await import(pathToFileURL(path.join(__dirname, 'builtin-proxy-responses-shim.te await import(pathToFileURL(path.join(__dirname, 'claude-proxy-adapter.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.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/tests/unit/session-usage-backend.test.mjs b/tests/unit/session-usage-backend.test.mjs index f76d36d0..fb62fad7 100644 --- a/tests/unit/session-usage-backend.test.mjs +++ b/tests/unit/session-usage-backend.test.mjs @@ -251,6 +251,54 @@ test('listSessionUsage normalizes source and default limit for lightweight usage ]); }); +test('exportSessionUsageCore exports filtered usage rows as csv and json', async () => { + const sessions = [ + { model: 'gpt-5.3-codex', models: ['gpt-5.3-codex'], updatedAt: '2026-05-01T10:00:00.000Z', totalTokens: 120 }, + { model: 'gpt-5.3-codex', models: ['gpt-5.3-codex'], updatedAt: '2026-05-01T12:00:00.000Z', totalTokens: 30 }, + { model: 'claude-sonnet', models: ['claude-sonnet'], updatedAt: '2026-05-02T09:00:00.000Z', totalTokens: 400 }, + { model: 'gpt-5.2-codex', models: ['gpt-5.2-codex'], updatedAt: '2026-05-07T09:00:00.000Z', totalTokens: 999 } + ]; + + const csv = await usageCore.exportSessionUsageCore({ + sessions, + format: 'csv', + from: '2026-05-01', + to: '2026-05-06', + model: 'gpt-5.3' + }); + + assert.strictEqual(csv.format, 'csv'); + assert.strictEqual(csv.mimeType, 'text/csv'); + assert.deepStrictEqual(csv.rows, [ + { date: '2026-05-01', model: 'gpt-5.3-codex', tokens: 150, sessions: 2 } + ]); + assert.strictEqual(csv.content, 'date,model,tokens,sessions\r\n2026-05-01,gpt-5.3-codex,150,2\r\n'); + + const json = await usageCore.exportSessionUsageCore({ + sessions, + format: 'json', + from: '2026-05-01', + to: '2026-05-06' + }); + + assert.strictEqual(json.format, 'json'); + assert.deepStrictEqual(JSON.parse(json.content), { + rows: [ + { date: '2026-05-01', model: 'gpt-5.3-codex', tokens: 150, sessions: 2 }, + { date: '2026-05-02', model: 'claude-sonnet', tokens: 400, sessions: 1 } + ] + }); +}); + +test('exportSessionUsageCore handles empty data gracefully', async () => { + const csv = await usageCore.exportSessionUsageCore({ sessions: [], format: 'csv' }); + assert.strictEqual(csv.content, 'date,model,tokens,sessions\r\n'); + assert.deepStrictEqual(csv.rows, []); + + const json = await usageCore.exportSessionUsageCore({ sessions: [], format: 'json' }); + assert.deepStrictEqual(JSON.parse(json.content), { rows: [] }); +}); + test('listSessionUsage backfills missing model metadata from parsed session summaries', async () => { const codexParses = []; const claudeParses = []; 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();