diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index b608ea2116c..6e01f7b81c3 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -63,6 +63,7 @@ jobs: github-token: ${{ github.token }} - name: Validate release approval and target + id: release_approval env: APPROVAL_PATH: ${{ runner.temp }}/android-release-approval/approval.json DIRECT_RELEASE_RECOVERY: ${{ inputs.direct_release_recovery && 'true' || 'false' }} @@ -105,7 +106,7 @@ jobs: exit 1 fi - release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,assets,url)" + release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName,isDraft,isPrerelease,createdAt,assets,url)" if [[ "$(printf '%s' "${release_json}" | jq -r '.tagName')" != "${RELEASE_TAG}" ]]; then echo "GitHub release tag does not match ${RELEASE_TAG}." >&2 exit 1 @@ -118,6 +119,9 @@ jobs: echo "Normal Android promotion requires the target GitHub release to remain a draft." >&2 exit 1 fi + release_created_at="$(printf '%s' "${release_json}" | jq -er '.createdAt')" + build_timestamp="$(date -u -d "${release_created_at}" +%Y-%m-%dT%H:%M:%SZ)" + echo "build_timestamp=${build_timestamp}" >> "${GITHUB_OUTPUT}" unexpected_assets="$(printf '%s' "${release_json}" | jq -r '[.assets[]? | select(.name | startswith("OpenClaw-Android")) | .name] | join(", ")')" if [[ -n "${unexpected_assets}" ]]; then echo "Target release already contains Android assets: ${unexpected_assets}. Immutable recovery accepts them only after rebuilding identical bytes." >&2 @@ -247,7 +251,9 @@ jobs: - name: Prepare and verify signed Android APK env: + GIT_COMMIT: ${{ inputs.release_target_sha }} GH_TOKEN: ${{ github.token }} + OPENCLAW_BUILD_TIMESTAMP: ${{ steps.release_approval.outputs.build_timestamp }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 7e145a05cc4..0c6a4349bca 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -87,12 +87,36 @@ jobs: fi echo "Docker Hub publishing configured for ${DOCKERHUB_IMAGE}." + resolve_build_provenance: + needs: [approve_manual_backfill, validate_publish_config] + if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + built_at: ${{ steps.build_provenance.outputs.built_at }} + source_sha: ${{ steps.build_provenance.outputs.source_sha }} + steps: + - name: Checkout selected source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.sha }} + fetch-depth: 0 + + - name: Resolve shared build provenance + id: build_provenance + shell: bash + run: | + set -euo pipefail + echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. # Build amd64 image. Default and slim tags point to the same slim runtime. build-amd64: - needs: [approve_manual_backfill, validate_publish_config] - if: ${{ always() && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} + needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance] + if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. runs-on: ubuntu-24.04 permissions: @@ -105,7 +129,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + ref: ${{ needs.resolve_build_provenance.outputs.source_sha }} fetch-depth: 0 - &buildkit_prepull_step @@ -189,10 +213,12 @@ jobs: id: labels shell: bash env: + BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }} + SOURCE_SHA: ${{ needs.resolve_build_provenance.outputs.source_sha }} SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail - source_sha="$(git rev-parse HEAD)" + source_sha="${SOURCE_SHA}" version="${source_sha}" if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" @@ -200,7 +226,7 @@ jobs: if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" fi - created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + created="${BUILD_TIMESTAMP}" { echo "value</dev/null | grep -q . || \ (echo "ERROR: matrix-sdk-crypto native addon missing after retries" >&2 && exit 1) +# Public source provenance supplied by release automation or local setup. Keep +# these after the dependency layer so a new timestamp does not invalidate install. +ARG GIT_COMMIT="" +ARG OPENCLAW_BUILD_TIMESTAMP="" +ENV GIT_COMMIT=${GIT_COMMIT} \ + OPENCLAW_BUILD_TIMESTAMP=${OPENCLAW_BUILD_TIMESTAMP} + COPY . . # Normalize extension paths now so runtime COPY preserves safe modes @@ -122,13 +129,18 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \ echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ - export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \ - fi && \ - OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 -RUN pnpm_config_verify_deps_before_run=false pnpm ui:build +RUN set -eu; \ + if [ -z "$OPENCLAW_BUILD_TIMESTAMP" ]; then \ + OPENCLAW_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \ + export OPENCLAW_BUILD_TIMESTAMP; \ + fi; \ + if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ + export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \ + fi; \ + OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker; \ + pnpm_config_verify_deps_before_run=false pnpm ui:build RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \ mkdir -p dist/extensions/qa-lab/web && \ diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index cc0da61335f..cbb87e04cb2 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -3115,7 +3115,7 @@ }, { "kind": "ui-named-argument", - "line": 236, + "line": 237, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -3123,7 +3123,7 @@ }, { "kind": "ui-named-argument", - "line": 236, + "line": 237, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -3131,7 +3131,7 @@ }, { "kind": "ui-named-argument", - "line": 256, + "line": 257, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -3139,7 +3139,7 @@ }, { "kind": "ui-named-argument", - "line": 261, + "line": 262, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -3147,7 +3147,7 @@ }, { "kind": "ui-named-argument", - "line": 262, + "line": 263, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -3155,7 +3155,7 @@ }, { "kind": "ui-named-argument", - "line": 297, + "line": 298, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -3163,7 +3163,7 @@ }, { "kind": "ui-named-argument", - "line": 297, + "line": 298, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -3171,7 +3171,7 @@ }, { "kind": "ui-named-argument", - "line": 308, + "line": 309, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", "surface": "android", @@ -3179,7 +3179,7 @@ }, { "kind": "ui-named-argument", - "line": 318, + "line": 319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -3187,7 +3187,7 @@ }, { "kind": "ui-named-argument", - "line": 323, + "line": 324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -3195,7 +3195,7 @@ }, { "kind": "ui-named-argument", - "line": 324, + "line": 325, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled work created on the gateway will appear here.", "surface": "android", @@ -3203,7 +3203,7 @@ }, { "kind": "ui-named-argument", - "line": 425, + "line": 426, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -3211,7 +3211,7 @@ }, { "kind": "ui-named-argument", - "line": 446, + "line": 447, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -3219,7 +3219,7 @@ }, { "kind": "conditional-branch", - "line": 454, + "line": 455, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -3227,7 +3227,7 @@ }, { "kind": "conditional-branch", - "line": 454, + "line": 455, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -3235,7 +3235,7 @@ }, { "kind": "ui-named-argument", - "line": 505, + "line": 506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -3243,7 +3243,7 @@ }, { "kind": "ui-named-argument", - "line": 505, + "line": 506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -3251,7 +3251,7 @@ }, { "kind": "ui-named-argument", - "line": 516, + "line": 517, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -3259,7 +3259,7 @@ }, { "kind": "ui-named-argument", - "line": 520, + "line": 521, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -3267,7 +3267,7 @@ }, { "kind": "ui-named-argument", - "line": 546, + "line": 547, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -3275,7 +3275,7 @@ }, { "kind": "ui-named-argument", - "line": 546, + "line": 547, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -3283,7 +3283,7 @@ }, { "kind": "conditional-branch", - "line": 557, + "line": 558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -3291,7 +3291,7 @@ }, { "kind": "conditional-branch", - "line": 557, + "line": 558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -3299,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 570, + "line": 571, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -3307,7 +3307,7 @@ }, { "kind": "ui-named-argument", - "line": 571, + "line": 572, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -3315,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 577, + "line": 578, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -3323,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 578, + "line": 579, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -3331,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 585, + "line": 586, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -3339,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 586, + "line": 587, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -3347,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 600, + "line": 601, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -3355,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 600, + "line": 601, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -3363,7 +3363,7 @@ }, { "kind": "ui-named-argument", - "line": 603, + "line": 604, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -3371,7 +3371,7 @@ }, { "kind": "ui-named-argument", - "line": 604, + "line": 605, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -3379,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 623, + "line": 624, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -3387,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 623, + "line": 624, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -3395,7 +3395,7 @@ }, { "kind": "ui-named-argument", - "line": 626, + "line": 627, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -3403,7 +3403,7 @@ }, { "kind": "ui-named-argument", - "line": 627, + "line": 628, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -3411,7 +3411,7 @@ }, { "kind": "conditional-branch", - "line": 630, + "line": 631, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -3419,7 +3419,7 @@ }, { "kind": "conditional-branch", - "line": 630, + "line": 631, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -3427,7 +3427,7 @@ }, { "kind": "conditional-branch", - "line": 631, + "line": 632, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -3435,7 +3435,7 @@ }, { "kind": "conditional-branch", - "line": 631, + "line": 632, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -3443,7 +3443,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 634, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -3451,7 +3451,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 634, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -3459,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 637, + "line": 638, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -3467,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 647, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -3475,7 +3475,7 @@ }, { "kind": "ui-named-argument", - "line": 648, + "line": 649, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -3483,7 +3483,7 @@ }, { "kind": "conditional-branch", - "line": 772, + "line": 773, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -3491,7 +3491,7 @@ }, { "kind": "conditional-branch", - "line": 772, + "line": 773, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -3499,7 +3499,7 @@ }, { "kind": "ui-named-argument", - "line": 805, + "line": 806, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -3507,7 +3507,7 @@ }, { "kind": "ui-named-argument", - "line": 805, + "line": 806, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -3515,7 +3515,7 @@ }, { "kind": "conditional-branch", - "line": 809, + "line": 810, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -3523,7 +3523,7 @@ }, { "kind": "conditional-branch", - "line": 809, + "line": 810, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3531,7 +3531,7 @@ }, { "kind": "conditional-branch", - "line": 821, + "line": 822, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3539,7 +3539,7 @@ }, { "kind": "conditional-branch", - "line": 821, + "line": 822, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3547,7 +3547,7 @@ }, { "kind": "conditional-branch", - "line": 826, + "line": 827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3555,7 +3555,7 @@ }, { "kind": "conditional-branch", - "line": 826, + "line": 827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3563,7 +3563,7 @@ }, { "kind": "ui-named-argument", - "line": 836, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3571,7 +3571,7 @@ }, { "kind": "ui-named-argument", - "line": 885, + "line": 886, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3579,7 +3579,7 @@ }, { "kind": "conditional-branch", - "line": 892, + "line": 893, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3587,7 +3587,7 @@ }, { "kind": "conditional-branch", - "line": 892, + "line": 893, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3595,7 +3595,7 @@ }, { "kind": "ui-named-argument", - "line": 897, + "line": 898, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3603,7 +3603,7 @@ }, { "kind": "ui-named-argument", - "line": 900, + "line": 901, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3611,7 +3611,7 @@ }, { "kind": "ui-named-argument", - "line": 901, + "line": 902, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3619,7 +3619,7 @@ }, { "kind": "ui-named-argument", - "line": 908, + "line": 909, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3627,7 +3627,7 @@ }, { "kind": "ui-named-argument", - "line": 919, + "line": 920, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3635,7 +3635,7 @@ }, { "kind": "ui-named-argument", - "line": 1158, + "line": 1159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3643,7 +3643,7 @@ }, { "kind": "ui-named-argument", - "line": 1158, + "line": 1159, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3651,7 +3651,7 @@ }, { "kind": "conditional-branch", - "line": 1167, + "line": 1168, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3659,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 1167, + "line": 1168, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3667,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 1177, + "line": 1178, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3675,7 +3675,7 @@ }, { "kind": "conditional-branch", - "line": 1177, + "line": 1178, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3683,7 +3683,7 @@ }, { "kind": "ui-named-argument", - "line": 1188, + "line": 1189, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3691,7 +3691,7 @@ }, { "kind": "ui-named-argument", - "line": 1196, + "line": 1197, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3699,7 +3699,7 @@ }, { "kind": "ui-call", - "line": 1221, + "line": 1222, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3707,7 +3707,7 @@ }, { "kind": "ui-call", - "line": 1223, + "line": 1224, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3715,7 +3715,7 @@ }, { "kind": "ui-call", - "line": 1236, + "line": 1237, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3723,7 +3723,7 @@ }, { "kind": "ui-call", - "line": 1241, + "line": 1242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3731,7 +3731,7 @@ }, { "kind": "ui-call", - "line": 1289, + "line": 1290, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3739,7 +3739,7 @@ }, { "kind": "ui-call", - "line": 1304, + "line": 1305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3747,7 +3747,7 @@ }, { "kind": "ui-call", - "line": 1320, + "line": 1321, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3755,7 +3755,7 @@ }, { "kind": "ui-call", - "line": 1333, + "line": 1334, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3763,7 +3763,7 @@ }, { "kind": "ui-named-argument", - "line": 1339, + "line": 1340, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3771,7 +3771,7 @@ }, { "kind": "conditional-branch", - "line": 1343, + "line": 1344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3779,7 +3779,7 @@ }, { "kind": "conditional-branch", - "line": 1343, + "line": 1344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3787,7 +3787,7 @@ }, { "kind": "conditional-branch", - "line": 1344, + "line": 1345, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3795,7 +3795,7 @@ }, { "kind": "conditional-branch", - "line": 1344, + "line": 1345, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3803,7 +3803,7 @@ }, { "kind": "ui-named-argument", - "line": 1354, + "line": 1355, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3811,7 +3811,7 @@ }, { "kind": "ui-named-argument", - "line": 1355, + "line": 1356, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3819,7 +3819,7 @@ }, { "kind": "ui-named-argument", - "line": 1359, + "line": 1360, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3827,7 +3827,7 @@ }, { "kind": "ui-named-argument", - "line": 1361, + "line": 1362, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3835,7 +3835,7 @@ }, { "kind": "ui-call", - "line": 1381, + "line": 1382, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3843,7 +3843,7 @@ }, { "kind": "ui-named-argument", - "line": 1403, + "line": 1404, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3851,7 +3851,7 @@ }, { "kind": "ui-named-argument", - "line": 1405, + "line": 1406, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3859,7 +3859,7 @@ }, { "kind": "ui-named-argument", - "line": 1412, + "line": 1413, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3867,7 +3867,7 @@ }, { "kind": "ui-named-argument", - "line": 1413, + "line": 1414, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3875,7 +3875,7 @@ }, { "kind": "ui-named-argument", - "line": 1417, + "line": 1418, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3883,7 +3883,7 @@ }, { "kind": "ui-named-argument", - "line": 1426, + "line": 1427, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3891,7 +3891,7 @@ }, { "kind": "ui-named-argument", - "line": 1427, + "line": 1428, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3899,7 +3899,7 @@ }, { "kind": "ui-named-argument", - "line": 1429, + "line": 1430, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3907,7 +3907,7 @@ }, { "kind": "ui-named-argument", - "line": 1430, + "line": 1431, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3915,7 +3915,7 @@ }, { "kind": "ui-named-argument", - "line": 1432, + "line": 1433, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3923,7 +3923,7 @@ }, { "kind": "conditional-branch", - "line": 1436, + "line": 1437, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3931,7 +3931,7 @@ }, { "kind": "conditional-branch", - "line": 1436, + "line": 1437, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3939,7 +3939,7 @@ }, { "kind": "ui-named-argument", - "line": 1453, + "line": 1454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3947,7 +3947,7 @@ }, { "kind": "ui-named-argument", - "line": 1454, + "line": 1455, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3955,7 +3955,7 @@ }, { "kind": "ui-named-argument", - "line": 1456, + "line": 1457, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3963,7 +3963,7 @@ }, { "kind": "ui-named-argument", - "line": 1461, + "line": 1462, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -3971,7 +3971,7 @@ }, { "kind": "ui-named-argument", - "line": 1478, + "line": 1479, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -3979,7 +3979,7 @@ }, { "kind": "ui-named-argument", - "line": 1504, + "line": 1505, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -3987,7 +3987,7 @@ }, { "kind": "ui-named-argument", - "line": 1504, + "line": 1505, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -3995,7 +3995,7 @@ }, { "kind": "ui-named-argument", - "line": 1516, + "line": 1517, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -4003,7 +4003,7 @@ }, { "kind": "ui-named-argument", - "line": 1526, + "line": 1527, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -4011,7 +4011,7 @@ }, { "kind": "ui-named-argument", - "line": 1528, + "line": 1529, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.", "surface": "android", @@ -4019,7 +4019,7 @@ }, { "kind": "ui-named-argument", - "line": 1565, + "line": 1566, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -4027,7 +4027,7 @@ }, { "kind": "conditional-branch", - "line": 1611, + "line": 1612, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -4035,7 +4035,7 @@ }, { "kind": "ui-named-argument", - "line": 1640, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -4043,7 +4043,7 @@ }, { "kind": "ui-named-argument", - "line": 1640, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -4051,7 +4051,7 @@ }, { "kind": "ui-named-argument", - "line": 1653, + "line": 1660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -4059,7 +4059,7 @@ }, { "kind": "ui-named-argument", - "line": 1655, + "line": 1662, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -4067,7 +4067,7 @@ }, { "kind": "ui-named-argument", - "line": 1658, + "line": 1665, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -4075,7 +4075,7 @@ }, { "kind": "ui-named-argument", - "line": 1669, + "line": 1676, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -4083,7 +4083,7 @@ }, { "kind": "ui-named-argument", - "line": 1686, + "line": 1693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -4091,7 +4091,7 @@ }, { "kind": "ui-named-argument", - "line": 1688, + "line": 1695, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -4099,7 +4099,7 @@ }, { "kind": "ui-named-argument", - "line": 1689, + "line": 1696, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -4107,7 +4107,7 @@ }, { "kind": "ui-named-argument", - "line": 1748, + "line": 1755, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -4115,7 +4115,7 @@ }, { "kind": "conditional-branch", - "line": 1749, + "line": 1756, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -4123,7 +4123,7 @@ }, { "kind": "ui-named-argument", - "line": 1758, + "line": 1765, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -4131,7 +4131,7 @@ }, { "kind": "ui-named-argument", - "line": 1784, + "line": 1791, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -4139,7 +4139,7 @@ }, { "kind": "conditional-branch", - "line": 1819, + "line": 1826, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -4147,7 +4147,7 @@ }, { "kind": "ui-named-argument", - "line": 1852, + "line": 1859, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -4155,7 +4155,7 @@ }, { "kind": "conditional-branch", - "line": 1926, + "line": 1933, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -4163,7 +4163,7 @@ }, { "kind": "conditional-branch", - "line": 1935, + "line": 1942, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -4171,7 +4171,7 @@ }, { "kind": "conditional-branch", - "line": 1935, + "line": 1942, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowing", "surface": "android", @@ -4179,7 +4179,7 @@ }, { "kind": "conditional-branch", - "line": 1943, + "line": 1950, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -4187,7 +4187,7 @@ }, { "kind": "conditional-branch", - "line": 1943, + "line": 1950, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Saving", "surface": "android", @@ -4195,7 +4195,7 @@ }, { "kind": "conditional-branch", - "line": 1951, + "line": 1958, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -4203,7 +4203,7 @@ }, { "kind": "conditional-branch", - "line": 1951, + "line": 1958, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Denying", "surface": "android", @@ -4211,7 +4211,7 @@ }, { "kind": "conditional-branch", - "line": 1976, + "line": 1983, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -4219,7 +4219,7 @@ }, { "kind": "conditional-branch", - "line": 2004, + "line": 2011, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -4227,7 +4227,7 @@ }, { "kind": "conditional-branch", - "line": 2059, + "line": 2066, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -4235,7 +4235,7 @@ }, { "kind": "conditional-branch", - "line": 2073, + "line": 2080, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -4243,7 +4243,7 @@ }, { "kind": "conditional-branch", - "line": 2073, + "line": 2080, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -4251,7 +4251,7 @@ }, { "kind": "ui-named-argument", - "line": 2091, + "line": 2098, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -4259,7 +4259,7 @@ }, { "kind": "ui-named-argument", - "line": 2094, + "line": 2101, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -4267,7 +4267,7 @@ }, { "kind": "ui-named-argument", - "line": 2133, + "line": 2140, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -4275,7 +4275,7 @@ }, { "kind": "ui-toast", - "line": 2148, + "line": 2155, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -4283,7 +4283,7 @@ }, { "kind": "conditional-branch", - "line": 2186, + "line": 2193, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -4291,7 +4291,7 @@ }, { "kind": "conditional-branch", - "line": 2188, + "line": 2195, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -4299,7 +4299,7 @@ }, { "kind": "conditional-branch", - "line": 2244, + "line": 2251, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -4307,7 +4307,7 @@ }, { "kind": "conditional-branch", - "line": 2247, + "line": 2254, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -4315,7 +4315,7 @@ }, { "kind": "conditional-branch", - "line": 2247, + "line": 2254, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -4323,7 +4323,7 @@ }, { "kind": "conditional-branch", - "line": 2275, + "line": 2282, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -4331,7 +4331,7 @@ }, { "kind": "conditional-branch", - "line": 2282, + "line": 2289, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -4339,7 +4339,7 @@ }, { "kind": "conditional-branch", - "line": 2313, + "line": 2320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -4347,7 +4347,7 @@ }, { "kind": "conditional-branch", - "line": 2334, + "line": 2341, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -4355,7 +4355,7 @@ }, { "kind": "conditional-branch", - "line": 2335, + "line": 2342, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -4363,7 +4363,7 @@ }, { "kind": "conditional-branch", - "line": 2336, + "line": 2343, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -4371,7 +4371,7 @@ }, { "kind": "conditional-branch", - "line": 2337, + "line": 2344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -6537,6 +6537,118 @@ "surface": "android", "id": "native.android.6ad2d3f084142bed" }, + { + "kind": "resource-string", + "line": 9, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Unknown", + "surface": "android", + "id": "native.android.f1252c0c03619337" + }, + { + "kind": "resource-string", + "line": 10, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "VERSION", + "surface": "android", + "id": "native.android.c0b5650d8639911c" + }, + { + "kind": "resource-string", + "line": 11, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "COMMIT", + "surface": "android", + "id": "native.android.d6f7fd58d1959e6f" + }, + { + "kind": "resource-string", + "line": 12, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "BUILT", + "surface": "android", + "id": "native.android.b28b492b48e83ad3" + }, + { + "kind": "resource-string", + "line": 13, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Version %1$s", + "surface": "android", + "id": "native.android.115aa71eb2a66fb6" + }, + { + "kind": "resource-string", + "line": 14, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Git commit %1$s", + "surface": "android", + "id": "native.android.eeddd6984c0eb974" + }, + { + "kind": "resource-string", + "line": 15, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Built %1$s UTC, timestamp %2$s", + "surface": "android", + "id": "native.android.a253a9c122cc5871" + }, + { + "kind": "resource-string", + "line": 16, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Build date %1$s", + "surface": "android", + "id": "native.android.e2d234b2ea956a77" + }, + { + "kind": "resource-string", + "line": 17, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Copy full Git commit hash", + "surface": "android", + "id": "native.android.eaf3c88646e35cab" + }, + { + "kind": "resource-string", + "line": 18, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Copy full build timestamp", + "surface": "android", + "id": "native.android.d869a719216054b8" + }, + { + "kind": "resource-string", + "line": 19, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "OpenClaw Git commit", + "surface": "android", + "id": "native.android.e3661fcea979a74c" + }, + { + "kind": "resource-string", + "line": 20, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "OpenClaw build timestamp", + "surface": "android", + "id": "native.android.a610564c863cdef7" + }, + { + "kind": "resource-string", + "line": 21, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Git commit copied", + "surface": "android", + "id": "native.android.9b9a42b4f56bea2a" + }, + { + "kind": "resource-string", + "line": 22, + "path": "apps/android/app/src/main/res/values/strings.xml", + "source": "Build timestamp copied", + "surface": "android", + "id": "native.android.6a0c0b0dc97333fe" + }, { "kind": "plist-string", "line": 8, @@ -10635,15 +10747,7 @@ }, { "kind": "ui-call", - "line": 710, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "OpenClaw app version", - "surface": "apple", - "id": "native.apple.35714980723b430d" - }, - { - "kind": "ui-call", - "line": 712, + "line": 713, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "iOS", "surface": "apple", @@ -10651,7 +10755,7 @@ }, { "kind": "ui-named-argument", - "line": 717, + "line": 718, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Website", "surface": "apple", @@ -10659,7 +10763,7 @@ }, { "kind": "ui-named-argument", - "line": 722, + "line": 723, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Docs", "surface": "apple", @@ -10667,7 +10771,7 @@ }, { "kind": "ui-named-argument", - "line": 727, + "line": 728, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "GitHub", "surface": "apple", @@ -10675,7 +10779,7 @@ }, { "kind": "ui-named-argument", - "line": 732, + "line": 733, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discord", "surface": "apple", @@ -10683,7 +10787,7 @@ }, { "kind": "ui-call", - "line": 737, + "line": 738, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -10691,7 +10795,7 @@ }, { "kind": "ui-call", - "line": 743, + "line": 744, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Title", "surface": "apple", @@ -10699,7 +10803,7 @@ }, { "kind": "ui-call", - "line": 778, + "line": 779, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location", "surface": "apple", @@ -10707,7 +10811,7 @@ }, { "kind": "ui-modifier", - "line": 796, + "line": 797, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location Sharing", "surface": "apple", @@ -10715,7 +10819,7 @@ }, { "kind": "ui-call", - "line": 807, + "line": 808, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Access Level", "surface": "apple", @@ -10723,7 +10827,7 @@ }, { "kind": "ui-modifier", - "line": 829, + "line": 830, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Chooses While Using the App or Always", "surface": "apple", @@ -10731,7 +10835,7 @@ }, { "kind": "ui-call", - "line": 849, + "line": 850, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Agent", "surface": "apple", @@ -10739,7 +10843,7 @@ }, { "kind": "ui-call", - "line": 850, + "line": 851, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default", "surface": "apple", @@ -10747,7 +10851,7 @@ }, { "kind": "ui-call", - "line": 860, + "line": 861, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Used for new Chat and Talk sessions.", "surface": "apple", @@ -10755,7 +10859,7 @@ }, { "kind": "ui-call", - "line": 867, + "line": 868, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paste setup code", "surface": "apple", @@ -10763,7 +10867,7 @@ }, { "kind": "ui-named-argument", - "line": 873, + "line": 874, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Scan QR", "surface": "apple", @@ -10771,7 +10875,7 @@ }, { "kind": "ui-named-argument", - "line": 883, + "line": 884, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect", "surface": "apple", @@ -10779,7 +10883,7 @@ }, { "kind": "ui-call", - "line": 892, + "line": 893, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Setup Code", "surface": "apple", @@ -10787,7 +10891,7 @@ }, { "kind": "ui-call", - "line": 905, + "line": 906, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered Gateways", "surface": "apple", @@ -10795,7 +10899,7 @@ }, { "kind": "ui-call", - "line": 907, + "line": 908, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateways found yet. Use manual setup if Bonjour is blocked.", "surface": "apple", @@ -10803,7 +10907,7 @@ }, { "kind": "ui-call", - "line": 921, + "line": 922, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Pair a gateway to make it available here.", "surface": "apple", @@ -10811,7 +10915,7 @@ }, { "kind": "ui-call", - "line": 930, + "line": 931, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paired Gateways", "surface": "apple", @@ -10819,7 +10923,7 @@ }, { "kind": "ui-call", - "line": 933, + "line": 934, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch gateways without pairing again.", "surface": "apple", @@ -10827,7 +10931,7 @@ }, { "kind": "ui-modifier", - "line": 961, + "line": 962, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Gateway", "surface": "apple", @@ -10835,7 +10939,7 @@ }, { "kind": "ui-call", - "line": 973, + "line": 974, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget", "surface": "apple", @@ -10843,7 +10947,7 @@ }, { "kind": "ui-call", - "line": 985, + "line": 986, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget Gateway", "surface": "apple", @@ -10851,7 +10955,7 @@ }, { "kind": "ui-call", - "line": 1038, + "line": 1039, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -10859,7 +10963,7 @@ }, { "kind": "ui-call", - "line": 1039, + "line": 1040, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -10867,7 +10971,7 @@ }, { "kind": "ui-call", - "line": 1040, + "line": 1041, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -10875,7 +10979,7 @@ }, { "kind": "ui-call", - "line": 1044, + "line": 1045, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -10883,7 +10987,7 @@ }, { "kind": "ui-call", - "line": 1047, + "line": 1048, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connection security", "surface": "apple", @@ -10891,7 +10995,7 @@ }, { "kind": "ui-call", - "line": 1048, + "line": 1049, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unencrypted", "surface": "apple", @@ -10899,7 +11003,7 @@ }, { "kind": "ui-call", - "line": 1051, + "line": 1052, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Secure (TLS)", "surface": "apple", @@ -10907,7 +11011,7 @@ }, { "kind": "ui-named-argument", - "line": 1063, + "line": 1064, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -10915,7 +11019,7 @@ }, { "kind": "ui-call", - "line": 1093, + "line": 1094, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -10923,7 +11027,7 @@ }, { "kind": "ui-call", - "line": 1094, + "line": 1095, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -10931,7 +11035,7 @@ }, { "kind": "ui-call", - "line": 1095, + "line": 1096, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -10939,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 1100, + "line": 1101, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -10947,7 +11051,7 @@ }, { "kind": "ui-call", - "line": 1107, + "line": 1108, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -10955,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 1134, + "line": 1135, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -10963,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 1137, + "line": 1138, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -10971,7 +11075,7 @@ }, { "kind": "ui-call", - "line": 1145, + "line": 1146, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -10979,7 +11083,7 @@ }, { "kind": "ui-call", - "line": 1152, + "line": 1153, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -10987,7 +11091,7 @@ }, { "kind": "ui-call", - "line": 1156, + "line": 1157, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -10995,7 +11099,7 @@ }, { "kind": "ui-call", - "line": 1177, + "line": 1178, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -11003,7 +11107,7 @@ }, { "kind": "ui-call", - "line": 1178, + "line": 1179, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -11011,7 +11115,7 @@ }, { "kind": "ui-call", - "line": 1185, + "line": 1186, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -11019,7 +11123,7 @@ }, { "kind": "ui-call", - "line": 1186, + "line": 1187, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -11027,7 +11131,7 @@ }, { "kind": "ui-call", - "line": 1193, + "line": 1194, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -11035,7 +11139,7 @@ }, { "kind": "ui-call", - "line": 1194, + "line": 1195, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -11043,7 +11147,7 @@ }, { "kind": "ui-call", - "line": 1196, + "line": 1197, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -11051,7 +11155,7 @@ }, { "kind": "ui-call", - "line": 1198, + "line": 1199, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -11059,7 +11163,7 @@ }, { "kind": "ui-call", - "line": 1199, + "line": 1200, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -11067,7 +11171,7 @@ }, { "kind": "ui-call", - "line": 1206, + "line": 1207, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -11075,7 +11179,7 @@ }, { "kind": "ui-call", - "line": 1207, + "line": 1208, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -11083,7 +11187,7 @@ }, { "kind": "ui-call", - "line": 1214, + "line": 1215, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -11091,7 +11195,7 @@ }, { "kind": "ui-call", - "line": 1231, + "line": 1232, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -11099,7 +11203,7 @@ }, { "kind": "ui-call", - "line": 1234, + "line": 1235, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -11107,7 +11211,7 @@ }, { "kind": "ui-call", - "line": 1238, + "line": 1239, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -11115,7 +11219,7 @@ }, { "kind": "ui-call", - "line": 1244, + "line": 1245, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -11123,7 +11227,7 @@ }, { "kind": "ui-call", - "line": 1245, + "line": 1246, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -11131,7 +11235,7 @@ }, { "kind": "ui-call", - "line": 1247, + "line": 1248, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -11139,7 +11243,7 @@ }, { "kind": "conditional-branch", - "line": 1270, + "line": 1271, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Off", "surface": "apple", @@ -11147,15 +11251,87 @@ }, { "kind": "conditional-branch", - "line": 1270, + "line": 1271, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", "id": "native.apple.0fca0d66e1fc4fa0" }, + { + "kind": "ui-call", + "line": 95, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Copy full commit hash", + "surface": "apple", + "id": "native.apple.2834f131e112b1b4" + }, + { + "kind": "ui-call", + "line": 99, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Copy build info", + "surface": "apple", + "id": "native.apple.e93e23f03719e358" + }, + { + "kind": "ui-call", + "line": 109, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Copy Commit", + "surface": "apple", + "id": "native.apple.34d0503d7d5ed23d" + }, + { + "kind": "ui-call", + "line": 120, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Copy Build Info", + "surface": "apple", + "id": "native.apple.9be4a644273089ce" + }, + { + "kind": "ui-call", + "line": 161, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Unavailable", + "surface": "apple", + "id": "native.apple.50991f9fae2ab99b" + }, + { + "kind": "ui-call", + "line": 179, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Version \\(version), commit \\(commit), built \\(built), timestamp \\(timestamp)", + "surface": "apple", + "id": "native.apple.b10bfeade7e6dcc4" + }, + { + "kind": "ui-call", + "line": 182, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Version \\(version), commit \\(commit), build date unavailable", + "surface": "apple", + "id": "native.apple.32c68e6864c7faa5" + }, + { + "kind": "ui-call", + "line": 185, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Version \\(version), commit unavailable, built \\(built), timestamp \\(timestamp)", + "surface": "apple", + "id": "native.apple.664c866505040add" + }, + { + "kind": "ui-call", + "line": 187, + "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", + "source": "Version \\(version), commit unavailable, build date unavailable", + "surface": "apple", + "id": "native.apple.29192c8dd3ef0f5e" + }, { "kind": "conditional-branch", - "line": 132, + "line": 282, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking", "surface": "apple", @@ -11163,7 +11339,7 @@ }, { "kind": "conditional-branch", - "line": 133, + "line": 283, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Enabled", "surface": "apple", @@ -11171,7 +11347,7 @@ }, { "kind": "conditional-branch", - "line": 134, + "line": 284, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Off", "surface": "apple", @@ -11179,7 +11355,7 @@ }, { "kind": "conditional-branch", - "line": 135, + "line": 285, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Setup", "surface": "apple", @@ -11187,7 +11363,7 @@ }, { "kind": "conditional-branch", - "line": 136, + "line": 286, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Denied", "surface": "apple", @@ -11195,7 +11371,7 @@ }, { "kind": "conditional-branch", - "line": 137, + "line": 287, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Not Enabled", "surface": "apple", @@ -11203,7 +11379,7 @@ }, { "kind": "conditional-branch", - "line": 138, + "line": 288, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Unknown", "surface": "apple", @@ -11211,7 +11387,7 @@ }, { "kind": "conditional-branch", - "line": 144, + "line": 294, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking iOS notification permission.", "surface": "apple", @@ -11219,7 +11395,7 @@ }, { "kind": "conditional-branch", - "line": 146, + "line": 296, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw can show approval prompts and event alerts when the app is not active.", "surface": "apple", @@ -11227,7 +11403,7 @@ }, { "kind": "conditional-branch", - "line": 148, + "line": 298, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw notifications are off.", "surface": "apple", @@ -11235,7 +11411,7 @@ }, { "kind": "conditional-branch", - "line": 150, + "line": 300, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Finish notification setup to receive alerts when the app is not active.", "surface": "apple", @@ -11243,7 +11419,7 @@ }, { "kind": "conditional-branch", - "line": 152, + "line": 302, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Notifications have been denied. Enable them in iOS Settings.", "surface": "apple", @@ -11251,7 +11427,7 @@ }, { "kind": "conditional-branch", - "line": 154, + "line": 304, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Enable notifications to receive approval prompts and event alerts outside the app.", "surface": "apple", @@ -11259,7 +11435,7 @@ }, { "kind": "conditional-branch", - "line": 156, + "line": 306, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "OpenClaw cannot determine the current notification permission state.", "surface": "apple", @@ -11267,7 +11443,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 430, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connected", "surface": "apple", @@ -11275,7 +11451,7 @@ }, { "kind": "ui-named-argument", - "line": 282, + "line": 432, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Gateway online", "surface": "apple", @@ -11283,7 +11459,7 @@ }, { "kind": "ui-named-argument", - "line": 283, + "line": 433, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connected to openclaw-gateway.tailnet.ts.net.", "surface": "apple", @@ -11291,7 +11467,7 @@ }, { "kind": "ui-call", - "line": 293, + "line": 443, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Loading", "surface": "apple", @@ -11299,7 +11475,7 @@ }, { "kind": "ui-named-argument", - "line": 295, + "line": 445, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Checking gateway", "surface": "apple", @@ -11307,7 +11483,7 @@ }, { "kind": "ui-named-argument", - "line": 296, + "line": 446, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Refreshing connection, discovery, and device trust state.", "surface": "apple", @@ -11315,7 +11491,7 @@ }, { "kind": "ui-call", - "line": 302, + "line": 452, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Empty", "surface": "apple", @@ -11323,7 +11499,7 @@ }, { "kind": "ui-named-argument", - "line": 304, + "line": 454, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "No gateway configured", "surface": "apple", @@ -11331,7 +11507,7 @@ }, { "kind": "ui-named-argument", - "line": 305, + "line": 455, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Scan a setup QR code, paste a setup code, or choose a discovered gateway.", "surface": "apple", @@ -11339,7 +11515,7 @@ }, { "kind": "ui-call", - "line": 311, + "line": 461, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Error", "surface": "apple", @@ -11347,7 +11523,7 @@ }, { "kind": "ui-named-argument", - "line": 313, + "line": 463, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Tailscale warning", "surface": "apple", @@ -11355,7 +11531,7 @@ }, { "kind": "ui-named-argument", - "line": 314, + "line": 464, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Tailscale is off on this device. Turn it on, then try again.", "surface": "apple", @@ -11363,7 +11539,7 @@ }, { "kind": "ui-call", - "line": 363, + "line": 513, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Address", "surface": "apple", @@ -11371,7 +11547,7 @@ }, { "kind": "ui-call", - "line": 365, + "line": 515, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Server", "surface": "apple", @@ -11379,7 +11555,7 @@ }, { "kind": "ui-call", - "line": 367, + "line": 517, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Discovered", "surface": "apple", @@ -11387,7 +11563,7 @@ }, { "kind": "ui-call", - "line": 369, + "line": 519, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Default Agent", "surface": "apple", @@ -11395,7 +11571,7 @@ }, { "kind": "ui-call", - "line": 391, + "line": 541, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Reconnect", "surface": "apple", @@ -11403,7 +11579,7 @@ }, { "kind": "ui-call", - "line": 392, + "line": 542, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Diagnose", "surface": "apple", @@ -11411,7 +11587,7 @@ }, { "kind": "ui-call", - "line": 401, + "line": 551, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Scan QR", "surface": "apple", @@ -11419,7 +11595,7 @@ }, { "kind": "ui-call", - "line": 402, + "line": 552, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Connect", "surface": "apple", @@ -11427,7 +11603,7 @@ }, { "kind": "ui-call", - "line": 404, + "line": 554, "path": "apps/ios/Sources/Design/SettingsProTabSupport.swift", "source": "Discovered gateways and manual setup live here when the gateway has not connected yet.", "surface": "apple", @@ -14707,7 +14883,7 @@ }, { "kind": "ui-modifier", - "line": 30, + "line": 32, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "OpenClaw on GitHub", "surface": "apple", @@ -14715,7 +14891,7 @@ }, { "kind": "ui-call", - "line": 38, + "line": 40, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "OpenClaw", "surface": "apple", @@ -14723,23 +14899,7 @@ }, { "kind": "ui-call", - "line": 40, - "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", - "source": "Version \\(self.versionString)", - "surface": "apple", - "id": "native.apple.383d9c0a8f5f4f7e" - }, - { - "kind": "ui-call", - "line": 43, - "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", - "source": "Built \\(buildTimestamp)\\(self.buildSuffix)", - "surface": "apple", - "id": "native.apple.33f4ac3e111b264f" - }, - { - "kind": "ui-call", - "line": 47, + "line": 44, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Menu bar companion for notifications, screenshots, and privileged agent actions.", "surface": "apple", @@ -14747,7 +14907,7 @@ }, { "kind": "ui-named-argument", - "line": 56, + "line": 53, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Website", "surface": "apple", @@ -14755,7 +14915,7 @@ }, { "kind": "ui-named-argument", - "line": 57, + "line": 54, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Docs", "surface": "apple", @@ -14763,7 +14923,7 @@ }, { "kind": "ui-named-argument", - "line": 60, + "line": 57, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "GitHub", "surface": "apple", @@ -14771,7 +14931,7 @@ }, { "kind": "ui-named-argument", - "line": 64, + "line": 61, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Discord", "surface": "apple", @@ -14779,7 +14939,7 @@ }, { "kind": "ui-call", - "line": 77, + "line": 74, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Check for updates automatically", "surface": "apple", @@ -14787,7 +14947,7 @@ }, { "kind": "ui-call", - "line": 81, + "line": 78, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Check for Updates…", "surface": "apple", @@ -14795,7 +14955,7 @@ }, { "kind": "ui-call", - "line": 84, + "line": 81, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "Updates unavailable in this build.", "surface": "apple", @@ -14803,12 +14963,84 @@ }, { "kind": "ui-call", - "line": 90, + "line": 87, "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", "id": "native.apple.4ec07761308f7f50" }, + { + "kind": "ui-call", + "line": 166, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Copy full commit hash", + "surface": "apple", + "id": "native.apple.37661ff1af405580" + }, + { + "kind": "ui-call", + "line": 170, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Copy build info", + "surface": "apple", + "id": "native.apple.2916df231993897d" + }, + { + "kind": "ui-call", + "line": 176, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Copy Commit", + "surface": "apple", + "id": "native.apple.f870085ee74e9885" + }, + { + "kind": "ui-call", + "line": 180, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Copy Build Info", + "surface": "apple", + "id": "native.apple.be5af46a63f9d1da" + }, + { + "kind": "ui-call", + "line": 196, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Unavailable", + "surface": "apple", + "id": "native.apple.ddd4228d93680320" + }, + { + "kind": "ui-call", + "line": 212, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Version \\(version), commit \\(commit), built \\(built), timestamp \\(timestamp)", + "surface": "apple", + "id": "native.apple.0e7c932e0d8fb288" + }, + { + "kind": "ui-call", + "line": 215, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Version \\(version), commit \\(commit), build date unavailable", + "surface": "apple", + "id": "native.apple.d4ef93e8ebca2d55" + }, + { + "kind": "ui-call", + "line": 218, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Version \\(version), commit unavailable, built \\(built), timestamp \\(timestamp)", + "surface": "apple", + "id": "native.apple.f67aac55a340fda0" + }, + { + "kind": "ui-call", + "line": 220, + "path": "apps/macos/Sources/OpenClaw/AboutSettings.swift", + "source": "Version \\(version), commit unavailable, build date unavailable", + "surface": "apple", + "id": "native.apple.76472e074114886c" + }, { "kind": "ui-call", "line": 11, diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index 1df182bd367..2db2902afa0 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +Shows the localized app version, Git commit, and build date together on the About screen, with real provenance in repository-backed debug builds. + ## 2026.7.1 - 2026-07-08 Adds multi-gateway switching with isolated credentials, history, queues, and notification routing. diff --git a/apps/android/README.md b/apps/android/README.md index a3c83239196..8697c4bad6d 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -46,6 +46,12 @@ cd apps/android ./gradlew :app:testThirdPartyDebugUnitTest ``` +Repository-backed debug Gradle invocations, including `pnpm android:run` and +`pnpm android:screenshots`, stamp the full checkout commit and capture one UTC +build timestamp shared by every debug variant in that invocation. Release +tasks still require explicit `openclawBuildCommit` and +`openclawBuildTimestamp` properties so signed artifacts remain reproducible. + Android release archives use the pinned version in `apps/android/version.json`. Update it with: ```bash @@ -102,12 +108,14 @@ Google Play API commands, or Play Console mutation commands. See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow. -Flavor-specific direct Gradle tasks: +Prefer `pnpm android:release:archive`, which stamps and validates the full Git commit and one UTC build timestamp before signing. Flavor-specific direct Gradle release tasks must pass the same metadata explicitly: ```bash cd apps/android -./gradlew :app:bundlePlayRelease -./gradlew :app:bundleThirdPartyRelease +commit="$(git -C ../.. rev-parse HEAD)" +built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundlePlayRelease +./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundleThirdPartyRelease ``` ## Kotlin Lint + Format diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 72708e51605..1c2305608d8 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -1,4 +1,7 @@ import com.android.build.api.variant.impl.VariantOutputImpl +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.Properties val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider" @@ -21,6 +24,61 @@ val openClawAndroidVersionCode = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull() ?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.") +fun optionalOpenClawBuildProperty(name: String): String? = + providers + .gradleProperty(name) + .orNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + +val fullGitCommitPattern = Regex("^[a-f0-9]{40}$") +val buildTimestampFormatter = + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) +val explicitOpenClawBuildCommit = + optionalOpenClawBuildProperty("openclawBuildCommit") + ?.lowercase() + ?.also { commit -> + if (!fullGitCommitPattern.matches(commit)) { + error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.") + } + } + +val explicitOpenClawBuildTimestamp = + optionalOpenClawBuildProperty("openclawBuildTimestamp") + ?.let { timestamp -> + if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) { + error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") + } + val instant = + runCatching { Instant.parse(timestamp) } + .getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") } + buildTimestampFormatter.format(instant) + } + +val repositoryBuildCommit = + if (explicitOpenClawBuildCommit == null) { + runCatching { + providers + .exec { + workingDir(rootProject.projectDir) + commandLine("git", "rev-parse", "HEAD") + }.standardOutput + .asText + .get() + .trim() + .lowercase() + .takeIf(fullGitCommitPattern::matches) + }.getOrNull() + } else { + null + } + +val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown" +// Keep every variant generated by one Gradle invocation on the same build instant. +val invocationBuildTimestamp = + providers.provider { buildTimestampFormatter.format(Instant.now()) }.get() +val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp + val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() } val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() } val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() } @@ -42,6 +100,8 @@ val wantsAndroidReleaseBuild = taskName.contains("Release", ignoreCase = true) || Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName) } +val missingAndroidBuildMetadata = + explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) { error( @@ -91,6 +151,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" versionCode = openClawAndroidVersionCode versionName = openClawAndroidVersionName + buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"") + buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"") ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -300,6 +362,18 @@ tasks.withType().configureEach { useJUnitPlatform() } +val validateOpenClawReleaseBuildMetadata = + tasks.register("validateOpenClawReleaseBuildMetadata") { + doLast { + if (missingAndroidBuildMetadata) { + error( + "Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " + + "Use the repository Android release helper.", + ) + } + } + } + val validateThirdPartyLicenseAssets = tasks.register("validateThirdPartyLicenseAssets") { inputs.dir(thirdPartyLicensesDir) @@ -331,6 +405,7 @@ androidComponents { onVariants(selector().withBuildType("release")) { variant -> val variantName = variant.name val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase) + val preBuildTaskName = "pre${variantNameCapitalized}Build" val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor" val mergeTaskName = "merge${variantNameCapitalized}JavaResource" val minifyTaskName = "minify${variantNameCapitalized}WithR8" @@ -339,6 +414,10 @@ androidComponents { "intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar", ) + tasks.matching { task -> task.name == preBuildTaskName }.configureEach { + dependsOn(validateOpenClawReleaseBuildMetadata) + } + val stripTask = tasks.register(stripTaskName) { inputs.file(mergedJar) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/AboutBuildIdentity.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/AboutBuildIdentity.kt new file mode 100644 index 00000000000..8d5a6413c5e --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/AboutBuildIdentity.kt @@ -0,0 +1,261 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.R +import ai.openclaw.app.ui.design.ClawPanel +import ai.openclaw.app.ui.design.ClawTheme +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.text.DateFormat +import java.time.Instant +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +internal data class AboutBuildIdentity( + val version: String, + val commit: String, + val fullCommit: String?, + val built: String, + val buildTimestamp: String?, +) + +private data class AboutBuildCell( + val title: String, + val value: String, + val accessibilityLabel: String, + val forceLeftToRight: Boolean = false, + val monospace: Boolean = false, + val onClick: (() -> Unit)? = null, + val onClickLabel: String? = null, +) + +private val fullGitCommitPattern = Regex("^[a-f0-9]{40}$") + +internal fun aboutBuildIdentity( + versionName: String, + versionCode: Int, + gitCommit: String, + buildTimestamp: String, + locale: Locale, + unknownLabel: String, +): AboutBuildIdentity { + val normalizedCommit = gitCommit.trim().lowercase().takeIf(fullGitCommitPattern::matches) + val normalizedBuildTimestamp = + buildTimestamp.trim().takeIf { it.endsWith("Z") }?.takeIf { timestamp -> + runCatching { Instant.parse(timestamp) }.isSuccess + } + val buildInstant = normalizedBuildTimestamp?.let(Instant::parse) + val built = + buildInstant?.let { instant -> + DateFormat.getDateInstance(DateFormat.MEDIUM, locale).run { + timeZone = TimeZone.getTimeZone("UTC") + format(Date.from(instant)) + } + } ?: unknownLabel + + return AboutBuildIdentity( + version = "${versionName.trim()} ($versionCode)", + commit = normalizedCommit?.take(12) ?: unknownLabel, + fullCommit = normalizedCommit, + built = built, + buildTimestamp = normalizedBuildTimestamp, + ) +} + +internal fun aboutCommitAccessibilityValue( + fullCommit: String?, + unknownLabel: String, +): String = + fullCommit?.let { commit -> + commit.toCharArray().joinToString(" ") + } ?: unknownLabel + +@Composable +internal fun AboutBuildIdentityPanel( + versionName: String, + versionCode: Int, + gitCommit: String, + buildTimestamp: String, + locale: Locale, +) { + val context = LocalContext.current + val unknownLabel = stringResource(R.string.about_build_unknown) + val identity = + aboutBuildIdentity( + versionName = versionName, + versionCode = versionCode, + gitCommit = gitCommit, + buildTimestamp = buildTimestamp, + locale = locale, + unknownLabel = unknownLabel, + ) + val commitClipboardLabel = stringResource(R.string.about_build_commit_clipboard_label) + val commitCopiedConfirmation = stringResource(R.string.about_build_commit_copied) + val timestampClipboardLabel = stringResource(R.string.about_build_timestamp_clipboard_label) + val timestampCopiedConfirmation = stringResource(R.string.about_build_timestamp_copied) + val copyCommitLabel = stringResource(R.string.about_build_copy_commit) + val copyTimestampLabel = stringResource(R.string.about_build_copy_timestamp) + val commitClick: (() -> Unit)? = + identity.fullCommit?.let { commit -> + { + copyAboutBuildValue( + context = context, + label = commitClipboardLabel, + value = commit, + confirmation = commitCopiedConfirmation, + ) + } + } + val timestampClick: (() -> Unit)? = + identity.buildTimestamp?.let { timestamp -> + { + copyAboutBuildValue( + context = context, + label = timestampClipboardLabel, + value = timestamp, + confirmation = timestampCopiedConfirmation, + ) + } + } + val builtAccessibilityLabel = + identity.buildTimestamp?.let { timestamp -> + stringResource(R.string.about_build_built_accessibility, identity.built, timestamp) + } ?: stringResource(R.string.about_build_date_accessibility, identity.built) + val cells = + listOf( + AboutBuildCell( + title = stringResource(R.string.about_build_version_title), + value = identity.version, + accessibilityLabel = stringResource(R.string.about_build_version_accessibility, identity.version), + forceLeftToRight = true, + ), + AboutBuildCell( + title = stringResource(R.string.about_build_commit_title), + value = identity.commit, + accessibilityLabel = + stringResource( + R.string.about_build_commit_accessibility, + aboutCommitAccessibilityValue(identity.fullCommit, unknownLabel), + ), + forceLeftToRight = true, + monospace = true, + onClick = commitClick, + onClickLabel = identity.fullCommit?.let { copyCommitLabel }, + ), + AboutBuildCell( + title = stringResource(R.string.about_build_built_title), + value = identity.built, + accessibilityLabel = builtAccessibilityLabel, + onClick = timestampClick, + onClickLabel = identity.buildTimestamp?.let { copyTimestampLabel }, + ), + ) + + ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val wraps = maxWidth < 260.dp || LocalDensity.current.fontScale >= 1.3f + if (wraps) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + cells.forEach { cell -> + AboutBuildIdentityCell(cell = cell, modifier = Modifier.fillMaxWidth()) + } + } + } else { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + cells.forEach { cell -> + AboutBuildIdentityCell(cell = cell, modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +@Composable +private fun AboutBuildIdentityCell( + cell: AboutBuildCell, + modifier: Modifier, +) { + val clickModifier = + cell.onClick?.let { action -> + Modifier.clickable(onClickLabel = cell.onClickLabel, onClick = action) + } ?: Modifier + val accessibilityModifier = + Modifier.clearAndSetSemantics { + contentDescription = cell.accessibilityLabel + cell.onClick?.let { action -> + onClick(label = cell.onClickLabel) { + action() + true + } + } + } + + Column( + modifier = + modifier + .then(clickModifier) + .then(accessibilityModifier) + .heightIn(min = 54.dp) + .padding(horizontal = 5.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = cell.title, + style = ClawTheme.type.caption.copy(fontSize = 11.sp, lineHeight = 14.sp), + color = ClawTheme.colors.textSubtle, + textAlign = TextAlign.Center, + ) + Text( + text = cell.value, + style = + ClawTheme.type.caption.copy( + fontFamily = if (cell.monospace) FontFamily.Monospace else ClawTheme.type.caption.fontFamily, + fontSize = 12.5.sp, + lineHeight = 17.sp, + textDirection = if (cell.forceLeftToRight) TextDirection.Ltr else ClawTheme.type.caption.textDirection, + ), + color = if (cell.onClick == null) ClawTheme.colors.text else ClawTheme.colors.primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } +} + +private fun copyAboutBuildValue( + context: Context, + label: String, + value: String, + confirmation: String, +) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) + Toast.makeText(context, confirmation, Toast.LENGTH_SHORT).show() +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index b259d2a3e44..56d53d9e110 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -138,6 +138,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontFamily @@ -1636,14 +1637,20 @@ private fun AboutSettingsScreen( val updateAvailable by viewModel.gatewayUpdateAvailable.collectAsState() val latestVersion = updateAvailable?.latestVersion?.takeIf { it.isNotBlank() } val currentGatewayVersion = updateAvailable?.currentVersion?.takeIf { it.isNotBlank() } ?: gatewayVersion + val appLocale = LocalConfiguration.current.locales[0] SettingsDetailFrame(title = "About", subtitle = "OpenClaw for Android.", icon = Icons.Default.Info, onBack = onBack) { AboutHeroPanel() + AboutBuildIdentityPanel( + versionName = BuildConfig.VERSION_NAME, + versionCode = BuildConfig.VERSION_CODE, + gitCommit = BuildConfig.GIT_COMMIT, + buildTimestamp = BuildConfig.BUILD_TIMESTAMP, + locale = appLocale, + ) SettingsMetricPanel( rows = listOf( - SettingsMetric("Android App", BuildConfig.VERSION_NAME), - SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()), SettingsMetric("Channel", androidDistributionChannel()), SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"), ), diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index 8431e637653..10d6b68f063 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -6,4 +6,18 @@ دردشة جديدة في worktree تحقق من بصمة الشهادة قبل الوثوق بهذه البوابة.\n\n%1$s تم تغيير شهادة البوابة. تابع فقط إذا كنت تتوقع هذا التغيير.\n\nSHA-256 القديم:\n%1$s\n\nSHA-256 الجديد:\n%2$s + غير معروف + الإصدار + الالتزام + تاريخ البناء + الإصدار %1$s + التزام Git ‏%1$s + تم البناء في %1$s بتوقيت UTC، والطابع الزمني %2$s + تاريخ البناء %1$s + نسخ تجزئة التزام Git الكاملة + نسخ الطابع الزمني الكامل للبناء + التزام Git لـ OpenClaw + الطابع الزمني لبناء OpenClaw + تم نسخ التزام Git + تم نسخ الطابع الزمني للبناء diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 0e79f40a95b..6e152b35815 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -6,4 +6,18 @@ Neuer Chat im Worktree Überprüfen Sie den Zertifikatfingerabdruck, bevor Sie diesem Gateway vertrauen.\n\n%1$s Das Gateway-Zertifikat wurde geändert. Fahren Sie nur fort, wenn Sie diese Änderung erwartet haben.\n\nAlter SHA-256-Wert:\n%1$s\n\nNeuer SHA-256-Wert:\n%2$s + Unbekannt + VERSION + COMMIT + ERSTELLT + Version %1$s + Git-Commit %1$s + Erstellt am %1$s UTC, Zeitstempel %2$s + Build-Datum %1$s + Vollständigen Git-Commit-Hash kopieren + Vollständigen Build-Zeitstempel kopieren + Git-Commit von OpenClaw + Build-Zeitstempel von OpenClaw + Git-Commit kopiert + Build-Zeitstempel kopiert diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index 539bde90d6d..9d54f7683c5 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -6,4 +6,18 @@ Nuevo chat en worktree Verifica la huella digital del certificado antes de confiar en este gateway.\n\n%1$s El certificado del gateway cambió. Continúa solo si esperabas este cambio.\n\nSHA-256 anterior:\n%1$s\n\nSHA-256 nuevo:\n%2$s + Desconocido + VERSIÓN + COMMIT + COMPILADO + Versión %1$s + Commit de Git %1$s + Compilado el %1$s UTC, marca de tiempo %2$s + Fecha de compilación %1$s + Copiar el hash completo del commit de Git + Copiar la marca de tiempo completa de la compilación + Commit de Git de OpenClaw + Marca de tiempo de compilación de OpenClaw + Commit de Git copiado + Marca de tiempo de compilación copiada diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index 907937ebf13..1665c06dcf0 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -6,4 +6,18 @@ چت جدید در worktree پیش از اعتماد به این دروازه، اثر انگشت گواهی را تأیید کنید.\n\n%1$s گواهی دروازه تغییر کرده است. فقط در صورتی ادامه دهید که انتظار این تغییر را داشتید.\n\nSHA-256 قدیمی:\n%1$s\n\nSHA-256 جدید:\n%2$s + نامشخص + نسخه + کامیت + ساخته‌شده + نسخهٔ %1$s + کامیت Git ‏%1$s + ساخته‌شده در %1$s به وقت UTC، برچسب زمانی %2$s + تاریخ ساخت %1$s + کپی هش کامل کامیت Git + کپی برچسب زمانی کامل ساخت + کامیت Git مربوط به OpenClaw + برچسب زمانی ساخت OpenClaw + کامیت Git کپی شد + برچسب زمانی ساخت کپی شد diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 3d967c76b5e..e3487fa9812 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -6,4 +6,18 @@ Nouveau chat dans le worktree Vérifiez l’empreinte du certificat avant d’accorder votre confiance à cette passerelle.\n\n%1$s Le certificat de la passerelle a changé. Continuez uniquement si vous vous attendiez à ce changement.\n\nAncien SHA-256 :\n%1$s\n\nNouveau SHA-256 :\n%2$s + Inconnu + VERSION + COMMIT + COMPILÉ + Version %1$s + Commit Git %1$s + Compilé le %1$s UTC, horodatage %2$s + Date de compilation %1$s + Copier le hash complet du commit Git + Copier l’horodatage complet de compilation + Commit Git d’OpenClaw + Horodatage de compilation d’OpenClaw + Commit Git copié + Horodatage de compilation copié diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index 5dcc02d8e59..d6ca5b99ee0 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -6,4 +6,18 @@ worktree में नया चैट इस गेटवे पर भरोसा करने से पहले प्रमाणपत्र फ़िंगरप्रिंट सत्यापित करें।\n\n%1$s गेटवे प्रमाणपत्र बदल गया है। केवल तभी जारी रखें जब आपको इस बदलाव की अपेक्षा थी।\n\nपुराना SHA-256:\n%1$s\n\nनया SHA-256:\n%2$s + अज्ञात + संस्करण + कमिट + बिल्ड + संस्करण %1$s + Git कमिट %1$s + %1$s UTC को बिल्ड किया गया, टाइमस्टैम्प %2$s + बिल्ड की तारीख %1$s + पूरा Git कमिट हैश कॉपी करें + पूरा बिल्ड टाइमस्टैम्प कॉपी करें + OpenClaw Git कमिट + OpenClaw बिल्ड टाइमस्टैम्प + Git कमिट कॉपी किया गया + बिल्ड टाइमस्टैम्प कॉपी किया गया diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index 773990f4fe7..8d3bc6376e2 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -6,4 +6,18 @@ Chat baru di worktree Verifikasi sidik jari sertifikat sebelum memercayai gateway ini.\n\n%1$s Sertifikat gateway berubah. Lanjutkan hanya jika Anda mengharapkan perubahan ini.\n\nSHA-256 lama:\n%1$s\n\nSHA-256 baru:\n%2$s + Tidak diketahui + VERSI + COMMIT + DIBUAT + Versi %1$s + Commit Git %1$s + Dibuat pada %1$s UTC, stempel waktu %2$s + Tanggal build %1$s + Salin hash lengkap commit Git + Salin stempel waktu build lengkap + Commit Git OpenClaw + Stempel waktu build OpenClaw + Commit Git disalin + Stempel waktu build disalin diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index 2e815058b62..ac049c6fd6c 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -6,4 +6,18 @@ Nuova chat nel worktree Verifica l’impronta digitale del certificato prima di considerare attendibile questo gateway.\n\n%1$s Il certificato del gateway è cambiato. Continua solo se ti aspettavi questa modifica.\n\nSHA-256 precedente:\n%1$s\n\nNuovo SHA-256:\n%2$s + Sconosciuto + VERSIONE + COMMIT + COMPILATO + Versione %1$s + Commit Git %1$s + Compilato il %1$s UTC, timestamp %2$s + Data di compilazione %1$s + Copia l’hash completo del commit Git + Copia il timestamp completo della compilazione + Commit Git di OpenClaw + Timestamp di compilazione di OpenClaw + Commit Git copiato + Timestamp di compilazione copiato diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index c8b8fc117e3..1544ac540fe 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -6,4 +6,18 @@ worktree で新規チャット このゲートウェイを信頼する前に、証明書のフィンガープリントを確認してください。\n\n%1$s ゲートウェイの証明書が変更されました。想定した変更である場合のみ続行してください。\n\n以前の SHA-256:\n%1$s\n\n新しい SHA-256:\n%2$s + 不明 + バージョン + コミット + ビルド日 + バージョン %1$s + Git コミット %1$s + ビルド日 %1$s UTC、タイムスタンプ %2$s + ビルド日 %1$s + Git コミットの完全なハッシュをコピー + 完全なビルドタイムスタンプをコピー + OpenClaw の Git コミット + OpenClaw のビルドタイムスタンプ + Git コミットをコピーしました + ビルドタイムスタンプをコピーしました diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 48dc88d658a..dc6cc15ca43 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -6,4 +6,18 @@ worktree에서 새 채팅 이 게이트웨이를 신뢰하기 전에 인증서 지문을 확인하세요.\n\n%1$s 게이트웨이 인증서가 변경되었습니다. 예상한 변경인 경우에만 계속하세요.\n\n이전 SHA-256:\n%1$s\n\n새 SHA-256:\n%2$s + 알 수 없음 + 버전 + 커밋 + 빌드 날짜 + 버전 %1$s + Git 커밋 %1$s + 빌드 날짜 %1$s UTC, 타임스탬프 %2$s + 빌드 날짜 %1$s + Git 커밋 전체 해시 복사 + 전체 빌드 타임스탬프 복사 + OpenClaw Git 커밋 + OpenClaw 빌드 타임스탬프 + Git 커밋을 복사했습니다 + 빌드 타임스탬프를 복사했습니다 diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 868a2866a08..4f5288a0694 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -6,4 +6,18 @@ Nieuwe chat in worktree Controleer de certificaatvingerafdruk voordat je deze gateway vertrouwt.\n\n%1$s Het gatewaycertificaat is gewijzigd. Ga alleen door als je deze wijziging verwachtte.\n\nOude SHA-256:\n%1$s\n\nNieuwe SHA-256:\n%2$s + Onbekend + VERSIE + COMMIT + GEBOUWD + Versie %1$s + Git-commit %1$s + Gebouwd op %1$s UTC, tijdstempel %2$s + Builddatum %1$s + Volledige Git-commithash kopiëren + Volledige buildtijdstempel kopiëren + Git-commit van OpenClaw + Buildtijdstempel van OpenClaw + Git-commit gekopieerd + Buildtijdstempel gekopieerd diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index c8408860e22..e43c85abc27 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -6,4 +6,18 @@ Nowy czat w worktree Sprawdź odcisk certyfikatu, zanim zaufasz tej bramie.\n\n%1$s Certyfikat bramy uległ zmianie. Kontynuuj tylko wtedy, gdy oczekujesz tej zmiany.\n\nStary SHA-256:\n%1$s\n\nNowy SHA-256:\n%2$s + Nieznane + WERSJA + COMMIT + ZBUDOWANO + Wersja %1$s + Commit Git %1$s + Zbudowano %1$s UTC, znacznik czasu %2$s + Data kompilacji %1$s + Skopiuj pełny hash commita Git + Skopiuj pełny znacznik czasu kompilacji + Commit Git OpenClaw + Znacznik czasu kompilacji OpenClaw + Skopiowano commit Git + Skopiowano znacznik czasu kompilacji diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index 4e0567e9161..0bd8ffc3439 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -6,4 +6,18 @@ Novo chat no worktree Verifique a impressão digital do certificado antes de confiar neste gateway.\n\n%1$s O certificado do gateway foi alterado. Continue somente se você esperava essa alteração.\n\nSHA-256 antigo:\n%1$s\n\nSHA-256 novo:\n%2$s + Desconhecido + VERSÃO + COMMIT + COMPILADO + Versão %1$s + Commit do Git %1$s + Compilado em %1$s UTC, carimbo de data/hora %2$s + Data da compilação %1$s + Copiar o hash completo do commit do Git + Copiar o carimbo de data/hora completo da compilação + Commit do Git do OpenClaw + Carimbo de data/hora da compilação do OpenClaw + Commit do Git copiado + Carimbo de data/hora da compilação copiado diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 94b5b2c3bde..cefb6d98f7b 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -6,4 +6,18 @@ Новый чат в worktree Проверьте отпечаток сертификата, прежде чем доверять этому шлюзу.\n\n%1$s Сертификат шлюза изменился. Продолжайте, только если вы ожидали это изменение.\n\nСтарый SHA-256:\n%1$s\n\nНовый SHA-256:\n%2$s + Неизвестно + ВЕРСИЯ + КОММИТ + СОБРАНО + Версия %1$s + Git-коммит %1$s + Собрано %1$s UTC, метка времени %2$s + Дата сборки %1$s + Скопировать полный хеш Git-коммита + Скопировать полную метку времени сборки + Git-коммит OpenClaw + Метка времени сборки OpenClaw + Git-коммит скопирован + Метка времени сборки скопирована diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index 267cdfa2b51..3902765e9ad 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -6,4 +6,18 @@ Ny chatt i worktree Verifiera certifikatets fingeravtryck innan du litar på denna gateway.\n\n%1$s Gateway-certifikatet har ändrats. Fortsätt bara om du förväntade dig denna ändring.\n\nTidigare SHA-256:\n%1$s\n\nNy SHA-256:\n%2$s + Okänt + VERSION + COMMIT + BYGGD + Version %1$s + Git-commit %1$s + Byggd den %1$s UTC, tidsstämpel %2$s + Byggdatum %1$s + Kopiera fullständig Git-commit-hash + Kopiera hela tidsstämpeln för bygget + Git-commit för OpenClaw + Byggtidsstämpel för OpenClaw + Git-commit kopierad + Byggtidsstämpel kopierad diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index f10686f06f9..2b550b49ff1 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -6,4 +6,18 @@ แชทใหม่ใน worktree ตรวจสอบลายนิ้วมือของใบรับรองก่อนเชื่อถือเกตเวย์นี้\n\n%1$s ใบรับรองของเกตเวย์มีการเปลี่ยนแปลง ดำเนินการต่อเมื่อคุณคาดว่าจะมีการเปลี่ยนแปลงนี้เท่านั้น\n\nSHA-256 เดิม:\n%1$s\n\nSHA-256 ใหม่:\n%2$s + ไม่ทราบ + เวอร์ชัน + คอมมิต + สร้างเมื่อ + เวอร์ชัน %1$s + คอมมิต Git %1$s + สร้างเมื่อ %1$s UTC, การประทับเวลา %2$s + วันที่สร้าง %1$s + คัดลอกแฮชแบบเต็มของคอมมิต Git + คัดลอกการประทับเวลาแบบเต็มของบิลด์ + คอมมิต Git ของ OpenClaw + การประทับเวลาของบิลด์ OpenClaw + คัดลอกคอมมิต Git แล้ว + คัดลอกการประทับเวลาของบิลด์แล้ว diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index b0d9edc3886..d370f3053e3 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -6,4 +6,18 @@ Worktree\'de yeni sohbet Bu ağ geçidine güvenmeden önce sertifika parmak izini doğrulayın.\n\n%1$s Ağ geçidi sertifikası değişti. Yalnızca bu değişikliği bekliyorsanız devam edin.\n\nEski SHA-256:\n%1$s\n\nYeni SHA-256:\n%2$s + Bilinmiyor + SÜRÜM + COMMIT + DERLEME + Sürüm %1$s + Git commit\'i %1$s + %1$s UTC tarihinde derlendi, zaman damgası %2$s + Derleme tarihi %1$s + Git commit\'inin tam hash değerini kopyala + Tam derleme zaman damgasını kopyala + OpenClaw Git commit\'i + OpenClaw derleme zaman damgası + Git commit\'i kopyalandı + Derleme zaman damgası kopyalandı diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index 889f00bc3ee..1552073fa01 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -6,4 +6,18 @@ Новий чат у worktree Перевірте відбиток сертифіката, перш ніж довіряти цьому шлюзу.\n\n%1$s Сертифікат шлюзу змінився. Продовжуйте, лише якщо ви очікували цю зміну.\n\nСтарий SHA-256:\n%1$s\n\nНовий SHA-256:\n%2$s + Невідомо + ВЕРСІЯ + КОМІТ + ЗІБРАНО + Версія %1$s + Git-коміт %1$s + Зібрано %1$s UTC, позначка часу %2$s + Дата збірки %1$s + Скопіювати повний хеш Git-коміту + Скопіювати повну позначку часу збірки + Git-коміт OpenClaw + Позначка часу збірки OpenClaw + Git-коміт скопійовано + Позначку часу збірки скопійовано diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index 8c680514eaa..857b85dd6a0 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -6,4 +6,18 @@ Trò chuyện mới trong worktree Xác minh dấu vân tay chứng chỉ trước khi tin cậy cổng này.\n\n%1$s Chứng chỉ cổng đã thay đổi. Chỉ tiếp tục nếu bạn mong đợi thay đổi này.\n\nSHA-256 cũ:\n%1$s\n\nSHA-256 mới:\n%2$s + Không xác định + PHIÊN BẢN + COMMIT + NGÀY TẠO + Phiên bản %1$s + Commit Git %1$s + Bản dựng được tạo lúc %1$s UTC, dấu thời gian %2$s + Ngày tạo bản dựng %1$s + Sao chép toàn bộ mã băm commit Git + Sao chép toàn bộ dấu thời gian bản dựng + Commit Git của OpenClaw + Dấu thời gian bản dựng của OpenClaw + Đã sao chép commit Git + Đã sao chép dấu thời gian bản dựng diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index 52614149a93..b9082780e7e 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -6,4 +6,18 @@ 在 worktree 中新建聊天 验证证书指纹后再信任此网关。\n\n%1$s 网关证书已更改。仅当这是您预期的更改时才继续。\n\n旧 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s + 未知 + 版本 + 提交 + 构建日期 + 版本 %1$s + Git 提交 %1$s + 构建于 %1$s UTC,时间戳 %2$s + 构建日期 %1$s + 复制完整 Git 提交哈希值 + 复制完整构建时间戳 + OpenClaw Git 提交 + OpenClaw 构建时间戳 + 已复制 Git 提交 + 已复制构建时间戳 diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index cdaa1745b5a..699f2a9db8e 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -6,4 +6,18 @@ 在 worktree 中新增聊天 請先驗證憑證指紋,再信任此閘道。\n\n%1$s 閘道憑證已變更。僅在這是您預期的變更時繼續。\n\n舊 SHA-256:\n%1$s\n\n新 SHA-256:\n%2$s + 未知 + 版本 + 提交 + 建置日期 + 版本 %1$s + Git 提交 %1$s + 建置於 %1$s UTC,時間戳記 %2$s + 建置日期 %1$s + 複製完整 Git 提交雜湊值 + 複製完整建置時間戳記 + OpenClaw Git 提交 + OpenClaw 建置時間戳記 + 已複製 Git 提交 + 已複製建置時間戳記 diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 3cc756921d9..3f3baf9465b 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -6,4 +6,18 @@ New chat in worktree Verify the certificate fingerprint before trusting this gateway.\n\n%1$s The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n%1$s\n\nNew SHA-256:\n%2$s + Unknown + VERSION + COMMIT + BUILT + Version %1$s + Git commit %1$s + Built %1$s UTC, timestamp %2$s + Build date %1$s + Copy full Git commit hash + Copy full build timestamp + OpenClaw Git commit + OpenClaw build timestamp + Git commit copied + Build timestamp copied diff --git a/apps/android/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt new file mode 100644 index 00000000000..0322d52ae58 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/BuildMetadataTest.kt @@ -0,0 +1,17 @@ +package ai.openclaw.app + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class BuildMetadataTest { + @Test + fun debugBuildConfigContainsRepositoryCommitAndUtcBuildTimestamp() { + assertTrue(Regex("^[a-f0-9]{40}$").matches(BuildConfig.GIT_COMMIT)) + assertTrue( + Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$") + .matches(BuildConfig.BUILD_TIMESTAMP), + ) + Instant.parse(BuildConfig.BUILD_TIMESTAMP) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index cddd7861ef3..4c5cabb09d3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.GatewayNodeCapabilityApproval import ai.openclaw.app.LocationMode import org.junit.Assert.assertEquals import org.junit.Test +import java.util.Locale class SettingsScreensTest { @Test @@ -24,6 +25,55 @@ class SettingsScreensTest { assertEquals("Unknown", androidDistributionChannel("")) } + @Test + fun aboutBuildIdentityFormatsVersionShortCommitAndUtcDate() { + val identity = + aboutBuildIdentity( + versionName = "2026.7.1", + versionCode = 2026070102, + gitCommit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + buildTimestamp = "2026-07-10T00:30:00.000Z", + locale = Locale.US, + unknownLabel = "Unknown", + ) + + assertEquals("2026.7.1 (2026070102)", identity.version) + assertEquals("abcdef012345", identity.commit) + assertEquals("abcdef0123456789abcdef0123456789abcdef01", identity.fullCommit) + assertEquals("Jul 10, 2026", identity.built) + assertEquals("2026-07-10T00:30:00.000Z", identity.buildTimestamp) + } + + @Test + fun aboutBuildIdentityKeepsUnknownFallbacksVisible() { + val identity = + aboutBuildIdentity( + versionName = "dev", + versionCode = 1, + gitCommit = "unknown", + buildTimestamp = "unknown", + locale = Locale.US, + unknownLabel = "Unbekannt", + ) + + assertEquals("dev (1)", identity.version) + assertEquals("Unbekannt", identity.commit) + assertEquals(null, identity.fullCommit) + assertEquals("Unbekannt", identity.built) + assertEquals(null, identity.buildTimestamp) + assertEquals("Unbekannt", aboutCommitAccessibilityValue(identity.fullCommit, "Unbekannt")) + } + + @Test + fun aboutCommitAccessibilityValueSpellsTheFullHash() { + val commit = "abcdef0123456789abcdef0123456789abcdef01" + + assertEquals( + commit.toCharArray().joinToString(" "), + aboutCommitAccessibilityValue(commit, "Unknown"), + ) + } + @Test fun gatewayStatusLabelReportsWhichAuthRecoveryAppliesInsteadOfGenericLabel() { assertEquals( diff --git a/apps/android/scripts/build-release-artifacts.ts b/apps/android/scripts/build-release-artifacts.ts index 838460c21a4..86362d32ea1 100644 --- a/apps/android/scripts/build-release-artifacts.ts +++ b/apps/android/scripts/build-release-artifacts.ts @@ -16,7 +16,7 @@ import { readdirSync, writeFileSync, } from "node:fs"; -import { basename, delimiter, dirname, join } from "node:path"; +import { basename, delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts"; @@ -33,11 +33,127 @@ type CliOptions = { verifyApk?: string; }; +export type AndroidBuildMetadata = { + commit: string; + timestamp: string; +}; + +type ResolveAndroidBuildMetadataOptions = { + env?: NodeJS.ProcessEnv; + now?: () => Date; + readGitCommit?: () => string; +}; + const scriptDir = dirname(fileURLToPath(import.meta.url)); const androidDir = join(scriptDir, ".."); const rootDir = join(androidDir, "..", ".."); const releaseOutputDir = join(androidDir, "build", "release-artifacts"); const releaseSigningManifestPath = join(androidDir, "Config", "ReleaseSigning.json"); +const fullGitCommitPattern = /^[a-f0-9]{40}$/u; +const isoUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; + +function normalizeFullGitCommit(raw: string): string { + const commit = raw.trim().toLowerCase(); + if (!fullGitCommitPattern.test(commit)) { + throw new Error("Android build metadata requires a full 40-character hexadecimal Git commit"); + } + return commit; +} + +function normalizeIsoUtcTimestamp(raw: string): string { + const timestamp = raw.trim(); + if (!isoUtcTimestampPattern.test(timestamp)) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp"); + } + + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp"); + } + const normalized = parsed.toISOString(); + if (normalized.slice(0, 19) !== timestamp.slice(0, 19)) { + throw new Error("OPENCLAW_BUILD_TIMESTAMP must be a valid ISO-8601 UTC timestamp"); + } + return normalized; +} + +function readRepositoryCommit(): string { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + throw new Error("Unable to resolve the Android release Git commit"); + } +} + +export function resolveAndroidBuildMetadata( + options: ResolveAndroidBuildMetadataOptions = {}, +): AndroidBuildMetadata { + const env = options.env ?? process.env; + const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); + let repositoryCommit: string | undefined; + if (!explicitCommit) { + try { + repositoryCommit = (options.readGitCommit ?? readRepositoryCommit)().trim() || undefined; + } catch { + // GitHub's ambient SHA is safe only when there is no readable checkout. + } + } + const commitSource = explicitCommit || repositoryCommit || env.GITHUB_SHA?.trim(); + if (!commitSource) { + throw new Error("Unable to resolve the Android release Git commit"); + } + const commit = normalizeFullGitCommit(commitSource); + + const configuredTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim(); + const timestamp = configuredTimestamp + ? normalizeIsoUtcTimestamp(configuredTimestamp) + : (options.now ?? (() => new Date()))().toISOString(); + + return { commit, timestamp }; +} + +export function androidBuildMetadataGradleArgs(metadata: AndroidBuildMetadata): string[] { + return [ + `-PopenclawBuildCommit=${metadata.commit}`, + `-PopenclawBuildTimestamp=${metadata.timestamp}`, + ]; +} + +export function verifyAndroidReleaseSource( + expectedCommit: string, + options: { + rootDir?: string; + runGit?: (args: string[], cwd: string) => string; + } = {}, +): void { + const cwd = options.rootDir ?? rootDir; + const runGit = + options.runGit ?? + ((args: string[], gitCwd: string) => + execFileSync("git", args, { + cwd: gitCwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + })); + let head: string; + let status: string; + try { + head = normalizeFullGitCommit(runGit(["rev-parse", "HEAD"], cwd)); + status = runGit(["status", "--porcelain", "--untracked-files=all"], cwd).trim(); + } catch { + throw new Error("Android release builds require a readable Git checkout"); + } + if (head !== expectedCommit) { + throw new Error(`Android release commit mismatch: metadata ${expectedCommit}, checkout ${head}`); + } + if (status) { + throw new Error("Android release builds require a clean Git checkout"); + } +} function parseArgs(argv: string[]): CliOptions { let artifact: CliOptions["artifact"] = "all"; @@ -249,12 +365,15 @@ function main() { syncAndroidVersioning({ mode: "check", rootDir }); const version = resolveAndroidVersion(rootDir); + const buildMetadata = resolveAndroidBuildMetadata(); const artifacts = releaseArtifacts(version.canonicalVersion).filter( (artifact) => options.artifact === "all" || artifact.flavorName === options.artifact, ); console.log(`Android versionName: ${version.canonicalVersion}`); console.log(`Android versionCode: ${version.versionCode}`); + console.log(`Android build commit: ${buildMetadata.commit}`); + console.log(`Android build timestamp: ${buildMetadata.timestamp}`); for (const artifact of artifacts) { console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`); console.log(`Gradle task: ${artifact.gradleTask}`); @@ -265,11 +384,19 @@ function main() { return; } + verifyAndroidReleaseSource(buildMetadata.commit); mkdirSync(releaseOutputDir, { recursive: true }); - execFileSync("./gradlew", artifacts.map((artifact) => artifact.gradleTask), { - cwd: androidDir, - stdio: "inherit", - }); + execFileSync( + "./gradlew", + [ + ...androidBuildMetadataGradleArgs(buildMetadata), + ...artifacts.map((artifact) => artifact.gradleTask), + ], + { + cwd: androidDir, + stdio: "inherit", + }, + ); for (const artifact of artifacts) { const outputPath = join( @@ -286,4 +413,7 @@ function main() { } } -main(); +const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false; +if (isMain) { + main(); +} diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 4b92cdcaf74..608df4cfe44 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -696,18 +696,19 @@ extension SettingsProTab { Text("Personal AI on your devices") .font(OpenClawType.footnote) .foregroundStyle(.secondary) + SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata()) + .padding(.top, 8) } } .frame(maxWidth: .infinity) .padding(.top, 4) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } // Concise public details only; deep hardware identifiers live in Diagnostics. detailListCard { - SettingsDetailRow("OpenClaw app version", value: DeviceInfoHelper.openClawVersionString()) SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily()) SettingsDetailRow("iOS", value: DeviceInfoHelper.iOSVersionStringForDisplay()) } diff --git a/apps/ios/Sources/Design/SettingsProTabSupport.swift b/apps/ios/Sources/Design/SettingsProTabSupport.swift index f80c24f333a..a739c5ba25b 100644 --- a/apps/ios/Sources/Design/SettingsProTabSupport.swift +++ b/apps/ios/Sources/Design/SettingsProTabSupport.swift @@ -1,6 +1,7 @@ import Darwin import OpenClawKit import SwiftUI +import UIKit import UserNotifications enum SettingsRoute: Hashable { @@ -47,6 +48,155 @@ struct SettingsDetailRow: View { } } +struct SettingsBuildMetadataStrip: View { + let metadata: ArtifactBuildInfo + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.layoutDirection) private var layoutDirection + + private struct Field: Identifiable { + enum ID: String { + case version + case commit + case built + } + + let id: ID + let title: LocalizedStringKey + let value: String? + let forceLeftToRight: Bool + } + + private var fields: [Field] { + [ + Field(id: .version, title: "Version", value: self.metadata.versionDisplay, forceLeftToRight: true), + Field(id: .commit, title: "Commit", value: self.metadata.shortCommit, forceLeftToRight: true), + Field(id: .built, title: "Built", value: self.metadata.localizedBuildDate(), forceLeftToRight: false), + ] + } + + var body: some View { + Group { + if self.dynamicTypeSize.isAccessibilitySize { + self.metadataColumn + } else { + ViewThatFits(in: .horizontal) { + self.metadataRow + self.metadataColumn + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .accessibilityElement(children: .ignore) + .accessibilityLabel(self.metadataAccessibilityLabel) + .accessibilityActions { + if self.metadata.gitCommit != nil { + Button("Copy full commit hash") { + self.copyCommit() + } + } + Button("Copy build info") { + self.copyBuildInfo() + } + } + .contextMenu { + if self.metadata.gitCommit != nil { + Button { + self.copyCommit() + } label: { + Label { + Text("Copy Commit") + .font(OpenClawType.subheadSemiBold) + } icon: { + Image(systemName: "number") + } + } + } + Button { + self.copyBuildInfo() + } label: { + Label { + Text("Copy Build Info") + .font(OpenClawType.subheadSemiBold) + } icon: { + Image(systemName: "doc.on.doc") + } + } + } + } + + private var metadataRow: some View { + HStack(alignment: .center, spacing: 0) { + ForEach(Array(self.fields.enumerated()), id: \.element.id) { index, field in + if index > 0 { + Divider() + .frame(height: 30) + } + self.metadataField(field, alignment: .center) + .frame(minWidth: 72, maxWidth: .infinity) + .padding(.horizontal, 4) + } + } + .frame(minWidth: 240) + } + + private var metadataColumn: some View { + VStack(alignment: .center, spacing: 8) { + ForEach(self.fields) { field in + self.metadataField(field, alignment: .center) + } + } + } + + private func metadataField(_ field: Field, alignment: HorizontalAlignment) -> some View { + VStack(alignment: alignment, spacing: 1) { + Text(field.title) + .font(OpenClawType.caption2SemiBold) + .textCase(.uppercase) + Group { + if let value = field.value { + Text(verbatim: value) + } else { + Text("Unavailable") + } + } + .font(OpenClawType.monoSmall) + .lineLimit(1) + .minimumScaleFactor(0.72) + .environment( + \.layoutDirection, + field.forceLeftToRight ? .leftToRight : self.layoutDirection) + } + } + + private var metadataAccessibilityLabel: Text { + let version = self.metadata.versionDisplay + let commit = self.metadata.spokenCommit + let timestamp = self.metadata.buildTimestamp + let built = self.metadata.localizedBuildDate() ?? timestamp + if let commit, let timestamp, let built { + return Text("Version \(version), commit \(commit), built \(built), timestamp \(timestamp)") + } + if let commit { + return Text("Version \(version), commit \(commit), build date unavailable") + } + if let timestamp, let built { + return Text("Version \(version), commit unavailable, built \(built), timestamp \(timestamp)") + } + return Text("Version \(version), commit unavailable, build date unavailable") + } + + private func copyCommit() { + guard let gitCommit = self.metadata.gitCommit else { return } + UIPasteboard.general.string = gitCommit + } + + private func copyBuildInfo() { + UIPasteboard.general.string = self.metadata.copyText + } +} + struct SettingsApprovalItem: Identifiable { let id: String let icon: String diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift index ea932f0d782..ab976eabcf8 100644 --- a/apps/ios/Sources/Device/DeviceInfoHelper.swift +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import OpenClawKit import UIKit /// Shared device and platform info for Settings, gateway node payloads, and device status. @@ -79,4 +80,14 @@ enum DeviceInfoHelper { } return "\(version) (\(build))" } + + static func buildMetadata() -> ArtifactBuildInfo { + self.buildMetadata(infoDictionary: Bundle.main.infoDictionary ?? [:]) + } + + static func buildMetadata(infoDictionary: [String: Any]) -> ArtifactBuildInfo { + ArtifactBuildInfo( + infoDictionary: infoDictionary, + versionKeys: ["OpenClawCanonicalVersion", "CFBundleShortVersionString"]) + } } diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index cc5618abab6..8a53c6a6714 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -82,6 +82,10 @@ $(OPENCLAW_ACTIVE_APP_GROUP_ID) OpenClawCanonicalVersion $(OPENCLAW_IOS_VERSION) + OpenClawGitCommit + $(OPENCLAW_GIT_COMMIT) + OpenClawBuildTimestamp + $(OPENCLAW_BUILD_TIMESTAMP) OpenClawPushMode $(OPENCLAW_PUSH_MODE) OpenClawPushRelayBaseURL diff --git a/apps/ios/Tests/DeviceInfoHelperTests.swift b/apps/ios/Tests/DeviceInfoHelperTests.swift index cbeb2934b9b..ffbcdbbc7b0 100644 --- a/apps/ios/Tests/DeviceInfoHelperTests.swift +++ b/apps/ios/Tests/DeviceInfoHelperTests.swift @@ -8,4 +8,14 @@ struct DeviceInfoHelperTests { #expect(DeviceInfoHelper.iOSVersionStringForDisplay(version) == "26.5.0") } + + @Test func `build metadata prefers canonical iOS version`() { + let metadata = DeviceInfoHelper.buildMetadata(infoDictionary: [ + "OpenClawCanonicalVersion": "2026.7.10", + "CFBundleShortVersionString": "2026.7.9", + "CFBundleVersion": "42", + ]) + + #expect(metadata.versionDisplay == "2026.7.10 (42)") + } } diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift index 9a1ccb7cc89..c6cf9d605e2 100644 --- a/apps/ios/Tests/OpenClawTypographyTests.swift +++ b/apps/ios/Tests/OpenClawTypographyTests.swift @@ -156,6 +156,9 @@ struct OpenClawTypographyTests { let settingsSections = try String( contentsOf: Self.sourceURL("Design/SettingsProTabSections.swift"), encoding: .utf8) + let settingsSupport = try String( + contentsOf: Self.sourceURL("Design/SettingsProTabSupport.swift"), + encoding: .utf8) let privacyAccess = try String( contentsOf: Self.sourceURL("Settings/PrivacyAccessSectionView.swift"), encoding: .utf8) @@ -252,6 +255,10 @@ struct OpenClawTypographyTests { #expect(settingsSections.contains("func gatewayActionButton")) #expect(settingsSections.contains("func settingsToggle")) #expect(settingsSections.contains(".font(OpenClawType.subheadSemiBold)")) + #expect(settingsSupport.contains("struct SettingsBuildMetadataStrip")) + #expect(settingsSupport.contains(".font(OpenClawType.caption2SemiBold)")) + #expect(settingsSupport.contains(".font(OpenClawType.monoSmall)")) + #expect(settingsSupport.contains("Text(\"Copy Build Info\")")) #expect(settingsSections.contains("Text(\"Use Manual Gateway\")") || settingsSections.contains("\"Use Manual Gateway\"")) #expect(settingsSections.contains("func gatewaySecureField")) diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index cecf4fabd60..20862560ccf 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -280,6 +280,7 @@ struct RootTabsSourceGuardTests { @Test func `settings about page shows concise public device details`() throws { let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) + let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8) let aboutDestination = try Self.extract( settingsSource, from: "var aboutDestination: some View", @@ -291,13 +292,20 @@ struct RootTabsSourceGuardTests { #expect(!aboutDestination.contains("detailStatusCard(")) #expect(aboutDestination.contains("detailListCard")) - #expect(aboutDestination.contains("SettingsDetailRow(\"OpenClaw app version\"")) + #expect(aboutDestination.contains("SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())")) + #expect(!aboutDestination.contains("SettingsDetailRow(\"OpenClaw app version\"")) #expect(aboutDestination.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) #expect(aboutDestination .contains("SettingsDetailRow(\"iOS\", value: DeviceInfoHelper.iOSVersionStringForDisplay())")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Version\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Platform\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Model\"")) + #expect(supportSource.contains("title: \"Version\"")) + #expect(supportSource.contains("title: \"Commit\"")) + #expect(supportSource.contains("title: \"Built\"")) + #expect(supportSource.contains("ViewThatFits(in: .horizontal)")) + #expect(supportSource.contains("Text(\"Unavailable\")")) + #expect(supportSource.contains(".textCase(.uppercase)")) #expect(diagnosticsDestination .contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) #expect(diagnosticsDestination diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 814e3dc2c6c..d86c92d113a 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -47,17 +47,20 @@ struct SwiftUIRenderSmokeTests { @Test @MainActor func `settings About destination builds in light and dark mode`() { for scheme in [ColorScheme.light, ColorScheme.dark] { - let appModel = NodeAppModel() - let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + for typeSize in [DynamicTypeSize.large, .accessibility2] { + let appModel = NodeAppModel() + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let root = SettingsProTab(directRoute: .about) - .environment(AppAppearanceModel()) - .environment(appModel) - .environment(appModel.voiceWake) - .environment(gatewayController) - .preferredColorScheme(scheme) + let root = SettingsProTab(directRoute: .about) + .environment(AppAppearanceModel()) + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + .environment(\.dynamicTypeSize, typeSize) + .preferredColorScheme(scheme) - _ = Self.host(root, size: CGSize(width: 393, height: 852)) + _ = Self.host(root, size: CGSize(width: 320, height: 852)) + } } } diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 769dfb286c6..1cd92fa050d 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -6,6 +6,7 @@ require "tmpdir" require "tempfile" require "cgi" require "digest/md5" +require "time" default_platform(:ios) @@ -1180,6 +1181,54 @@ def release_git_sha stdout.strip end +def normalize_release_git_sha(value) + commit = value.to_s.strip.downcase + UI.user_error!("Release build commit must be a full 40-character hexadecimal SHA.") unless commit.match?(/\A[0-9a-f]{40}\z/) + commit +end + +def normalize_release_build_timestamp(value) + timestamp = value.to_s.strip + unless timestamp.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z\z/) + UI.user_error!("Release build timestamp must be an ISO-8601 UTC timestamp ending in Z.") + end + begin + parsed = Time.iso8601(timestamp) + rescue ArgumentError + UI.user_error!("Release build timestamp must be a valid ISO-8601 UTC timestamp.") + end + fraction = timestamp[/\.(\d{1,3})Z\z/, 1].to_s.ljust(3, "0") + canonical_input = timestamp.sub(/(?:\.\d{1,3})?Z\z/, ".#{fraction}Z") + normalized = parsed.utc.iso8601(3) + UI.user_error!("Release build timestamp must be a valid ISO-8601 UTC timestamp.") unless normalized == canonical_input + normalized +end + +def verify_apple_release_source!(expected_commit) + script_path = File.join(repo_root, "scripts", "apple-release-source-check.sh") + sh(shell_join([ + "bash", + script_path, + "--root", + repo_root, + "--expected-commit", + expected_commit + ])) +end + +def pin_release_build_provenance! + commit = [ENV["GIT_COMMIT"], ENV["GIT_SHA"]].find { |value| env_present?(value) } || release_git_sha + timestamp = env_present?(ENV["OPENCLAW_BUILD_TIMESTAMP"]) ? ENV["OPENCLAW_BUILD_TIMESTAMP"] : Time.now.utc.iso8601(3) + normalized_commit = normalize_release_git_sha(commit) + verify_apple_release_source!(normalized_commit) + ENV["GIT_COMMIT"] = normalized_commit + ENV["OPENCLAW_BUILD_TIMESTAMP"] = normalize_release_build_timestamp(timestamp) + { + git_commit: ENV.fetch("GIT_COMMIT"), + build_timestamp: ENV.fetch("OPENCLAW_BUILD_TIMESTAMP") + } +end + def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil) args = [ "node", @@ -1222,9 +1271,18 @@ def record_mobile_release_ref!(platform:, version:, build: nil, version_code: ni ) end -def validate_app_store_ipa!(ipa_path) +def validate_app_store_ipa!(ipa_path, expected_commit:, expected_build_timestamp:) script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh") - sh(shell_join(["bash", script_path, "--ipa", ipa_path])) + sh(shell_join([ + "bash", + script_path, + "--ipa", + ipa_path, + "--expected-commit", + expected_commit, + "--expected-build-timestamp", + expected_build_timestamp + ])) end def build_app_store_release(context) @@ -1236,6 +1294,7 @@ def build_app_store_release(context) output_name = "OpenClaw-#{version}.ipa" expected_ipa_path = File.join(output_directory, output_name) + verify_apple_release_source!(context[:git_commit]) FileUtils.mkdir_p(output_directory) FileUtils.rm_rf(archive_path) Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) } @@ -1277,7 +1336,11 @@ def build_app_store_release(context) UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1 exported_ipa = exported_ipas.first FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path - validate_app_store_ipa!(expected_ipa_path) + validate_app_store_ipa!( + expected_ipa_path, + expected_commit: context[:git_commit], + expected_build_timestamp: context[:build_timestamp] + ) { archive_path: archive_path, @@ -1349,6 +1412,7 @@ platform :ios do version_metadata = read_ios_version_metadata(release_version: release_version) version = version_metadata[:version] short_version = version_metadata[:short_version] + provenance = pin_release_build_provenance! build_number = resolve_release_build_number( api_key: api_key, short_version: short_version, @@ -1358,7 +1422,9 @@ platform :ios do { api_key: api_key, + build_timestamp: provenance[:build_timestamp], build_number: build_number, + git_commit: provenance[:git_commit], release_xcconfig: release_xcconfig, short_version: short_version, version: version @@ -1416,13 +1482,13 @@ platform :ios do UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane upload is disabled.") end - release_sha = release_git_sha release_signing_check! context = prepare_app_store_context( require_api_key: true, release_version: options[:release_version], build_number: options[:build_number] ) + release_sha = context[:git_commit] ensure_mobile_release_ref_available!( platform: "ios", version: context[:short_version], diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 6fbdd03be2b..29b193d0677 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -158,6 +158,8 @@ targets: - "$(OPENCLAW_URL_SCHEME)" CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)" OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)" + OpenClawGitCommit: "$(OPENCLAW_GIT_COMMIT)" + OpenClawBuildTimestamp: "$(OPENCLAW_BUILD_TIMESTAMP)" OpenClawAppGroupIdentifier: "$(OPENCLAW_ACTIVE_APP_GROUP_ID)" CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)" UILaunchScreen: {} diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift index 1db19eb3a2a..d5b96ec9d51 100644 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -1,4 +1,6 @@ +import AppKit import OpenClawChatUI +import OpenClawKit import SwiftUI struct AboutSettings: View { @@ -37,13 +39,8 @@ struct AboutSettings: View { VStack(spacing: 3) { Text("OpenClaw") .font(.title3.bold()) - Text("Version \(self.versionString)") - .foregroundStyle(.secondary) - if let buildTimestamp { - Text("Built \(buildTimestamp)\(self.buildSuffix)") - .font(.footnote) - .foregroundStyle(.secondary) - } + AboutBuildMetadataStrip(metadata: self.buildMetadata) + .padding(.top, 3) Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") .font(.footnote) .foregroundStyle(.secondary) @@ -109,49 +106,129 @@ struct AboutSettings: View { } } - private var versionString: String { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - return build.map { "\(version) (\($0))" } ?? version + private var buildMetadata: ArtifactBuildInfo { + ArtifactBuildInfo(infoDictionary: Bundle.main.infoDictionary ?? [:]) + } +} + +private struct AboutBuildMetadataStrip: View { + let metadata: ArtifactBuildInfo + @Environment(\.layoutDirection) private var layoutDirection + + private struct Field: Identifiable { + enum ID: String { + case version + case commit + case built + } + + let id: ID + let title: LocalizedStringKey + let value: String? + let forceLeftToRight: Bool } - private var buildTimestamp: String? { - guard - let raw = - (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ?? - (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) - else { return nil } - let parser = ISO8601DateFormatter() - parser.formatOptions = [.withInternetDateTime] - guard let date = parser.date(from: raw) else { return raw } - - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.locale = .current - return formatter.string(from: date) + private var fields: [Field] { + [ + Field(id: .version, title: "Version", value: self.metadata.versionDisplay, forceLeftToRight: true), + Field(id: .commit, title: "Commit", value: self.metadata.shortCommit, forceLeftToRight: true), + Field(id: .built, title: "Built", value: self.metadata.localizedBuildDate(), forceLeftToRight: false), + ] } - private var gitCommit: String { - (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? - (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? - "unknown" + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 12) { + ForEach(Array(self.fields.enumerated()), id: \.element.id) { index, field in + if index > 0 { + Divider() + .frame(height: 28) + } + self.metadataField(field) + .fixedSize(horizontal: true, vertical: false) + } + } + .fixedSize(horizontal: true, vertical: false) + + VStack(alignment: .center, spacing: 7) { + ForEach(self.fields) { field in + self.metadataField(field) + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .accessibilityElement(children: .ignore) + .accessibilityLabel(self.metadataAccessibilityLabel) + .accessibilityActions { + if self.metadata.gitCommit != nil { + Button("Copy full commit hash") { + self.copyCommit() + } + } + Button("Copy build info") { + self.copyBuildInfo() + } + } + .contextMenu { + if self.metadata.gitCommit != nil { + Button("Copy Commit") { + self.copyCommit() + } + } + Button("Copy Build Info") { + self.copyBuildInfo() + } + } + .help(self.metadata.copyText) } - private var bundleID: String { - Bundle.main.bundleIdentifier ?? "unknown" + private func metadataField(_ field: Field) -> some View { + VStack(alignment: .center, spacing: 1) { + Text(field.title) + .font(.caption2.weight(.semibold)) + .textCase(.uppercase) + Group { + if let value = field.value { + Text(verbatim: value) + } else { + Text("Unavailable") + } + } + .font(.caption.monospaced()) + .environment( + \.layoutDirection, + field.forceLeftToRight ? .leftToRight : self.layoutDirection) + } } - private var buildSuffix: String { - let git = self.gitCommit - guard !git.isEmpty, git != "unknown" else { return "" } + private var metadataAccessibilityLabel: Text { + let version = self.metadata.versionDisplay + let commit = self.metadata.spokenCommit + let timestamp = self.metadata.buildTimestamp + let built = self.metadata.localizedBuildDate() ?? timestamp + if let commit, let timestamp, let built { + return Text("Version \(version), commit \(commit), built \(built), timestamp \(timestamp)") + } + if let commit { + return Text("Version \(version), commit \(commit), build date unavailable") + } + if let timestamp, let built { + return Text("Version \(version), commit unavailable, built \(built), timestamp \(timestamp)") + } + return Text("Version \(version), commit unavailable, build date unavailable") + } - var suffix = " (\(git)" - #if DEBUG - suffix += " DEBUG" - #endif - suffix += ")" - return suffix + private func copyCommit() { + guard let gitCommit = self.metadata.gitCommit else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(gitCommit, forType: .string) + } + + private func copyBuildInfo() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(self.metadata.copyText, forType: .string) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ArtifactBuildInfo.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ArtifactBuildInfo.swift new file mode 100644 index 00000000000..e6291b0812b --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ArtifactBuildInfo.swift @@ -0,0 +1,91 @@ +import Foundation + +public struct ArtifactBuildInfo: Equatable, Sendable { + public let version: String + public let build: String + public let gitCommit: String? + public let buildTimestamp: String? + public let builtAt: Date? + + public init( + infoDictionary: [String: Any], + versionKeys: [String] = ["CFBundleShortVersionString"]) + { + self.version = versionKeys.lazy.compactMap { Self.nonEmptyString(infoDictionary[$0]) }.first ?? "dev" + self.build = Self.nonEmptyString(infoDictionary["CFBundleVersion"]) ?? "" + self.gitCommit = Self.validGitCommit(Self.nonEmptyString(infoDictionary["OpenClawGitCommit"])) + let buildTimestamp = Self.nonEmptyString(infoDictionary["OpenClawBuildTimestamp"]) + self.builtAt = buildTimestamp.flatMap(Self.parseBuildTimestamp) + self.buildTimestamp = self.builtAt == nil ? nil : buildTimestamp + } + + public var versionDisplay: String { + if self.build.isEmpty || self.build == self.version { + return self.version + } + return "\(self.version) (\(self.build))" + } + + public var shortCommit: String? { + self.gitCommit.map { String($0.prefix(12)) } + } + + public var spokenCommit: String? { + self.gitCommit.map { $0.map(String.init).joined(separator: " ") } + } + + public func localizedBuildDate( + locale: Locale = .current, + timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) -> String? + { + guard let builtAt else { return nil } + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: builtAt) + } + + public var copyText: String { + [ + "Version \(self.versionDisplay)", + "Commit \(self.gitCommit ?? "Unavailable")", + "Built \(self.buildTimestamp ?? "Unavailable")", + ].joined(separator: "\n") + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func validGitCommit(_ value: String?) -> String? { + guard let value, value.utf8.count == 40 else { return nil } + let isAsciiHex = value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (65...70).contains(byte) || (97...102).contains(byte) + } + guard isAsciiHex else { return nil } + return value.lowercased() + } + + private static func parseBuildTimestamp(_ value: String) -> Date? { + let utcPattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$"# + guard value.range(of: utcPattern, options: .regularExpression) != nil else { return nil } + let withoutZulu = String(value.dropLast()) + let canonicalValue: String + if let fractionSeparator = withoutZulu.lastIndex(of: ".") { + let prefix = withoutZulu[...fractionSeparator] + let fraction = withoutZulu[withoutZulu.index(after: fractionSeparator)...] + let paddedFraction = String(fraction).padding(toLength: 3, withPad: "0", startingAt: 0) + canonicalValue = "\(prefix)\(paddedFraction)Z" + } else { + canonicalValue = "\(withoutZulu).000Z" + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = formatter.date(from: canonicalValue) else { return nil } + return formatter.string(from: date) == canonicalValue ? date : nil + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ArtifactBuildInfoTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ArtifactBuildInfoTests.swift new file mode 100644 index 00000000000..283339fde77 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ArtifactBuildInfoTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import OpenClawKit + +struct ArtifactBuildInfoTests { + @Test func `preserves full provenance and formats compact UTC values`() { + let commit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01" + let info = ArtifactBuildInfo( + infoDictionary: [ + "OpenClawCanonicalVersion": "2026.7.10", + "CFBundleShortVersionString": "2026.7.9", + "CFBundleVersion": "42", + "OpenClawGitCommit": commit, + "OpenClawBuildTimestamp": "2026-01-01T00:30:00.123Z", + ], + versionKeys: ["OpenClawCanonicalVersion", "CFBundleShortVersionString"]) + + #expect(info.versionDisplay == "2026.7.10 (42)") + #expect(info.gitCommit == commit.lowercased()) + #expect(info.shortCommit == "abcdef012345") + #expect(info.localizedBuildDate(locale: Locale(identifier: "en_US_POSIX")) == "Jan 1, 2026") + #expect(info.copyText.contains(commit.lowercased())) + #expect(info.copyText.contains("2026-01-01T00:30:00.123Z")) + #expect(info.spokenCommit == commit.lowercased().map(String.init).joined(separator: " ")) + } + + @Test func `reports missing or malformed optional provenance`() { + let info = ArtifactBuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "1.2.3", + "OpenClawGitCommit": "abc123", + "OpenClawBuildTimestamp": "not-a-date", + ]) + + #expect(info.versionDisplay == "1.2.3") + #expect(info.gitCommit == nil) + #expect(info.shortCommit == nil) + #expect(info.buildTimestamp == nil) + #expect(info.localizedBuildDate() == nil) + #expect(info.copyText.contains("Commit Unavailable")) + #expect(info.copyText.contains("Built Unavailable")) + #expect(info.spokenCommit == nil) + } + + @Test func `rejects non UTC timestamps and non ASCII commit digits`() { + let info = ArtifactBuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "OpenClawGitCommit": String(repeating: "A", count: 40), + "OpenClawBuildTimestamp": "2026-07-10T12:34:56+00:00", + ]) + + #expect(info.gitCommit == nil) + #expect(info.buildTimestamp == nil) + } + + @Test func `rejects impossible calendar dates and accepts short fractions`() { + let invalid = ArtifactBuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "OpenClawBuildTimestamp": "2026-02-30T12:34:56Z", + ]) + let valid = ArtifactBuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "OpenClawBuildTimestamp": "2026-07-10T12:34:56.7Z", + ]) + + #expect(invalid.buildTimestamp == nil) + #expect(valid.buildTimestamp == "2026-07-10T12:34:56.7Z") + #expect(valid.builtAt != nil) + } + + @Test func `retains commit independently when timestamp is absent`() { + let commit = "abcdef0123456789abcdef0123456789abcdef01" + let info = ArtifactBuildInfo(infoDictionary: [ + "CFBundleShortVersionString": "2026.7.10", + "OpenClawGitCommit": commit, + ]) + + #expect(info.shortCommit == "abcdef012345") + #expect(info.localizedBuildDate() == nil) + #expect(info.copyText.contains(commit)) + #expect(info.copyText.contains("Built Unavailable")) + } +} diff --git a/docs/install/docker.md b/docs/install/docker.md index 82897328935..d2206cc64f2 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -103,7 +103,12 @@ The default sandbox backend uses Docker when `agents.defaults.sandbox` is enable ### Manual flow ```bash -docker build -t openclaw:local -f Dockerfile . +BUILD_GIT_COMMIT="$(git rev-parse HEAD)" +BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +docker build \ + --build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}" \ + --build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \ + -t openclaw:local -f Dockerfile . docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ dist/index.js onboard --mode local --no-install-daemon docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ @@ -111,6 +116,11 @@ docker compose run --rm --no-deps --entrypoint node openclaw-gateway \ docker compose up -d openclaw-gateway ``` +The Docker context excludes `.git`. Pass the source identity as build arguments +as shown above so the image's About screen reports the checked-out commit and +one build timestamp. `scripts/docker/setup.sh` resolves and passes both values +automatically. + Run `docker compose` from the repo root. If you enabled `OPENCLAW_EXTRA_MOUNTS` or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`; include it after any `docker-compose.override.yml` you maintain yourself, e.g. `-f docker-compose.yml -f docker-compose.override.yml -f docker-compose.extra.yml`. diff --git a/scripts/apple-release-source-check.sh b/scripts/apple-release-source-check.sh new file mode 100755 index 00000000000..de7c98e9ab7 --- /dev/null +++ b/scripts/apple-release-source-check.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/apple-release-source-check.sh --root --expected-commit + +Verifies an Apple release build uses the clean checkout at the selected commit. +EOF +} + +EXPECTED_COMMIT="" +ROOT_DIR="" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/build-metadata.sh" + +require_option_value() { + local option="$1" + local value="${2-}" + + if [[ -z "${value}" || "${value}" == --* ]]; then + echo "Missing value for ${option}." >&2 + usage >&2 + exit 1 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --expected-commit) + require_option_value "$1" "${2-}" + EXPECTED_COMMIT="$2" + shift 2 + ;; + --root) + require_option_value "$1" "${2-}" + ROOT_DIR="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "${ROOT_DIR}" ]]; then + echo "Missing required --root." >&2 + usage >&2 + exit 1 +fi +if [[ -z "${EXPECTED_COMMIT}" ]]; then + echo "Missing required --expected-commit." >&2 + usage >&2 + exit 1 +fi + +EXPECTED_COMMIT="$(openclaw_trim_build_metadata_value "${EXPECTED_COMMIT}")" +if ! openclaw_is_full_git_commit "${EXPECTED_COMMIT}"; then + echo "Apple release commit must be a full 40-character hexadecimal SHA." >&2 + exit 1 +fi +EXPECTED_COMMIT="$(printf '%s' "${EXPECTED_COMMIT}" | tr '[:upper:]' '[:lower:]')" + +if ! CHECKOUT_COMMIT="$(git -C "${ROOT_DIR}" rev-parse --verify HEAD 2>/dev/null)"; then + echo "Apple release builds require a readable Git checkout." >&2 + exit 1 +fi +if ! openclaw_is_full_git_commit "${CHECKOUT_COMMIT}"; then + echo "Apple release checkout HEAD must be a full Git commit." >&2 + exit 1 +fi +CHECKOUT_COMMIT="$(printf '%s' "${CHECKOUT_COMMIT}" | tr '[:upper:]' '[:lower:]')" + +if [[ "${EXPECTED_COMMIT}" != "${CHECKOUT_COMMIT}" ]]; then + echo "Apple release commit mismatch: metadata ${EXPECTED_COMMIT}, checkout ${CHECKOUT_COMMIT}." >&2 + exit 1 +fi + +if ! CHECKOUT_STATUS="$(git -C "${ROOT_DIR}" status --porcelain=v1 --untracked-files=all 2>/dev/null)"; then + echo "Apple release builds require a readable Git checkout." >&2 + exit 1 +fi +if [[ -n "${CHECKOUT_STATUS}" ]]; then + echo "Apple release builds require a clean Git checkout." >&2 + exit 1 +fi + +echo "Verified Apple release source: commit=${CHECKOUT_COMMIT} clean=true" diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 3847c65dbc2..1980ccb7668 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -11,6 +11,7 @@ import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; const nodeBin = process.execPath; +const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; const BUILD_CACHE_VERSION = 3; const PLUGIN_SDK_ENTRY_DTS_CACHE_ENV = [ "OPENCLAW_BUILD_PRIVATE_QA", @@ -259,6 +260,38 @@ export function resolveBuildAllSteps(profile = "full") { }); } +function readCurrentGitCommit() { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : null; +} + +/** Pin one source identity for every child process that contributes to this build. */ +export function resolveBuildAllEnvironment( + env = process.env, + now = () => new Date(), + readGitCommit = readCurrentGitCommit, +) { + const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim(); + const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); + const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim(); + // GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. + const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim(); + if (commit && !FULL_GIT_COMMIT_RE.test(commit)) { + throw new Error("build commit must be a full 40-character hexadecimal SHA"); + } + const buildEnv = { + ...env, + OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(), + }; + if (commit) { + buildEnv.GIT_COMMIT = commit.toLowerCase(); + } + return buildEnv; +} + function resolveStepEnv(step, env, platform) { const stepEnv = step.env ? Object.assign({}, env, step.env) : env; if (platform !== "win32" || !step.windowsNodeOptions) { @@ -586,11 +619,12 @@ if (isMainModule()) { if (args?.help) { console.log(buildAllUsage()); } else { + const buildEnv = resolveBuildAllEnvironment(); const timings = []; let exitCode = 0; for (const step of resolveBuildAllSteps(args.profile)) { const startedAt = performance.now(); - const cacheState = resolveBuildAllStepCacheState(step); + const cacheState = resolveBuildAllStepCacheState(step, { env: buildEnv }); if (process.env.OPENCLAW_BUILD_CACHE !== "0" && cacheState.fresh) { restoreBuildAllStepCacheOutputs(cacheState); const durationMs = performance.now() - startedAt; @@ -599,7 +633,7 @@ if (isMainModule()) { continue; } console.error(`[build-all] ${step.label}`); - const invocation = resolveBuildAllStep(step); + const invocation = resolveBuildAllStep(step, { env: buildEnv }); const result = spawnSync(invocation.command, invocation.args, invocation.options); const durationMs = performance.now() - startedAt; if (typeof result.status === "number") { @@ -611,7 +645,10 @@ if (isMainModule()) { exitCode = result.status; break; } - writeBuildAllStepCacheStamp(step, resolveBuildAllStepCacheStampState(step, cacheState)); + writeBuildAllStepCacheStamp( + step, + resolveBuildAllStepCacheStampState(step, cacheState, { env: buildEnv }), + ); timings.push({ label: step.label, status: "ran", durationMs }); console.error(`[build-all] ${step.label} done in ${formatBuildAllDuration(durationMs)}`); continue; diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 2057ab0d2ba..996fc606b0e 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -866,7 +866,12 @@ const server = await createServer({ clearScreen: false, configFile: path.join(uiRoot, "vite.config.ts"), define: { - OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify("mock"), + "globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify({ + version: "2026.7.10", + commit: "0123456789abcdef0123456789abcdef01234567", + builtAt: "2026-07-10T12:34:56.000Z", + buildId: "mock", + }), }, logLevel: "error", optimizeDeps: { diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 0719ba8c833..5b8ca2001fe 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/lib/docker-build.sh" +source "$ROOT_DIR/scripts/lib/build-metadata.sh" source "$ROOT_DIR/scripts/lib/host-timeout.sh" COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml" @@ -754,7 +755,14 @@ if [[ -n "$OFFLINE_MODE" ]]; then echo "==> Using preloaded Docker image: $IMAGE_NAME" elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then echo "==> Building Docker image: $IMAGE_NAME" + BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")" + BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)" + PROVENANCE_BUILD_ARGS=(--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}") + if [[ "$BUILD_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then + PROVENANCE_BUILD_ARGS+=(--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}") + fi run_docker_build \ + "${PROVENANCE_BUILD_ARGS[@]}" \ --build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \ --build-arg "OPENCLAW_IMAGE_PIP_PACKAGES=${OPENCLAW_IMAGE_PIP_PACKAGES}" \ --build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \ diff --git a/scripts/ios-release-prepare.sh b/scripts/ios-release-prepare.sh index f41afe8754f..a12285ee7ec 100755 --- a/scripts/ios-release-prepare.sh +++ b/scripts/ios-release-prepare.sh @@ -24,6 +24,7 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh" IOS_VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts" VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts" RELEASE_SIGNING_HELPER="${ROOT_DIR}/scripts/ios-release-signing.mjs" +RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh" CANONICAL_TEAM_ID="FWJYW4S8P8" BUILD_NUMBER="" @@ -31,6 +32,7 @@ RELEASE_VERSION="" TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" IOS_VERSION="" RELEASE_SIGNING_XCCONFIG="" +RELEASE_GIT_COMMIT="" prepare_build_dir() { if [[ -L "${BUILD_DIR}" ]]; then @@ -129,6 +131,11 @@ if [[ -n "${OPENCLAW_PUSH_RELAY_BASE_URL:-}" || -n "${IOS_PUSH_RELAY_BASE_URL:-} exit 1 fi +source "${ROOT_DIR}/scripts/lib/build-metadata.sh" +RELEASE_GIT_COMMIT="$(OPENCLAW_REQUIRE_BUILD_METADATA=1 openclaw_resolve_git_commit "${ROOT_DIR}")" +bash "${RELEASE_SOURCE_HELPER}" --root "${ROOT_DIR}" --expected-commit "${RELEASE_GIT_COMMIT}" +export GIT_COMMIT="${RELEASE_GIT_COMMIT}" + prepare_build_dir ( @@ -148,7 +155,8 @@ if [[ -z "${RELEASE_SIGNING_XCCONFIG}" ]]; then fi ( - bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}" + OPENCLAW_REQUIRE_BUILD_METADATA=1 \ + bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}" ) node "${ROOT_DIR}/scripts/ios-write-swift-filelist.mjs" diff --git a/scripts/ios-validate-app-store-ipa.sh b/scripts/ios-validate-app-store-ipa.sh index e7f88a0bce1..9e4f106e983 100755 --- a/scripts/ios-validate-app-store-ipa.sh +++ b/scripts/ios-validate-app-store-ipa.sh @@ -4,13 +4,16 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-validate-app-store-ipa.sh --ipa apps/ios/build/app-store/OpenClaw-.ipa + scripts/ios-validate-app-store-ipa.sh --ipa apps/ios/build/app-store/OpenClaw-.ipa \ + [--expected-commit ] [--expected-build-timestamp ] Validates the exported iOS App Store IPA before App Store Connect upload. EOF } IPA_PATH="" +EXPECTED_GIT_COMMIT="" +EXPECTED_BUILD_TIMESTAMP="" EXPECTED_TEAM_ID="FWJYW4S8P8" EXPECTED_BUNDLE_ID="ai.openclawfoundation.app" EXPECTED_PROFILE_NAME="OpenClaw App Store ai.openclawfoundation.app" @@ -40,6 +43,16 @@ while [[ $# -gt 0 ]]; do IPA_PATH="$2" shift 2 ;; + --expected-commit) + require_option_value "$1" "${2-}" + EXPECTED_GIT_COMMIT="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')" + shift 2 + ;; + --expected-build-timestamp) + require_option_value "$1" "${2-}" + EXPECTED_BUILD_TIMESTAMP="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -158,8 +171,32 @@ assert_plist_empty_or_absent() { fi } +assert_build_provenance() { + local commit + local timestamp + commit="$(plist_value "${info_plist}" "OpenClawGitCommit")" + timestamp="$(plist_value "${info_plist}" "OpenClawBuildTimestamp")" + if [[ ! "${commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid IPA: OpenClawGitCommit must be a full lowercase commit SHA; got ${commit:-missing}." >&2 + exit 1 + fi + if [[ ! "${timestamp}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$ ]]; then + echo "Invalid IPA: OpenClawBuildTimestamp must be a canonical UTC timestamp; got ${timestamp:-missing}." >&2 + exit 1 + fi + if [[ -n "${EXPECTED_GIT_COMMIT}" && "${commit}" != "${EXPECTED_GIT_COMMIT}" ]]; then + echo "Invalid IPA: embedded Git commit mismatch; expected ${EXPECTED_GIT_COMMIT}, got ${commit}." >&2 + exit 1 + fi + if [[ -n "${EXPECTED_BUILD_TIMESTAMP}" && "${timestamp}" != "${EXPECTED_BUILD_TIMESTAMP}" ]]; then + echo "Invalid IPA: embedded build timestamp mismatch; expected ${EXPECTED_BUILD_TIMESTAMP}, got ${timestamp}." >&2 + exit 1 + fi +} + assert_plist_string "${info_plist}" "CFBundleIdentifier" "${EXPECTED_BUNDLE_ID}" "bundle identifier mismatch" assert_plist_string "${info_plist}" "OpenClawPushMode" "${EXPECTED_PUSH_MODE}" "push mode mismatch" +assert_build_provenance assert_plist_empty_or_absent "${info_plist}" "OpenClawPushRelayBaseURL" "push relay URL override" assert_plist_key_absent "${info_plist}" "OpenClawPushTransport" "legacy push transport" assert_plist_key_absent "${info_plist}" "OpenClawPushDistribution" "legacy push distribution" diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh index 2734ffd9f76..fd993e72d1b 100755 --- a/scripts/ios-write-version-xcconfig.sh +++ b/scripts/ios-write-version-xcconfig.sh @@ -14,6 +14,7 @@ EOF } ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "${ROOT_DIR}/scripts/lib/build-metadata.sh" IOS_DIR="${ROOT_DIR}/apps/ios" BUILD_DIR="${IOS_DIR}/build" VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig" @@ -22,6 +23,8 @@ IOS_VERSION="" MARKETING_VERSION="" BUILD_NUMBER="" RELEASE_VERSION="" +RESOLVED_GIT_COMMIT="" +RESOLVED_BUILD_TIMESTAMP="" require_option_value() { local option="$1" @@ -114,6 +117,9 @@ if [[ ! "${BUILD_NUMBER}" =~ ^[0-9]+$ ]]; then exit 1 fi +RESOLVED_GIT_COMMIT="$(openclaw_resolve_git_commit "${ROOT_DIR}")" +RESOLVED_BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)" + prepare_build_dir write_generated_file "${VERSION_XCCONFIG}" <= 1 && month_number <= 12)) || return 1 + ((hour_number <= 23 && minute_number <= 59 && second_number <= 59)) || return 1 + case "${month_number}" in + 1 | 3 | 5 | 7 | 8 | 10 | 12) max_day=31 ;; + 4 | 6 | 9 | 11) max_day=30 ;; + 2) + max_day=28 + if ((year_number % 400 == 0 || (year_number % 4 == 0 && year_number % 100 != 0))); then + max_day=29 + fi + ;; + esac + ((day_number >= 1 && day_number <= max_day)) || return 1 + + fraction="${fraction#.}" + case "${#fraction}" in + 0) fraction="000" ;; + 1) fraction="${fraction}00" ;; + 2) fraction="${fraction}0" ;; + esac + printf '%s-%s-%sT%s:%s:%s.%sZ' \ + "${year}" "${month}" "${day}" "${hour}" "${minute}" "${second}" "${fraction}" +} + +openclaw_is_utc_build_timestamp() { + openclaw_normalize_utc_build_timestamp "${1:-}" >/dev/null +} + +openclaw_resolve_git_commit() { + local root_dir="$1" + local candidate + local source_name + for source_name in GIT_COMMIT GIT_SHA; do + candidate="$(openclaw_trim_build_metadata_value "${!source_name:-}")" + [[ -n "${candidate}" ]] && break + done + if [[ -n "${candidate}" ]]; then + if ! openclaw_is_full_git_commit "${candidate}"; then + echo "ERROR: ${source_name} must be a full 40-character hexadecimal commit." >&2 + return 1 + fi + printf '%s' "${candidate}" | tr '[:upper:]' '[:lower:]' + return 0 + fi + + candidate="$( (cd "${root_dir}" && git rev-parse HEAD) 2>/dev/null || true)" + if [[ -n "${candidate}" ]] && ! openclaw_is_full_git_commit "${candidate}"; then + echo "ERROR: git rev-parse HEAD must return a full 40-character hexadecimal commit." >&2 + return 1 + fi + # GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. + if [[ -z "${candidate}" ]]; then + candidate="$(openclaw_trim_build_metadata_value "${GITHUB_SHA:-}")" + if [[ -n "${candidate}" ]] && ! openclaw_is_full_git_commit "${candidate}"; then + echo "ERROR: GITHUB_SHA must be a full 40-character hexadecimal commit." >&2 + return 1 + fi + fi + if [[ -z "${candidate}" ]]; then + if [[ "${OPENCLAW_REQUIRE_BUILD_METADATA:-0}" == "1" ]]; then + echo "ERROR: Unable to resolve a full Git commit for the release build." >&2 + return 1 + fi + printf 'unknown' + return 0 + fi + printf '%s' "${candidate}" | tr '[:upper:]' '[:lower:]' +} + +openclaw_resolve_build_timestamp() { + local candidate + candidate="$(openclaw_trim_build_metadata_value "${OPENCLAW_BUILD_TIMESTAMP:-}")" + if [[ -n "${candidate}" ]]; then + if ! candidate="$(openclaw_normalize_utc_build_timestamp "${candidate}")"; then + echo "ERROR: OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp." >&2 + return 1 + fi + else + candidate="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + if ! candidate="$(openclaw_normalize_utc_build_timestamp "${candidate}")"; then + echo "ERROR: Unable to resolve an ISO-8601 UTC timestamp for the build." >&2 + return 1 + fi + fi + printf '%s' "${candidate}" +} diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index 01efd177df6..68a155c3afc 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -8,6 +8,7 @@ import { formatErrorMessage } from "../src/infra/errors.ts"; import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts"; import { preparePackageChangelog } from "./package-changelog.mjs"; import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs"; +const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; const requiredPreparedPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/control-ui/index.html"], @@ -155,13 +156,42 @@ function run(command: string, args: string[], options: SpawnSyncOptions = {}): v process.exit(result.status ?? 1); } -function runPnpm(args: string[]): void { +export function resolvePrepackBuildEnvironment( + env: NodeJS.ProcessEnv = process.env, + now: () => Date = () => new Date(), + readGitCommit: () => string | null = () => { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : null; + }, +): NodeJS.ProcessEnv { + const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim(); + const explicitCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); + const checkedOutCommit = explicitCommit ? null : readGitCommit()?.trim(); + // GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. + const commit = explicitCommit || checkedOutCommit || env.GITHUB_SHA?.trim(); + if (commit && !FULL_GIT_COMMIT_RE.test(commit)) { + throw new Error("build commit must be a full 40-character hexadecimal SHA"); + } + const buildEnv: NodeJS.ProcessEnv = { + ...env, + OPENCLAW_BUILD_TIMESTAMP: explicitTimestamp || now().toISOString(), + }; + if (commit) { + buildEnv.GIT_COMMIT = commit.toLowerCase(); + } + return buildEnv; +} + +function runPnpm(args: string[], env: NodeJS.ProcessEnv): void { const command = createPnpmRunnerSpawnSpec({ - env: process.env, + env, pnpmArgs: args, stdio: "inherit", }); - run(command.command, command.args, command.options); + run(command.command, command.args, { ...command.options, env }); } function runBuildSmoke(): void { @@ -173,8 +203,9 @@ async function writeDistInventory(): Promise { } async function main(): Promise { - runPnpm(["build"]); - runPnpm(["ui:build"]); + const buildEnv = resolvePrepackBuildEnvironment(); + runPnpm(["build"], buildEnv); + runPnpm(["ui:build"], buildEnv); ensurePreparedArtifacts(); await writeDistInventory(); runBuildSmoke(); diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index f4ff41e6612..77023dd7c76 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -7,6 +7,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" source "$ROOT_DIR/scripts/lib/plistbuddy.sh" source "$ROOT_DIR/scripts/lib/swift-toolchain.sh" +source "$ROOT_DIR/scripts/lib/build-metadata.sh" APP_ROOT="$ROOT_DIR/dist/OpenClaw.app" BUILD_ROOT="$ROOT_DIR/apps/macos/.build" PRODUCT="OpenClaw" @@ -15,12 +16,26 @@ MLX_TTS_HELPER_ROOT="$ROOT_DIR/apps/macos-mlx-tts" MLX_TTS_HELPER_BUILD_ROOT="$MLX_TTS_HELPER_ROOT/.build" BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}" PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" -BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_CONFIG="${BUILD_CONFIG:-debug}" +BUILD_TS="$(openclaw_resolve_build_timestamp)" +if [[ "$BUILD_CONFIG" == "release" ]]; then + OPENCLAW_REQUIRE_BUILD_METADATA=1 +fi +BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")" +if [[ "$BUILD_CONFIG" == "release" ]]; then + bash "$ROOT_DIR/scripts/apple-release-source-check.sh" \ + --root "$ROOT_DIR" \ + --expected-commit "$BUILD_GIT_COMMIT" +fi +export OPENCLAW_BUILD_TIMESTAMP="$BUILD_TS" +if openclaw_is_full_git_commit "$BUILD_GIT_COMMIT"; then + export GIT_COMMIT="$BUILD_GIT_COMMIT" +else + unset GIT_COMMIT +fi GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || echo "0") APP_VERSION="${APP_VERSION:-$PKG_VERSION}" APP_BUILD="${APP_BUILD:-}" -BUILD_CONFIG="${BUILD_CONFIG:-debug}" if [[ -n "${BUILD_ARCHS:-}" ]]; then BUILD_ARCHS_VALUE="${BUILD_ARCHS}" elif [[ "$BUILD_CONFIG" == "release" ]]; then @@ -222,7 +237,14 @@ plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleIdentifier "$B plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleShortVersionString "$APP_VERSION" plist_set_string_required "$APP_ROOT/Contents/Info.plist" CFBundleVersion "$APP_BUILD" plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawBuildTimestamp "$BUILD_TS" -plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit "$GIT_COMMIT" +plist_set_string_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit "$BUILD_GIT_COMMIT" +if [[ "$BUILD_CONFIG" == "release" ]]; then + EMBEDDED_GIT_COMMIT="$(plist_print_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit)" + if [[ "$EMBEDDED_GIT_COMMIT" != "$BUILD_GIT_COMMIT" ]]; then + echo "ERROR: Release app embedded Git commit '$EMBEDDED_GIT_COMMIT', expected '$BUILD_GIT_COMMIT'." >&2 + exit 1 + fi +fi plist_set_or_add_string "$APP_ROOT/Contents/Info.plist" SUFeedURL "$SPARKLE_FEED_URL" plist_set_or_add_string "$APP_ROOT/Contents/Info.plist" SUPublicEDKey "$SPARKLE_PUBLIC_ED_KEY" plist_set_or_add_bool "$APP_ROOT/Contents/Info.plist" SUEnableAutomaticChecks "$AUTO_CHECKS" diff --git a/scripts/podman/setup.sh b/scripts/podman/setup.sh index e47e7101ecb..5c7151a7bd3 100755 --- a/scripts/podman/setup.sh +++ b/scripts/podman/setup.sh @@ -17,6 +17,7 @@ set -euo pipefail REPO_PATH="${OPENCLAW_REPO_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +source "$REPO_PATH/scripts/lib/build-metadata.sh" source "$REPO_PATH/scripts/lib/host-timeout.sh" RUN_SCRIPT_SRC="$REPO_PATH/scripts/run-openclaw-podman.sh" QUADLET_TEMPLATE="$REPO_PATH/scripts/podman/openclaw.container.in" @@ -373,6 +374,12 @@ ensure_private_existing_dir_owned_by_user "workspace directory" "$OPENCLAW_WORKS OPENCLAW_IMAGE_APT_PACKAGES="${OPENCLAW_IMAGE_APT_PACKAGES-${OPENCLAW_DOCKER_APT_PACKAGES:-}}" OPENCLAW_IMAGE_PIP_PACKAGES="${OPENCLAW_IMAGE_PIP_PACKAGES:-}" BUILD_ARGS=() +BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$REPO_PATH")" +BUILD_TIMESTAMP="$(openclaw_resolve_build_timestamp)" +BUILD_ARGS+=(--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}") +if [[ "$BUILD_GIT_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]]; then + BUILD_ARGS+=(--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}") +fi if [[ -n "$OPENCLAW_IMAGE_APT_PACKAGES" ]]; then BUILD_ARGS+=(--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}") fi diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 889eaf1f30a..80e6083d19b 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1007,6 +1007,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["test/scripts/openclaw-cross-os-release-workflow.test.ts"], ], ["scripts/mobile-release-ref.ts", ["test/scripts/mobile-release-ref.test.ts"]], + ["scripts/apple-release-source-check.sh", ["test/scripts/apple-release-source-check.test.ts"]], ["scripts/android-release.sh", ["test/scripts/android-release-wrapper-args.test.ts"]], ["scripts/android-release-signing.mjs", ["test/scripts/android-release-signing.test.ts"]], ["scripts/android-release-upload.sh", ["test/scripts/android-release-wrapper-args.test.ts"]], @@ -1016,7 +1017,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ], ["apps/android/fastlane/Fastfile", ["test/scripts/android-release-fastlane-gates.test.ts"]], ["scripts/ios-release-archive.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]], - ["scripts/ios-release-prepare.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]], + [ + "scripts/ios-release-prepare.sh", + ["test/scripts/ios-release-prepare.test.ts", "test/scripts/ios-release-wrapper-args.test.ts"], + ], ["scripts/ios-release-signing.mjs", ["test/scripts/ios-release-signing.test.ts"]], ["apps/ios/fastlane/Fastfile", ["test/scripts/ios-release-fastlane-gates.test.ts"]], [ @@ -1241,6 +1245,16 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "test/release-check.test.ts", ], ], + [ + "scripts/lib/build-metadata.sh", + [ + "src/docker-setup.e2e.test.ts", + "test/scripts/apple-release-source-check.test.ts", + "test/scripts/ios-version.test.ts", + "test/scripts/package-mac-app.test.ts", + "test/scripts/test-install-sh-docker.test.ts", + ], + ], [ "scripts/lib/plistbuddy.sh", [ @@ -1419,6 +1433,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ], ["scripts/podman/openclaw.container.in", ["test/scripts/test-install-sh-docker.test.ts"]], ["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]], + ["scripts/ios-write-version-xcconfig.sh", ["test/scripts/ios-version.test.ts"]], ["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]], ["scripts/kova-ci-summary.mjs", ["test/scripts/kova-ci-summary.test.ts"]], ["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]], diff --git a/scripts/write-build-info.ts b/scripts/write-build-info.ts index dd77ddec512..c315af17a30 100644 --- a/scripts/write-build-info.ts +++ b/scripts/write-build-info.ts @@ -1,48 +1,140 @@ -// Write Build Info script supports OpenClaw repository automation. -import { execSync } from "node:child_process"; +// Writes canonical package build provenance for runtime and release diagnostics. +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const distDir = path.join(rootDir, "dist"); -const pkgPath = path.join(rootDir, "package.json"); +const defaultRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; +const UTC_ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; -const readPackageVersion = () => { +type ExecFileSync = ( + command: string, + args: string[], + options: { + cwd: string; + encoding: "utf8"; + stdio: ["ignore", "pipe", "ignore"]; + }, +) => string | Buffer; + +export type BuildInfo = { + version: string | null; + commit: string | null; + builtAt: string; +}; + +type ResolveBuildInfoOptions = { + rootDir?: string; + env?: NodeJS.ProcessEnv; + execFileSync?: ExecFileSync; + now?: () => Date; +}; + +function readPackageVersion(rootDir: string): string | null { try { - const raw = fs.readFileSync(pkgPath, "utf8"); - const parsed = JSON.parse(raw) as { version?: string }; - return parsed.version ?? null; + const raw = fs.readFileSync(path.join(rootDir, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version.trim() + ? parsed.version.trim() + : null; } catch { return null; } -}; +} -const resolveCommit = () => { - const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim(); - if (envCommit) { - return envCommit; +export function normalizeBuildCommit(raw: string, source = "GIT_COMMIT"): string { + const commit = raw.trim().toLowerCase(); + if (!FULL_GIT_COMMIT_RE.test(commit)) { + throw new Error(`${source} must be a full 40-character Git commit SHA.`); } + return commit; +} + +export function normalizeBuildTimestamp(raw: string, source = "OPENCLAW_BUILD_TIMESTAMP"): string { + const timestamp = raw.trim(); + if (!UTC_ISO_TIMESTAMP_RE.test(timestamp)) { + throw new Error(`${source} must be an ISO-8601 UTC timestamp ending in Z.`); + } + + const parsed = new Date(timestamp); + if (!Number.isFinite(parsed.getTime())) { + throw new Error(`${source} must be a valid ISO-8601 UTC timestamp.`); + } + + const normalizedInput = timestamp.replace(/(?:\.(\d{1,3}))?Z$/u, (_match, fraction) => { + return `.${String(fraction ?? "").padEnd(3, "0")}Z`; + }); + const normalized = parsed.toISOString(); + if (normalized !== normalizedInput) { + throw new Error(`${source} must be a valid ISO-8601 UTC timestamp.`); + } + return normalized; +} + +function resolveGitCommit(rootDir: string, execFileSyncImpl: ExecFileSync): string | null { + let raw: string; try { - return execSync("git rev-parse HEAD", { + raw = execFileSyncImpl("git", ["rev-parse", "HEAD"], { cwd: rootDir, + encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }) - .toString() - .trim(); + }).toString(); } catch { return null; } -}; + return normalizeBuildCommit(raw, "git rev-parse HEAD"); +} -const version = readPackageVersion(); -const commit = resolveCommit(); +export function resolveBuildInfo(options: ResolveBuildInfoOptions = {}): BuildInfo { + const rootDir = options.rootDir ?? defaultRootDir; + const env = options.env ?? process.env; + const explicitCommit = env.GIT_COMMIT?.trim(); + const explicitSha = env.GIT_SHA?.trim(); + const githubSha = env.GITHUB_SHA?.trim(); + const explicitTimestamp = env.OPENCLAW_BUILD_TIMESTAMP?.trim(); + const checkedOutCommit = + explicitCommit || explicitSha + ? null + : resolveGitCommit(rootDir, options.execFileSync ?? execFileSync); + // GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. + const commit = explicitCommit + ? normalizeBuildCommit(explicitCommit) + : explicitSha + ? normalizeBuildCommit(explicitSha, "GIT_SHA") + : (checkedOutCommit ?? (githubSha ? normalizeBuildCommit(githubSha, "GITHUB_SHA") : null)); + const builtAt = explicitTimestamp + ? normalizeBuildTimestamp(explicitTimestamp) + : (options.now ?? (() => new Date()))().toISOString(); -const buildInfo = { - version, - commit, - builtAt: new Date().toISOString(), -}; + return { + version: readPackageVersion(rootDir), + commit, + builtAt, + }; +} -fs.mkdirSync(distDir, { recursive: true }); -fs.writeFileSync(path.join(distDir, "build-info.json"), `${JSON.stringify(buildInfo, null, 2)}\n`); +export function writeBuildInfo(options: ResolveBuildInfoOptions = {}): string { + const rootDir = options.rootDir ?? defaultRootDir; + const distDir = path.join(rootDir, "dist"); + const outputPath = path.join(distDir, "build-info.json"); + const buildInfo = resolveBuildInfo({ ...options, rootDir }); + + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(buildInfo, null, 2)}\n`); + return outputPath; +} + +function isMainModule(): boolean { + const argv1 = process.argv[1]; + return Boolean(argv1 && import.meta.url === pathToFileURL(argv1).href); +} + +if (isMainModule()) { + try { + writeBuildInfo(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 78b5838d1dc..62964e7d643 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -141,6 +141,10 @@ async function createDockerSetupSandbox(): Promise { join(repoRoot, "scripts", "lib", "docker-build.sh"), join(rootDir, "scripts", "lib", "docker-build.sh"), ); + await copyFile( + join(repoRoot, "scripts", "lib", "build-metadata.sh"), + join(rootDir, "scripts", "lib", "build-metadata.sh"), + ); await copyFile( join(repoRoot, "scripts", "lib", "docker-e2e-logs.sh"), join(rootDir, "scripts", "lib", "docker-e2e-logs.sh"), @@ -355,8 +359,10 @@ describe("scripts/docker/setup.sh", () => { it("handles env defaults, home-volume mounts, and Docker build args", async () => { const activeSandbox = requireSandbox(sandbox); + const buildCommit = "0123456789abcdef0123456789abcdef01234567"; const result = runDockerSetup(activeSandbox, { + GIT_COMMIT: buildCommit, OPENCLAW_DOCKER_APT_PACKAGES: "curl wget", OPENCLAW_EXTRA_MOUNTS: undefined, OPENCLAW_HOME_VOLUME: "openclaw-home", @@ -390,6 +396,10 @@ describe("scripts/docker/setup.sh", () => { ); expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB="); expect(log).toContain("--build-arg OPENCLAW_DOCKER_BUILD_SKIP_DTS=1"); + expect(log).toMatch( + /--build-arg OPENCLAW_BUILD_TIMESTAMP=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/u, + ); + expect(log).toContain(`--build-arg GIT_COMMIT=${buildCommit}`); expect(log).toContain( `run --rm --no-deps ${prestartContainerEnvFlags} --entrypoint node openclaw-gateway dist/index.js onboard --mode local --no-install-daemon --gateway-auth token --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN --skip-ui --suppress-gateway-token-output`, ); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 703218df524..7968e07440d 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePath = join(repoRoot, "Dockerfile"); +const dockerInstallDocsPath = join(repoRoot, "docs/install/docker.md"); const dockerReleaseWorkflowPath = join(repoRoot, ".github/workflows/docker-release.yml"); const fullReleaseValidationWorkflowPath = join( repoRoot, @@ -193,10 +194,7 @@ describe("Dockerfile", () => { it("does not let pnpm resync the full source workspace during Docker build scripts", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); const collapsed = collapseDockerContinuations(dockerfile); - const qaLabBuildBlock = - /RUN if printf '%s\\n' "\$OPENCLAW_EXTENSIONS" \| tr ',' ' ' \| tr ' ' '\\n' \| grep -qx 'qa-lab'; then\s+pnpm_config_verify_deps_before_run=false pnpm qa:lab:build &&\s+mkdir -p dist\/extensions\/qa-lab\/web &&\s+rm -rf dist\/extensions\/qa-lab\/web\/dist &&\s+cp -R extensions\/qa-lab\/web\/dist dist\/extensions\/qa-lab\/web\/dist;\s+fi/u; const qaLabExtensionCheckIndex = collapsed.indexOf("grep -qx 'qa-lab'"); - const qaLabBuildBlockMatch = qaLabBuildBlock.exec(collapsed); const privateQaExportIndex = collapsed.indexOf( "export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1", ); @@ -212,7 +210,6 @@ describe("Dockerfile", () => { const runtimeAssetsIndex = collapsed.indexOf("FROM build AS runtime-assets"); expect(qaLabExtensionCheckIndex).toBeGreaterThan(-1); - expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(-1); expect(buildDockerIndex).toBeGreaterThan(-1); expect(qaLabBuildIndex).toBeGreaterThan(-1); expect(qaLabDistCopyIndex).toBeGreaterThan(-1); @@ -220,7 +217,6 @@ describe("Dockerfile", () => { expect(privateQaExportIndex).toBeGreaterThan(qaLabExtensionCheckIndex); expect(privateQaExportIndex).toBeLessThan(buildDockerIndex); expect(qaLabBuildIndex).toBeGreaterThan(buildDockerIndex); - expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(buildDockerIndex); expect(qaLabDistCopyIndex).toBeGreaterThan(qaLabBuildIndex); expect(qaLabDistCopyIndex).toBeLessThan(runtimeAssetsIndex); expect(dockerfile).toContain( @@ -230,6 +226,34 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("pnpm_config_verify_deps_before_run=false pnpm qa:lab:build"); }); + it("shares public source provenance across backend and Control UI builds", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + const commitArgIndex = dockerfile.indexOf('ARG GIT_COMMIT=""'); + const timestampArgIndex = dockerfile.indexOf('ARG OPENCLAW_BUILD_TIMESTAMP=""'); + const provenanceEnvIndex = dockerfile.indexOf("ENV GIT_COMMIT=${GIT_COMMIT}"); + const backendBuildIndex = dockerfile.indexOf("pnpm build:docker"); + const uiBuildIndex = dockerfile.indexOf("pnpm ui:build"); + + expect(commitArgIndex).toBeGreaterThan(installIndex); + expect(timestampArgIndex).toBeGreaterThan(commitArgIndex); + expect(provenanceEnvIndex).toBeGreaterThan(timestampArgIndex); + expect(dockerfile).toContain("OPENCLAW_BUILD_TIMESTAMP=${OPENCLAW_BUILD_TIMESTAMP}"); + expect(dockerfile).toContain('OPENCLAW_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"'); + expect(backendBuildIndex).toBeGreaterThan(provenanceEnvIndex); + expect(uiBuildIndex).toBeGreaterThan(backendBuildIndex); + }); + + it("documents provenance arguments for manual source builds", async () => { + const docs = await readFile(dockerInstallDocsPath, "utf8"); + + expect(docs).toContain('BUILD_GIT_COMMIT="$(git rev-parse HEAD)"'); + expect(docs).toContain('BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"'); + expect(docs).toContain('--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}"'); + expect(docs).toContain('--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"'); + expect(docs).toContain("The Docker context excludes `.git`."); + }); + it("prunes runtime dependencies and omitted plugin packages after the build stage", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("FROM build AS runtime-assets"); @@ -327,6 +351,31 @@ describe("Dockerfile", () => { expect(workflow).not.toContain("OPENCLAW_EXTENSIONS=diagnostics-otel\n"); }); + it("uses one source commit and timestamp for every official Docker artifact", async () => { + const workflow = await readFile(dockerReleaseWorkflowPath, "utf8"); + + expect(workflow).toContain("resolve_build_provenance:"); + expect(workflow).toContain("built_at: ${{ steps.build_provenance.outputs.built_at }}"); + expect(workflow).toContain("source_sha: ${{ steps.build_provenance.outputs.source_sha }}"); + expect(workflow.match(/date -u \+%Y-%m-%dT%H:%M:%SZ/gu)).toHaveLength(1); + expect( + workflow.split("BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}") + .length - 1, + ).toBe(2); + expect( + workflow.split("ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}").length - 1, + ).toBe(4); + expect( + workflow.split("GIT_COMMIT=${{ needs.resolve_build_provenance.outputs.source_sha }}").length - + 1, + ).toBe(4); + expect( + workflow.split( + "OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve_build_provenance.outputs.built_at }}", + ).length - 1, + ).toBe(4); + }); + it("publishes official Docker browser images with baked Chromium", async () => { const workflow = await readFile(dockerReleaseWorkflowPath, "utf8"); @@ -346,7 +395,7 @@ describe("Dockerfile", () => { expect(workflow).toContain("chrome-headless-shell"); expect(workflow).toContain("grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile"); expect(workflow).toContain("if: steps.tags.outputs.browser != ''"); - expect(workflow).toContain('git show "${SOURCE_REF}:Dockerfile"'); + expect(workflow).not.toContain('git show "${SOURCE_REF}:Dockerfile"'); expect(workflow).toContain('if [[ -n "${BROWSER_TAGS}" ]]; then'); }); diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts index 7a6100da56e..91afc06af9f 100644 --- a/test/openclaw-prepack.test.ts +++ b/test/openclaw-prepack.test.ts @@ -2,11 +2,75 @@ import { describe, expect, it } from "vitest"; import { collectPreparedPrepackErrors, + resolvePrepackBuildEnvironment, resolvePrepackCommandStdio, resolvePrepackCommandTimeoutMs, runPrepackCommand, } from "../scripts/openclaw-prepack.ts"; +describe("resolvePrepackBuildEnvironment", () => { + it("pins one timestamp across package and Control UI builds", () => { + const commit = "0123456789abcdef0123456789abcdef01234567"; + expect( + resolvePrepackBuildEnvironment( + {}, + () => new Date("2026-07-10T12:34:56.000Z"), + () => commit, + ), + ).toMatchObject({ + GIT_COMMIT: commit, + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56.000Z", + }); + expect( + resolvePrepackBuildEnvironment( + { OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03.7Z" }, + () => new Date("2026-07-11T00:00:00.000Z"), + () => commit, + ).OPENCLAW_BUILD_TIMESTAMP, + ).toBe("2026-07-10T01:02:03.7Z"); + }); + + it("normalizes explicit commit aliases and rejects malformed values", () => { + expect( + resolvePrepackBuildEnvironment( + { GIT_SHA: "A".repeat(40) }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => "b".repeat(40), + ).GIT_COMMIT, + ).toBe("a".repeat(40)); + expect(() => + resolvePrepackBuildEnvironment({ GIT_COMMIT: "deadbeef" }, undefined, () => null), + ).toThrow("full 40-character hexadecimal SHA"); + }); + + it("uses checked-out Git instead of unverified GitHub workflow context", () => { + const checkedOutCommit = "b".repeat(40); + const ambientCommit = "a".repeat(40); + + expect( + resolvePrepackBuildEnvironment( + { GITHUB_SHA: ambientCommit }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => checkedOutCommit, + ).GIT_COMMIT, + ).toBe(checkedOutCommit); + expect( + resolvePrepackBuildEnvironment( + { GITHUB_SHA: ambientCommit }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => null, + ).GIT_COMMIT, + ).toBe(ambientCommit); + expect(() => + resolvePrepackBuildEnvironment( + { GITHUB_SHA: "bad" }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => null, + ), + ).toThrow("full 40-character hexadecimal SHA"); + }); +}); + describe("collectPreparedPrepackErrors", () => { it("accepts prepared release artifacts", () => { expect( diff --git a/test/scripts/android-release-artifacts.test.ts b/test/scripts/android-release-artifacts.test.ts index 31d1c38d83f..7a787e57106 100644 --- a/test/scripts/android-release-artifacts.test.ts +++ b/test/scripts/android-release-artifacts.test.ts @@ -3,6 +3,11 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach } from "vitest"; import { describe, expect, it } from "vitest"; +import { + androidBuildMetadataGradleArgs, + resolveAndroidBuildMetadata, + verifyAndroidReleaseSource, +} from "../../apps/android/scripts/build-release-artifacts.ts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const SCRIPT = "apps/android/scripts/build-release-artifacts.ts"; @@ -10,10 +15,15 @@ const APK_CERTIFICATE_SHA256 = "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef const tempRoots = useAutoCleanupTempDirTracker(afterEach); function run(args: string[], env: NodeJS.ProcessEnv = {}) { + const processEnv = { ...process.env }; + delete processEnv.GIT_COMMIT; + delete processEnv.GIT_SHA; + delete processEnv.GITHUB_SHA; + delete processEnv.OPENCLAW_BUILD_TIMESTAMP; return spawnSync(process.execPath, ["--import", "tsx", SCRIPT, ...args], { cwd: process.cwd(), encoding: "utf8", - env: { ...process.env, ...env }, + env: { ...processEnv, ...env }, }); } @@ -37,6 +47,105 @@ function fakeApkSigner(certificateSha256: string, signerCount = 1) { } describe("Android release artifacts", () => { + it("resolves release metadata from explicit environment values first", () => { + const metadata = resolveAndroidBuildMetadata({ + env: { + GIT_COMMIT: "A".repeat(40), + GIT_SHA: "d".repeat(40), + GITHUB_SHA: "b".repeat(40), + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03Z", + }, + now: () => new Date("2026-07-11T00:00:00Z"), + readGitCommit: () => "c".repeat(40), + }); + + expect(metadata).toEqual({ + commit: "a".repeat(40), + timestamp: "2026-07-10T01:02:03.000Z", + }); + }); + + it("prefers explicit metadata, then the checkout, then a git-less GitHub fallback", () => { + const gitShaMetadata = resolveAndroidBuildMetadata({ + env: { GIT_SHA: "a".repeat(40), GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T04:05:06Z"), + readGitCommit: () => "c".repeat(40), + }); + const repositoryMetadata = resolveAndroidBuildMetadata({ + env: { GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T04:05:06Z"), + readGitCommit: () => "c".repeat(40), + }); + const githubMetadata = resolveAndroidBuildMetadata({ + env: { GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T04:05:06Z"), + readGitCommit: () => { + throw new Error("git unavailable"); + }, + }); + + expect(gitShaMetadata.commit).toBe("a".repeat(40)); + expect(githubMetadata.commit).toBe("b".repeat(40)); + expect(repositoryMetadata).toEqual({ + commit: "c".repeat(40), + timestamp: "2026-07-10T04:05:06.000Z", + }); + }); + + it("rejects missing commit metadata when neither Git nor GitHub is available", () => { + expect(() => + resolveAndroidBuildMetadata({ + env: {}, + readGitCommit: () => { + throw new Error("git unavailable"); + }, + }), + ).toThrow("Unable to resolve the Android release Git commit"); + }); + + it("rejects partial commits and non-UTC build timestamps", () => { + expect(() => + resolveAndroidBuildMetadata({ + env: { GIT_COMMIT: "abc1234", GITHUB_SHA: "b".repeat(40) }, + }), + ).toThrow("full 40-character hexadecimal Git commit"); + expect(() => + resolveAndroidBuildMetadata({ + env: { + GIT_COMMIT: "a".repeat(40), + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T01:02:03+01:00", + }, + }), + ).toThrow("ISO-8601 UTC timestamp"); + }); + + it("passes build metadata as named Gradle project properties", () => { + expect( + androidBuildMetadataGradleArgs({ + commit: "a".repeat(40), + timestamp: "2026-07-10T01:02:03.000Z", + }), + ).toEqual([ + `-PopenclawBuildCommit=${"a".repeat(40)}`, + "-PopenclawBuildTimestamp=2026-07-10T01:02:03.000Z", + ]); + }); + + it("requires release metadata to match a clean checkout", () => { + const commit = "a".repeat(40); + const cleanGit = (args: string[]) => (args[0] === "rev-parse" ? `${commit}\n` : ""); + + expect(() => verifyAndroidReleaseSource(commit, { runGit: cleanGit })).not.toThrow(); + expect(() => verifyAndroidReleaseSource("b".repeat(40), { runGit: cleanGit })).toThrow( + "Android release commit mismatch", + ); + expect(() => + verifyAndroidReleaseSource(commit, { + runGit: (args) => (args[0] === "rev-parse" ? `${commit}\n` : " M app/src/main.kt\n"), + }), + ).toThrow("Android release builds require a clean Git checkout"); + }); + it("selects only the signed third-party APK for GitHub distribution", () => { const result = run(["--artifact", "third-party", "--dry-run"]); diff --git a/test/scripts/apple-release-source-check.test.ts b/test/scripts/apple-release-source-check.test.ts new file mode 100644 index 00000000000..d17b0684186 --- /dev/null +++ b/test/scripts/apple-release-source-check.test.ts @@ -0,0 +1,92 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = path.join(process.cwd(), "scripts", "apple-release-source-check.sh"); +const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash"; +const tempDirs: string[] = []; + +function makeRepository(): { root: string; commit: string } { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-apple-release-source-")); + tempDirs.push(root); + execFileSync("git", ["init", "--quiet"], { cwd: root }); + execFileSync("git", ["config", "user.email", "release-test@openclaw.test"], { cwd: root }); + execFileSync("git", ["config", "user.name", "OpenClaw Release Test"], { cwd: root }); + writeFileSync(path.join(root, "tracked.txt"), "clean\n", "utf8"); + execFileSync("git", ["add", "tracked.txt"], { cwd: root }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "initial"], { + cwd: root, + }); + const commit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + return { root, commit }; +} + +function runCheck(root: string, commit: string) { + const args = process.platform === "win32" ? [SCRIPT] : ["--noprofile", "--norc", SCRIPT]; + return spawnSync(BASH_BIN, [...args, "--root", root, "--expected-commit", commit], { + cwd: process.cwd(), + encoding: "utf8", + }); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Apple release source check", () => { + it("accepts a matching full commit from a clean checkout", () => { + const repository = makeRepository(); + const result = runCheck(repository.root, repository.commit.toUpperCase()); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + `Verified Apple release source: commit=${repository.commit} clean=true`, + ); + }); + + it("rejects release metadata from a different commit", () => { + const repository = makeRepository(); + const mismatch = `${repository.commit[0] === "a" ? "b" : "a"}${repository.commit.slice(1)}`; + const result = runCheck(repository.root, mismatch); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Apple release commit mismatch: metadata ${mismatch}, checkout ${repository.commit}.`, + ); + }); + + it("rejects tracked, staged, and untracked release checkout changes", () => { + const fixtures = [ + () => { + const repository = makeRepository(); + writeFileSync(path.join(repository.root, "tracked.txt"), "dirty\n", "utf8"); + return repository; + }, + () => { + const repository = makeRepository(); + writeFileSync(path.join(repository.root, "staged.txt"), "staged\n", "utf8"); + execFileSync("git", ["add", "staged.txt"], { cwd: repository.root }); + return repository; + }, + () => { + const repository = makeRepository(); + writeFileSync(path.join(repository.root, "untracked.txt"), "untracked\n", "utf8"); + return repository; + }, + ]; + + for (const makeFixture of fixtures) { + const repository = makeFixture(); + const result = runCheck(repository.root, repository.commit); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Apple release builds require a clean Git checkout."); + } + }); +}); diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 2c45e54be15..0ff26f74538 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -12,6 +12,7 @@ import { formatBuildAllDuration, formatBuildAllTimingSummary, parseBuildAllArgs, + resolveBuildAllEnvironment, resolveBuildAllStepCacheState, resolveBuildAllStepCacheStampState, resolveBuildAllStep, @@ -69,6 +70,79 @@ function withBuildCacheFixture( } describe("resolveBuildAllStep", () => { + it("pins one generated timestamp across every child build", () => { + const commit = "0123456789abcdef0123456789abcdef01234567"; + const buildEnv = resolveBuildAllEnvironment( + { FOO: "bar" }, + () => new Date("2026-07-10T12:34:56.789Z"), + () => commit, + ); + const uiInvocation = resolveBuildAllStep(getBuildAllStep("ui:build"), { + env: buildEnv, + }); + const buildInfoInvocation = resolveBuildAllStep(getBuildAllStep("write-build-info"), { + env: buildEnv, + }); + + expect(uiInvocation.options.env).toMatchObject({ + FOO: "bar", + GIT_COMMIT: commit, + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56.789Z", + }); + expect(buildInfoInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP).toBe( + uiInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP, + ); + }); + + it("pins the first explicit full commit alias and rejects malformed values", () => { + const gitSha = "A".repeat(40); + expect( + resolveBuildAllEnvironment( + { GIT_SHA: gitSha, GITHUB_SHA: "b".repeat(40) }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => "c".repeat(40), + ).GIT_COMMIT, + ).toBe(gitSha.toLowerCase()); + expect(() => + resolveBuildAllEnvironment({ GIT_COMMIT: "deadbeef" }, undefined, () => null), + ).toThrow("full 40-character hexadecimal SHA"); + }); + + it("uses checked-out Git instead of unverified GitHub workflow context", () => { + const checkedOutCommit = "b".repeat(40); + const ambientCommit = "a".repeat(40); + + expect( + resolveBuildAllEnvironment( + { GITHUB_SHA: ambientCommit }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => checkedOutCommit, + ).GIT_COMMIT, + ).toBe(checkedOutCommit); + expect( + resolveBuildAllEnvironment( + { GITHUB_SHA: ambientCommit }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => null, + ).GIT_COMMIT, + ).toBe(ambientCommit); + expect(() => + resolveBuildAllEnvironment( + { GITHUB_SHA: "bad" }, + () => new Date("2026-07-10T12:34:56.000Z"), + () => null, + ), + ).toThrow("full 40-character hexadecimal SHA"); + }); + + it("preserves an explicit build timestamp after trimming outer whitespace", () => { + expect( + resolveBuildAllEnvironment({ + OPENCLAW_BUILD_TIMESTAMP: " 2026-07-10T01:02:03.000Z ", + }).OPENCLAW_BUILD_TIMESTAMP, + ).toBe("2026-07-10T01:02:03.000Z"); + }); + it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => { const step = getBuildAllStep("plugins:assets:build"); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-")); diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index ab781f7d0ee..7fcd3a9d6b4 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -24,6 +24,18 @@ function laneBody(source: string, name: string): string { return nextLane < 0 ? rest : rest.slice(0, nextLane); } +function functionBody(source: string, name: string): string { + const startMarker = `def ${name}`; + const start = source.indexOf(startMarker); + if (start < 0) { + throw new Error(`missing Fastfile function ${name}`); + } + + const rest = source.slice(start + startMarker.length); + const nextFunction = rest.search(/\ndef /); + return nextFunction < 0 ? rest : rest.slice(0, nextFunction); +} + describe("iOS Fastlane release upload gates", () => { it("does not keep the old package release alias", () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { @@ -77,13 +89,30 @@ describe("iOS Fastlane release upload gates", () => { it("validates the exported IPA before the sole TestFlight upload call", () => { const fastfile = readFastfile(); - const validationCall = fastfile.indexOf("validate_app_store_ipa!(expected_ipa_path)"); + const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]"); const uploadCall = fastfile.indexOf("upload_to_testflight("); expect(validationCall).toBeGreaterThanOrEqual(0); expect(uploadCall).toBeGreaterThan(validationCall); }); + it("requires clean matching source before preparing and building release artifacts", () => { + const fastfile = readFastfile(); + const verifier = functionBody(fastfile, "verify_apple_release_source!"); + const provenance = functionBody(fastfile, "pin_release_build_provenance!"); + const builder = functionBody(fastfile, "build_app_store_release"); + + expect(verifier).toContain('"apple-release-source-check.sh"'); + expect(verifier).toContain('"--root"'); + expect(verifier).toContain('"--expected-commit"'); + expect(provenance).toContain("verify_apple_release_source!(normalized_commit)"); + expect(provenance).not.toContain('ENV["GITHUB_SHA"]'); + expect(builder).toContain("verify_apple_release_source!(context[:git_commit])"); + expect(builder.indexOf("verify_apple_release_source!")).toBeLessThan( + builder.indexOf("FileUtils.mkdir_p(output_directory)"), + ); + }); + it("preflights and records mobile release refs around TestFlight upload", () => { const fastfile = readFastfile(); const releaseUpload = laneBody(fastfile, "release_upload"); @@ -93,7 +122,11 @@ describe("iOS Fastlane release upload gates", () => { expect(fastfile).toContain('"--root"'); expect(fastfile).toContain('"--sha"'); expect(fastfile).toContain("repo_root"); - expect(releaseUpload).toContain("release_sha = release_git_sha"); + expect(fastfile).toContain("def pin_release_build_provenance!"); + expect(laneBody(fastfile, "prepare_app_store_context")).toContain( + "provenance = pin_release_build_provenance!", + ); + expect(releaseUpload).toContain("release_sha = context[:git_commit]"); expect(releaseUpload).toContain("ensure_mobile_release_ref_available!"); expect(releaseUpload).toContain("record_mobile_release_ref!"); expect(releaseUpload).toContain( diff --git a/test/scripts/ios-release-wrapper-args.test.ts b/test/scripts/ios-release-wrapper-args.test.ts index ccaf95c8fff..252a984daec 100644 --- a/test/scripts/ios-release-wrapper-args.test.ts +++ b/test/scripts/ios-release-wrapper-args.test.ts @@ -105,4 +105,18 @@ describe("iOS release shell wrapper arguments", () => { expect(result.stderr).not.toContain("fastlane"); expect(result.stdout).toBe(""); }); + + it("requires stamped build metadata for App Store release preparation", () => { + const script = readFileSync(path.join(process.cwd(), "scripts/ios-release-prepare.sh"), "utf8"); + + expect(script).toContain("OPENCLAW_REQUIRE_BUILD_METADATA=1"); + expect(script).toContain( + 'RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh"', + ); + expect(script).toContain('--expected-commit "${RELEASE_GIT_COMMIT}"'); + expect(script.indexOf('bash "${RELEASE_SOURCE_HELPER}"')).toBeLessThan( + script.lastIndexOf("prepare_build_dir"), + ); + expect(script).toContain('export GIT_COMMIT="${RELEASE_GIT_COMMIT}"'); + }); }); diff --git a/test/scripts/ios-validate-app-store-ipa.test.ts b/test/scripts/ios-validate-app-store-ipa.test.ts index 2c842e5f060..b7f87cfe511 100644 --- a/test/scripts/ios-validate-app-store-ipa.test.ts +++ b/test/scripts/ios-validate-app-store-ipa.test.ts @@ -16,6 +16,8 @@ import { afterEach, describe, expect, it } from "vitest"; const SCRIPT = path.join(process.cwd(), "scripts", "ios-validate-app-store-ipa.sh"); const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash"; +const BUILD_COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const BUILD_TIMESTAMP = "2026-07-10T12:34:56.000Z"; const tempDirs: string[] = []; @@ -176,7 +178,12 @@ async function writeIpaFixture(root: string): Promise { async function writeValidFixture( root: string, - options: { pushMode?: string; legacyKey?: boolean } = {}, + options: { + buildCommit?: string; + buildTimestamp?: string; + pushMode?: string; + legacyKey?: boolean; + } = {}, ): Promise<{ ipaPath: string; plistBuddy: string; @@ -194,6 +201,8 @@ async function writeValidFixture( const infoBody = [ plistString("CFBundleIdentifier", "ai.openclawfoundation.app"), + plistString("OpenClawGitCommit", options.buildCommit ?? BUILD_COMMIT), + plistString("OpenClawBuildTimestamp", options.buildTimestamp ?? BUILD_TIMESTAMP), plistString("OpenClawPushMode", options.pushMode ?? "appStore"), plistString("OpenClawPushRelayBaseURL", ""), options.legacyKey ? plistString("OpenClawPushRelayProfile", "production") : "", @@ -266,26 +275,41 @@ cat "${profilePath}" return { ipaPath, plistBuddy, codesign, security, unzip }; } -function runValidator(fixture: { - ipaPath: string; - plistBuddy: string; - codesign: string; - security: string; - unzip: string; -}): { ok: boolean; stdout: string; stderr: string } { +function runValidator( + fixture: { + ipaPath: string; + plistBuddy: string; + codesign: string; + security: string; + unzip: string; + }, + expected: { commit?: string; timestamp?: string } = {}, +): { ok: boolean; stdout: string; stderr: string } { try { - const stdout = execFileSync(BASH_BIN, [...bashArgs(SCRIPT), "--ipa", fixture.ipaPath], { - cwd: process.cwd(), - env: { - ...process.env, - IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy, - IOS_VALIDATE_CODESIGN_BIN: fixture.codesign, - IOS_VALIDATE_SECURITY_BIN: fixture.security, - IOS_VALIDATE_UNZIP_BIN: fixture.unzip, + const stdout = execFileSync( + BASH_BIN, + [ + ...bashArgs(SCRIPT), + "--ipa", + fixture.ipaPath, + "--expected-commit", + expected.commit ?? BUILD_COMMIT, + "--expected-build-timestamp", + expected.timestamp ?? BUILD_TIMESTAMP, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy, + IOS_VALIDATE_CODESIGN_BIN: fixture.codesign, + IOS_VALIDATE_SECURITY_BIN: fixture.security, + IOS_VALIDATE_UNZIP_BIN: fixture.unzip, + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], }, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + ); return { ok: true, stdout, stderr: "" }; } catch (error) { const e = error as { stdout?: unknown; stderr?: unknown }; @@ -334,4 +358,20 @@ describe("scripts/ios-validate-app-store-ipa.sh", () => { expect(result.ok).toBe(false); expect(result.stderr).toContain("legacy relay profile"); }); + + it("rejects malformed or mismatched embedded build provenance", async () => { + const malformedRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-")); + const mismatchRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-")); + tempDirs.push(malformedRoot, mismatchRoot); + const malformed = await writeValidFixture(malformedRoot, { buildCommit: "deadbeef" }); + const mismatch = await writeValidFixture(mismatchRoot); + + const malformedResult = runValidator(malformed); + const mismatchResult = runValidator(mismatch, { commit: "a".repeat(40) }); + + expect(malformedResult.ok).toBe(false); + expect(malformedResult.stderr).toContain("full lowercase commit SHA"); + expect(mismatchResult.ok).toBe(false); + expect(mismatchResult.stderr).toContain("embedded Git commit mismatch"); + }); }); diff --git a/test/scripts/ios-version.test.ts b/test/scripts/ios-version.test.ts index 4633c977a40..416766204d8 100644 --- a/test/scripts/ios-version.test.ts +++ b/test/scripts/ios-version.test.ts @@ -16,6 +16,16 @@ import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-su installIosFixtureCleanup(); describe("resolveIosVersion", () => { + it("writes shared full commit and UTC timestamp settings for iOS builds", () => { + const script = fs.readFileSync("scripts/ios-write-version-xcconfig.sh", "utf8"); + + expect(script).toContain('source "${ROOT_DIR}/scripts/lib/build-metadata.sh"'); + expect(script).toContain("OPENCLAW_GIT_COMMIT = ${RESOLVED_GIT_COMMIT}"); + expect(script).toContain("OPENCLAW_BUILD_TIMESTAMP = ${RESOLVED_BUILD_TIMESTAMP}"); + expect(script).toContain('openclaw_resolve_git_commit "${ROOT_DIR}"'); + expect(script).toContain("openclaw_resolve_build_timestamp"); + }); + it("rejects missing CLI option values before reading version files", () => { const result = spawnSync( process.execPath, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 6aba3b9dda3..cc2742a592a 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -2438,6 +2438,12 @@ describe("package artifact reuse", () => { expect(androidWorkflow).toContain("FALLBACK_ANDROID_BASE_SHA"); expect(androidWorkflow).toContain('--source-digest "${FALLBACK_ANDROID_BASE_SHA}"'); expect(androidWorkflow).toContain("steps.release_source.outputs.fallback_base_tag == ''"); + expect(androidWorkflow).toContain( + "OPENCLAW_BUILD_TIMESTAMP: ${{ steps.release_approval.outputs.build_timestamp }}", + ); + expect(androidWorkflow).toContain("GIT_COMMIT: ${{ inputs.release_target_sha }}"); + expect(androidWorkflow).toContain("--json tagName,isDraft,isPrerelease,createdAt,assets,url"); + expect(androidWorkflow).toContain("release_created_at="); expect(androidWorkflow).toContain( "Reusing verified Android APK from ${FALLBACK_ANDROID_BASE_TAG}", ); diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 81fe9445965..ce3a913dde8 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -162,6 +162,163 @@ afterEach(() => { }); describe("package-mac-app plist stamping", () => { + it("resolves canonical build provenance and rejects explicit invalid overrides", () => { + const commit = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; + const valid = runHelper(` + source scripts/lib/build-metadata.sh + node() { echo "unexpected Node invocation" >&2; return 97; } + GIT_COMMIT=${JSON.stringify(commit)} + OPENCLAW_BUILD_TIMESTAMP=2026-07-10T12:34:56.7Z + printf '%s\n%s\n' "$(openclaw_resolve_git_commit "$PWD")" "$(openclaw_resolve_build_timestamp)" + `); + const invalidCommit = runHelper(` + source scripts/lib/build-metadata.sh + GIT_COMMIT=abc123 + openclaw_resolve_git_commit "$PWD" + `); + const validAlias = runHelper(` + source scripts/lib/build-metadata.sh + unset GIT_COMMIT GITHUB_SHA + GIT_SHA=${JSON.stringify(commit)} + openclaw_resolve_git_commit "$PWD" + `); + const invalidTimestamp = runHelper(` + source scripts/lib/build-metadata.sh + OPENCLAW_BUILD_TIMESTAMP=2026-99-99T12:34:56Z + openclaw_resolve_build_timestamp + `); + const missingLocalCommit = runHelper(` + source scripts/lib/build-metadata.sh + unset GIT_COMMIT GIT_SHA GITHUB_SHA + empty_root="$(mktemp -d)" + openclaw_resolve_git_commit "$empty_root" + `); + const missingReleaseCommit = runHelper(` + source scripts/lib/build-metadata.sh + unset GIT_COMMIT GIT_SHA GITHUB_SHA + empty_root="$(mktemp -d)" + OPENCLAW_REQUIRE_BUILD_METADATA=1 openclaw_resolve_git_commit "$empty_root" + `); + const ambientGithubCommit = runHelper(` + source scripts/lib/build-metadata.sh + unset GIT_COMMIT GIT_SHA + GITHUB_SHA=${JSON.stringify("a".repeat(40))} + openclaw_resolve_git_commit "$PWD" + `); + const invalidGithubFallback = runHelper(` + source scripts/lib/build-metadata.sh + unset GIT_COMMIT GIT_SHA + GITHUB_SHA=bad + empty_root="$(mktemp -d)" + openclaw_resolve_git_commit "$empty_root" + `); + const checkedOutCommit = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: process.cwd(), + encoding: "utf8", + }).stdout.trim(); + + expect(valid.status).toBe(0); + expect(valid.stdout).toBe(`${commit.toLowerCase()}\n2026-07-10T12:34:56.700Z\n`); + expect(invalidCommit.status).toBe(1); + expect(invalidCommit.stderr).toContain( + "GIT_COMMIT must be a full 40-character hexadecimal commit", + ); + expect(validAlias.status).toBe(0); + expect(validAlias.stdout).toBe(commit.toLowerCase()); + expect(invalidTimestamp.status).toBe(1); + expect(invalidTimestamp.stderr).toContain( + "OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp", + ); + expect(missingLocalCommit.status).toBe(0); + expect(missingLocalCommit.stdout).toBe("unknown"); + expect(missingReleaseCommit.status).toBe(1); + expect(missingReleaseCommit.stderr).toContain("full Git commit for the release build"); + expect(ambientGithubCommit.status).toBe(0); + expect(ambientGithubCommit.stdout).toBe(checkedOutCommit); + expect(invalidGithubFallback.status).toBe(1); + expect(invalidGithubFallback.stderr).toContain( + "GITHUB_SHA must be a full 40-character hexadecimal commit", + ); + }); + + it("normalizes valid timestamps without requiring host Node", () => { + const result = runHelper(` + source scripts/lib/build-metadata.sh + node() { echo "unexpected Node invocation" >&2; return 97; } + for value in \ + 0000-01-01T00:00:00Z \ + 2000-02-29T23:59:59.7Z \ + 2024-02-29T12:34:56.78Z \ + 2026-07-10T12:34:56.789Z; do + OPENCLAW_BUILD_TIMESTAMP="$value" openclaw_resolve_build_timestamp + printf '\n' + done + for value in \ + 2026-00-01T00:00:00Z \ + 2026-02-29T00:00:00Z \ + 2100-02-29T00:00:00Z \ + 2026-04-31T00:00:00Z \ + 2026-01-01T24:00:00Z \ + 2026-01-01T00:60:00Z \ + 2026-01-01T00:00:60Z \ + 2026-01-01T00:00:00+00:00; do + if OPENCLAW_BUILD_TIMESTAMP="$value" openclaw_resolve_build_timestamp >/dev/null 2>&1; then + exit 1 + fi + done + unset OPENCLAW_BUILD_TIMESTAMP + generated="$(openclaw_resolve_build_timestamp)" + [[ "$generated" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[.]000Z$ ]] + `); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + [ + "0000-01-01T00:00:00.000Z", + "2000-02-29T23:59:59.700Z", + "2024-02-29T12:34:56.780Z", + "2026-07-10T12:34:56.789Z", + "", + ].join("\n"), + ); + }); + + it("uses the shared build metadata policy for full commit and timestamp stamps", () => { + const script = readFileSync(scriptPath, "utf8"); + + expect(script).toContain('source "$ROOT_DIR/scripts/lib/build-metadata.sh"'); + expect(script).toContain('BUILD_GIT_COMMIT="$(openclaw_resolve_git_commit "$ROOT_DIR")"'); + expect(script).toContain('BUILD_TS="$(openclaw_resolve_build_timestamp)"'); + expect(script).toContain('export OPENCLAW_BUILD_TIMESTAMP="$BUILD_TS"'); + expect(script).toContain('export GIT_COMMIT="$BUILD_GIT_COMMIT"'); + expect(script).not.toContain("git rev-parse --short HEAD"); + }); + + it("gates only release packaging on clean matching source and verifies the embedded commit", () => { + const script = readFileSync(scriptPath, "utf8"); + const sourceCheck = script.indexOf('bash "$ROOT_DIR/scripts/apple-release-source-check.sh"'); + const build = script.indexOf('cd "$ROOT_DIR/apps/macos"'); + const embeddedRead = script.indexOf( + 'plist_print_required "$APP_ROOT/Contents/Info.plist" OpenClawGitCommit', + ); + const signing = script.indexOf('"$ROOT_DIR/scripts/codesign-mac-app.sh"'); + const releaseBranch = script.lastIndexOf( + 'if [[ "$BUILD_CONFIG" == "release" ]]; then', + sourceCheck, + ); + const releaseBranchEnd = script.indexOf("\nfi", sourceCheck); + + expect(script).toContain('BUILD_CONFIG="${BUILD_CONFIG:-debug}"'); + expect(sourceCheck).toBeGreaterThan(releaseBranch); + expect(sourceCheck).toBeLessThan(releaseBranchEnd); + expect(sourceCheck).toBeLessThan(build); + expect(script).toContain('--expected-commit "$BUILD_GIT_COMMIT"'); + expect(embeddedRead).toBeGreaterThan(sourceCheck); + expect(embeddedRead).toBeLessThan(signing); + expect(script).toContain("Release app embedded Git commit"); + }); + it("keeps dependency installation lockfile-safe", () => { const script = readFileSync(scriptPath, "utf8"); const installBlock = script.slice( diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index c909f61518b..bd9b097bcbc 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -673,6 +673,19 @@ printf 'status=%s\\n' "$status" expect(podmanSetup).not.toContain("OPENCLAW_DOCKER_PIP_PACKAGES"); }); + it("passes one source identity into local Docker and Podman builds", () => { + const dockerSetup = readFileSync(DOCKER_SETUP_PATH, "utf8"); + const podmanSetup = readFileSync(PODMAN_SETUP_PATH, "utf8"); + + for (const setupScript of [dockerSetup, podmanSetup]) { + expect(setupScript).toContain("scripts/lib/build-metadata.sh"); + expect(setupScript).toContain("openclaw_resolve_git_commit"); + expect(setupScript).toContain("openclaw_resolve_build_timestamp"); + expect(setupScript).toContain("OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"); + expect(setupScript).toContain("GIT_COMMIT=${BUILD_GIT_COMMIT}"); + } + }); + it("keeps the Podman Quadlet template aligned with setup substitutions", () => { const setupScript = readFileSync(PODMAN_SETUP_PATH, "utf8"); const template = readFileSync(PODMAN_QUADLET_TEMPLATE_PATH, "utf8"); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 123c4de3f65..8cff128ca40 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -263,6 +263,17 @@ describe("scripts/test-projects changed-target routing", () => { }); it("routes release wrapper changes through their owner tests", () => { + expect(resolveChangedTestTargetPlan(["scripts/apple-release-source-check.sh"])).toEqual({ + mode: "targets", + targets: ["test/scripts/apple-release-source-check.test.ts"], + }); + expect(resolveChangedTestTargetPlan(["scripts/ios-release-prepare.sh"])).toEqual({ + mode: "targets", + targets: [ + "test/scripts/ios-release-prepare.test.ts", + "test/scripts/ios-release-wrapper-args.test.ts", + ], + }); expect(resolveChangedTestTargetPlan(["scripts/android-release.sh"])).toEqual({ mode: "targets", targets: ["test/scripts/android-release-wrapper-args.test.ts"], @@ -1489,10 +1500,21 @@ describe("scripts/test-projects changed-target routing", () => { ["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"], ], ["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]], + ["scripts/ios-write-version-xcconfig.sh", ["test/scripts/ios-version.test.ts"]], ["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]], ["scripts/make_appcast.sh", ["test/scripts/make-appcast.test.ts"]], ["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]], ["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]], + [ + "scripts/lib/build-metadata.sh", + [ + "src/docker-setup.e2e.test.ts", + "test/scripts/apple-release-source-check.test.ts", + "test/scripts/ios-version.test.ts", + "test/scripts/package-mac-app.test.ts", + "test/scripts/test-install-sh-docker.test.ts", + ], + ], [ "scripts/lib/swift-toolchain.sh", ["test/scripts/package-mac-app.test.ts", "test/scripts/package-mac-dist.test.ts"], @@ -1983,6 +2005,16 @@ describe("scripts/test-projects changed-target routing", () => { "test/release-check.test.ts", ], ], + [ + "scripts/lib/build-metadata.sh", + [ + "src/docker-setup.e2e.test.ts", + "test/scripts/apple-release-source-check.test.ts", + "test/scripts/ios-version.test.ts", + "test/scripts/package-mac-app.test.ts", + "test/scripts/test-install-sh-docker.test.ts", + ], + ], [ "scripts/lib/plistbuddy.sh", [ diff --git a/test/scripts/write-build-info.test.ts b/test/scripts/write-build-info.test.ts new file mode 100644 index 00000000000..a72b17a1599 --- /dev/null +++ b/test/scripts/write-build-info.test.ts @@ -0,0 +1,156 @@ +// Build info tests cover canonical package provenance generation. +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + normalizeBuildCommit, + normalizeBuildTimestamp, + resolveBuildInfo, + writeBuildInfo, +} from "../../scripts/write-build-info.ts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +describe("write-build-info", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + function createPackage(version = "2026.7.10"): string { + const rootDir = tempDirs.make("openclaw-build-info-"); + fs.writeFileSync(path.join(rootDir, "package.json"), `${JSON.stringify({ version })}\n`); + return rootDir; + } + + it("normalizes explicit release provenance and writes the shared manifest", () => { + const rootDir = createPackage(); + const execFileSync = vi.fn(() => { + throw new Error("Git fallback should not run"); + }); + + const outputPath = writeBuildInfo({ + rootDir, + env: { + GIT_COMMIT: "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56Z", + }, + execFileSync, + }); + + expect(execFileSync).not.toHaveBeenCalled(); + expect(path.relative(rootDir, outputPath)).toBe("dist/build-info.json"); + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ + version: "2026.7.10", + commit: "abcdef0123456789abcdef0123456789abcdef01", + builtAt: "2026-07-10T12:34:56.000Z", + }); + }); + + it("falls back to build-time Git and one current UTC timestamp for local builds", () => { + const rootDir = createPackage("2026.7.10-beta.1"); + const execFileSync = vi.fn(() => "1234567890ABCDEF1234567890ABCDEF12345678\n"); + + expect( + resolveBuildInfo({ + rootDir, + env: {}, + execFileSync, + now: () => new Date("2026-07-10T01:02:03.456Z"), + }), + ).toEqual({ + version: "2026.7.10-beta.1", + commit: "1234567890abcdef1234567890abcdef12345678", + builtAt: "2026-07-10T01:02:03.456Z", + }); + expect(execFileSync).toHaveBeenCalledWith("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + }); + + it("uses null when Git metadata is unavailable", () => { + const rootDir = createPackage(); + + expect( + resolveBuildInfo({ + rootDir, + env: {}, + execFileSync: () => { + throw new Error("git unavailable"); + }, + now: () => new Date("2026-07-10T01:02:03.000Z"), + }).commit, + ).toBeNull(); + }); + + it("preserves GIT_COMMIT then GIT_SHA explicit input precedence", () => { + const rootDir = createPackage(); + const fallbackSha = "1234567890abcdef1234567890abcdef12345678"; + + expect( + resolveBuildInfo({ + rootDir, + env: { GIT_SHA: fallbackSha }, + now: () => new Date("2026-07-10T01:02:03.000Z"), + }).commit, + ).toBe(fallbackSha); + expect(() => + resolveBuildInfo({ + rootDir, + env: { GIT_COMMIT: "bad", GIT_SHA: fallbackSha }, + }), + ).toThrow("GIT_COMMIT must be a full 40-character Git commit SHA."); + }); + + it("uses checked-out Git instead of unverified GitHub workflow context", () => { + const rootDir = createPackage(); + const checkedOutCommit = "b".repeat(40); + const execFileSync = vi.fn(() => checkedOutCommit); + + expect( + resolveBuildInfo({ + rootDir, + env: { GITHUB_SHA: "a".repeat(40) }, + execFileSync, + now: () => new Date("2026-07-10T01:02:03.000Z"), + }).commit, + ).toBe(checkedOutCommit); + expect(execFileSync).toHaveBeenCalledOnce(); + expect( + resolveBuildInfo({ + rootDir, + env: { GITHUB_SHA: "a".repeat(40) }, + execFileSync: () => { + throw new Error("git unavailable"); + }, + now: () => new Date("2026-07-10T01:02:03.000Z"), + }).commit, + ).toBe("a".repeat(40)); + expect(() => + resolveBuildInfo({ + rootDir, + env: { GITHUB_SHA: "bad" }, + execFileSync: () => { + throw new Error("git unavailable"); + }, + }), + ).toThrow("GITHUB_SHA must be a full 40-character Git commit SHA."); + }); + + it("rejects abbreviated or malformed explicit commits", () => { + expect(() => normalizeBuildCommit("abc1234")).toThrow( + "GIT_COMMIT must be a full 40-character Git commit SHA.", + ); + expect(() => normalizeBuildCommit("g".repeat(40))).toThrow( + "GIT_COMMIT must be a full 40-character Git commit SHA.", + ); + }); + + it("normalizes valid UTC timestamps and rejects offsets or impossible dates", () => { + expect(normalizeBuildTimestamp("2026-07-10T12:34:56.7Z")).toBe("2026-07-10T12:34:56.700Z"); + expect(() => normalizeBuildTimestamp("2026-07-10T12:34:56+00:00")).toThrow( + "OPENCLAW_BUILD_TIMESTAMP must be an ISO-8601 UTC timestamp ending in Z.", + ); + expect(() => normalizeBuildTimestamp("2026-02-30T12:34:56Z")).toThrow( + "OPENCLAW_BUILD_TIMESTAMP must be a valid ISO-8601 UTC timestamp.", + ); + }); +}); diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index c748ce8160a..893613a328d 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -78,6 +78,7 @@ describe("navigationIconForRoute", () => { automation: "terminal", mcp: "wrench", infrastructure: "globe", + about: "fileText", "ai-agents": "brain", debug: "bug", logs: "scrollText", @@ -119,6 +120,7 @@ describe("titleForRoute", () => { automation: "Automation", mcp: "MCP", infrastructure: "Infrastructure", + about: "About", "ai-agents": "AI & Agents", debug: "Debug", logs: "Logs", @@ -154,6 +156,7 @@ describe("subtitleForRoute", () => { automation: "Commands, hooks, cron, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", infrastructure: "Gateway, web, browser, and media settings.", + about: "Control UI and connected Gateway build identity.", "ai-agents": "Agents, models, skills, tools, memory, session.", debug: "Snapshots, events, RPC.", logs: "Live gateway logs.", @@ -223,6 +226,8 @@ describe("routeIdFromPath", () => { expect(routeIdFromPath("/logs")).toBe("logs"); expect(routeIdFromPath("/dreaming")).toBe("dreams"); expect(routeIdFromPath("/dreams")).toBe("dreams"); + expect(routeIdFromPath("/settings/about")).toBe("about"); + expect(routeIdFromPath("/about")).toBeNull(); }); it("leaves root fallback to application startup", () => { @@ -302,6 +307,7 @@ describe("inferBasePathFromPathname", () => { it("preserves mount roots without a route suffix", () => { expect(inferBasePathFromPathname("/__openclaw__/")).toBe("/__openclaw__"); expect(inferBasePathFromPathname("/apps/openclaw/")).toBe("/apps/openclaw"); + expect(inferBasePathFromPathname("/about/")).toBe("/about"); expect(inferBasePathFromPathname("/typo")).toBe(""); }); @@ -352,6 +358,7 @@ describe("SIDEBAR_NAV_ROUTES", () => { "worktrees", "debug", "logs", + "about", ]); }); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index 1f966d145bf..69b7830a8b0 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -75,7 +75,7 @@ export const SETTINGS_NAVIGATION_GROUPS = [ { labelKey: "nav.settingsGroupAgents", routes: ["ai-agents", "automation", "mcp"] }, { labelKey: "nav.settingsGroupSystem", - routes: ["infrastructure", "worktrees", "debug", "logs"], + routes: ["infrastructure", "worktrees", "debug", "logs", "about"], }, ] as const satisfies readonly SettingsNavigationGroup[]; @@ -105,6 +105,7 @@ const NAVIGATION_ICONS: NavigationItem = { automation: "terminal", mcp: "wrench", infrastructure: "globe", + about: "fileText", "ai-agents": "brain", debug: "bug", logs: "scrollText", @@ -197,6 +198,7 @@ const NAVIGATION_COPY: Record { ); }); + it("keeps an about mount root distinct from the settings About route", () => { + expect(inferControlUiPublicAssetPath("sw.js", { pathname: "/about/" })).toBe("/about/sw.js"); + }); + it("prefers an explicit base path over pathname inference", () => { expect( inferControlUiPublicAssetPath("apple-touch-icon.png", { diff --git a/ui/src/app/service-worker-cache.test.ts b/ui/src/app/service-worker-cache.test.ts index 9b83792495d..cc428925f5b 100644 --- a/ui/src/app/service-worker-cache.test.ts +++ b/ui/src/app/service-worker-cache.test.ts @@ -15,6 +15,7 @@ describe("Control UI service worker cache versioning", () => { const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8"); expect(mainSource).toContain('swUrl.searchParams.set("v"'); + expect(mainSource).toContain("CONTROL_UI_BUILD_INFO.buildId"); expect(mainSource).toContain('updateViaCache: "none"'); expect(mainSource).toContain('navigator.serviceWorker.addEventListener("message"'); expect(mainSource).toContain("event.data.version !== currentControlUiBuildId"); @@ -30,6 +31,12 @@ describe("Control UI service worker cache versioning", () => { 'postMessage({ type: "sw-updated", version: CACHE_VERSION },', ); expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))"); + expect(viteConfigSource).toContain( + '"globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify(buildInfo)', + ); + expect(viteConfigSource).not.toContain( + "OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify(controlUiBuildId)", + ); expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"'); }); diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index 36e4533d528..1260024c34a 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -1,8 +1,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { controlUiBrowserOnlySharedModuleAliases, + resolveControlUiBuildInfo, resolveExternalPackageAliasesForVite, resolveSourcePackageAliasesForVite, resolveTsconfigPathAliasesForVite, @@ -21,6 +22,116 @@ function findStringAlias(key: string) { } describe("Control UI Vite config", () => { + it("embeds one canonical artifact identity from explicit build inputs", () => { + const readGitCommit = vi.fn(() => "f".repeat(40)); + expect( + resolveControlUiBuildInfo({ + env: { + GIT_COMMIT: "0123456789abcdef0123456789abcdef01234567", + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56Z", + }, + readGitCommit, + readPackageVersion: () => "2026.7.10", + }), + ).toEqual({ + version: "2026.7.10", + commit: "0123456789abcdef0123456789abcdef01234567", + builtAt: "2026-07-10T12:34:56.000Z", + buildId: "2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z", + }); + expect(readGitCommit).not.toHaveBeenCalled(); + }); + + it("falls back to Git and the current UTC time only when inputs are absent", () => { + expect( + resolveControlUiBuildInfo({ + env: {}, + now: () => new Date("2026-07-10T13:14:15.000Z"), + readGitCommit: () => "a".repeat(40), + readPackageVersion: () => null, + }), + ).toEqual({ + version: null, + commit: "a".repeat(40), + builtAt: "2026-07-10T13:14:15.000Z", + buildId: "aaaaaaaaaaaa-2026-07-10T13-14-15.000Z", + }); + }); + + it("uses checked-out Git instead of unverified GitHub workflow context", () => { + const readGitCommit = vi.fn(() => "c".repeat(40)); + expect( + resolveControlUiBuildInfo({ + env: { GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T13:14:15.000Z"), + readGitCommit, + readPackageVersion: () => null, + }).commit, + ).toBe("c".repeat(40)); + expect(readGitCommit).toHaveBeenCalledOnce(); + expect( + resolveControlUiBuildInfo({ + env: { GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T13:14:15.000Z"), + readGitCommit: () => null, + readPackageVersion: () => null, + }).commit, + ).toBe("b".repeat(40)); + expect(() => + resolveControlUiBuildInfo({ + env: { GITHUB_SHA: "bad" }, + readGitCommit: () => null, + readPackageVersion: () => null, + }), + ).toThrow("GITHUB_SHA must be a full 40-character hexadecimal SHA"); + }); + + it("uses explicit commit aliases before reading Git", () => { + const readGitCommit = vi.fn(() => "c".repeat(40)); + expect( + resolveControlUiBuildInfo({ + env: { GIT_SHA: "A".repeat(40), GITHUB_SHA: "b".repeat(40) }, + now: () => new Date("2026-07-10T13:14:15.000Z"), + readGitCommit, + readPackageVersion: () => null, + }).commit, + ).toBe("a".repeat(40)); + expect(readGitCommit).not.toHaveBeenCalled(); + }); + + it("does not let a generic release selector replace the artifact build identity", () => { + expect( + resolveControlUiBuildInfo({ + env: { + OPENCLAW_VERSION: "latest", + OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T13:14:15.000Z", + }, + readGitCommit: () => "a".repeat(40), + readPackageVersion: () => "2026.7.10", + }).buildId, + ).toBe("2026.7.10-aaaaaaaaaaaa-2026-07-10T13-14-15.000Z"); + }); + + it("fails closed for nonempty invalid explicit build inputs", () => { + const readGitCommit = vi.fn(() => "a".repeat(40)); + expect(() => + resolveControlUiBuildInfo({ + env: { GIT_COMMIT: "deadbeef" }, + readGitCommit, + readPackageVersion: () => "2026.7.10", + }), + ).toThrow("GIT_COMMIT must be a full 40-character hexadecimal SHA"); + expect(readGitCommit).not.toHaveBeenCalled(); + + expect(() => + resolveControlUiBuildInfo({ + env: { OPENCLAW_BUILD_TIMESTAMP: "2026-07-10 12:34:56" }, + readGitCommit: () => "a".repeat(40), + readPackageVersion: () => "2026.7.10", + }), + ).toThrow("OPENCLAW_BUILD_TIMESTAMP must be a valid UTC ISO-8601 timestamp ending in Z"); + }); + it("resolves root tsconfig package aliases for source imports", () => { expect(findStringAlias("@openclaw/net-policy/ip")?.replacement).toBe( path.join(repoRoot, "packages/net-policy/src/ip.ts"), diff --git a/ui/src/build-info.test.ts b/ui/src/build-info.test.ts new file mode 100644 index 00000000000..3152d79b320 --- /dev/null +++ b/ui/src/build-info.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + deriveControlUiBuildId, + normalizeControlUiBuildInfo, + normalizeControlUiBuildTimestamp, + normalizeControlUiCommit, +} from "./build-info.ts"; + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; + +describe("Control UI build info", () => { + it("keeps only full Git SHAs", () => { + expect(normalizeControlUiCommit(COMMIT.toUpperCase())).toBe(COMMIT); + expect(normalizeControlUiCommit(COMMIT.slice(0, 12))).toBeNull(); + expect(normalizeControlUiCommit("not-a-sha")).toBeNull(); + }); + + it("canonicalizes only valid UTC build timestamps", () => { + expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56Z")).toBe( + "2026-07-10T12:34:56.000Z", + ); + expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.123Z")).toBe( + "2026-07-10T12:34:56.123Z", + ); + expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.7Z")).toBe( + "2026-07-10T12:34:56.700Z", + ); + expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56.12Z")).toBe( + "2026-07-10T12:34:56.120Z", + ); + expect(normalizeControlUiBuildTimestamp("2026-02-30T12:34:56Z")).toBeNull(); + expect(normalizeControlUiBuildTimestamp("2026-07-10T12:34:56+00:00")).toBeNull(); + }); + + it("renders invalid injected metadata as unavailable instead of inventing identity", () => { + expect( + normalizeControlUiBuildInfo({ + version: " ", + commit: "deadbeef", + builtAt: "later", + buildId: "", + }), + ).toEqual({ version: null, commit: null, builtAt: null, buildId: "dev" }); + }); + + it("derives a stable service-worker id from the same artifact metadata", () => { + expect( + deriveControlUiBuildId({ + version: "2026.7.10", + commit: COMMIT, + builtAt: "2026-07-10T12:34:56.000Z", + }), + ).toBe("2026.7.10-0123456789ab-2026-07-10T12-34-56.000Z"); + }); +}); diff --git a/ui/src/build-info.ts b/ui/src/build-info.ts new file mode 100644 index 00000000000..bab7e3681f4 --- /dev/null +++ b/ui/src/build-info.ts @@ -0,0 +1,72 @@ +// Compile-time identity for the Control UI artifact. + +export type ControlUiBuildInfo = Readonly<{ + version: string | null; + commit: string | null; + builtAt: string | null; + buildId: string; +}>; + +type ControlUiBuildMetadata = Pick; + +const FULL_GIT_SHA = /^[0-9a-f]{40}$/u; +const UTC_BUILD_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; +const BUILD_ID_MAX_LENGTH = 96; + +declare global { + // Vite replaces this property with one object so the UI and service worker + // share the exact artifact identity without separate compile-time constants. + var OPENCLAW_CONTROL_UI_BUILD_INFO: ControlUiBuildInfo | undefined; +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function normalizeControlUiCommit(value: unknown): string | null { + const commit = normalizeOptionalString(value)?.toLowerCase() ?? null; + return commit && FULL_GIT_SHA.test(commit) ? commit : null; +} + +export function normalizeControlUiBuildTimestamp(value: unknown): string | null { + const timestamp = normalizeOptionalString(value); + if (!timestamp || !UTC_BUILD_TIMESTAMP.test(timestamp)) { + return null; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return null; + } + const canonicalInput = timestamp.replace(/(?:\.(\d{1,3}))?Z$/u, (_match, fraction) => { + return `.${String(fraction ?? "").padEnd(3, "0")}Z`; + }); + return date.toISOString() === canonicalInput ? date.toISOString() : null; +} + +export function normalizeControlUiBuildId(value: unknown): string { + const normalized = normalizeOptionalString(value)?.replace(/[^a-zA-Z0-9._-]+/g, "-"); + return normalized?.slice(0, BUILD_ID_MAX_LENGTH) || "dev"; +} + +export function deriveControlUiBuildId(info: ControlUiBuildMetadata): string { + const identity = [info.version, info.commit?.slice(0, 12), info.builtAt] + .filter((value): value is string => Boolean(value)) + .join("-"); + return normalizeControlUiBuildId(identity); +} + +export function normalizeControlUiBuildInfo(value: unknown): ControlUiBuildInfo { + const record = value && typeof value === "object" ? (value as Record) : {}; + const version = normalizeOptionalString(record.version); + const commit = normalizeControlUiCommit(record.commit); + const builtAt = normalizeControlUiBuildTimestamp(record.builtAt); + const metadata = { version, commit, builtAt }; + return { + ...metadata, + buildId: normalizeControlUiBuildId(record.buildId ?? deriveControlUiBuildId(metadata)), + }; +} + +const injectedBuildInfo = globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO; + +export const CONTROL_UI_BUILD_INFO = normalizeControlUiBuildInfo(injectedBuildInfo); diff --git a/ui/src/e2e/about.e2e.test.ts b/ui/src/e2e/about.e2e.test.ts new file mode 100644 index 00000000000..1b3f3d5e3e3 --- /dev/null +++ b/ui/src/e2e/about.e2e.test.ts @@ -0,0 +1,123 @@ +// Control UI tests cover About artifact identity against a mocked Gateway. +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const BUILT_AT = "2026-07-10T12:34:56.000Z"; + +let browser: Browser; +let server: ControlUiE2eServer; + +describeControlUiE2e("Control UI About mocked Gateway E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("shows and copies browser artifact identity, separately from the Gateway version", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + (globalThis as typeof globalThis & { __openclawCopiedCommit?: string })[ + "__openclawCopiedCommit" + ] = text; + }, + }, + }); + }); + const page = await context.newPage(); + await installMockGateway(page); + + try { + const response = await page.goto(`${server.baseUrl}settings/about`); + expect(response?.status()).toBe(200); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + + const aboutLink = page.getByRole("link", { name: "About", exact: true }); + await expect.poll(() => aboutLink.getAttribute("aria-current")).toBe("page"); + + const strip = page.getByRole("group", { name: "Control UI build details" }); + const items = strip.locator(":scope > .about-build-strip__item"); + await expect.poll(() => items.count()).toBe(3); + await expect.poll(() => items.nth(0).textContent()).toContain("2026.7.10"); + + const commit = items.nth(1).locator("code"); + await expect.poll(() => commit.textContent()).toBe(COMMIT.slice(0, 12)); + await expect.poll(() => commit.getAttribute("title")).toBe(COMMIT); + + const built = items.nth(2).locator("time"); + await expect.poll(() => built.textContent()).toBe("Jul 10, 2026"); + await expect.poll(() => built.getAttribute("datetime")).toBe(BUILT_AT); + await expect.poll(() => built.getAttribute("title")).toBe(BUILT_AT); + + const gatewayRow = page.locator(".about-gateway-row"); + await expect.poll(() => gatewayRow.textContent()).toContain("e2e"); + await expect + .poll(() => gatewayRow.textContent()) + .toContain("separate from this Control UI build"); + + const desktopTops = await items.evaluateAll((elements) => + elements.map((element) => Math.round(element.getBoundingClientRect().top)), + ); + expect(new Set(desktopTops).size).toBe(1); + + const copyButton = strip.locator(".about-build-strip__copy"); + await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Copy full commit hash"); + await copyButton.click(); + await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Commit hash copied"); + await expect + .poll(() => + page.evaluate( + () => + (globalThis as typeof globalThis & { __openclawCopiedCommit?: string })[ + "__openclawCopiedCommit" + ], + ), + ) + .toBe(COMMIT); + + await page.setViewportSize({ height: 812, width: 375 }); + const mobileTops = await items.evaluateAll((elements) => + elements.map((element) => Math.round(element.getBoundingClientRect().top)), + ); + expect(new Set(mobileTops).size).toBe(3); + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ), + ) + .toBeLessThanOrEqual(1); + const mobileScreenshot = await page.screenshot({ animations: "disabled", fullPage: true }); + expect(mobileScreenshot.byteLength).toBeGreaterThan(1_000); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index cd8b0ee46c3..677edf6527f 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:15.941Z", + "generatedAt": "2026-07-10T08:37:26.526Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index a17bf30ecc0..cfea003ae14 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:11.851Z", + "generatedAt": "2026-07-10T08:37:25.833Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 5110bcd7e48..7c93fd87adb 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:12.401Z", + "generatedAt": "2026-07-10T08:37:25.947Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 2c36858246e..59798236e08 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:22.383Z", + "generatedAt": "2026-07-10T08:37:27.543Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index be1d65d3009..54ee117017f 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:14.479Z", + "generatedAt": "2026-07-10T08:37:26.288Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index 850bd5c3523..9a2aa7354b2 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:15.230Z", + "generatedAt": "2026-07-10T08:37:26.413Z", "locale": "hi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.tm.jsonl b/ui/src/i18n/.i18n/hi.tm.jsonl index 2bf1611c243..72f25417716 100644 --- a/ui/src/i18n/.i18n/hi.tm.jsonl +++ b/ui/src/i18n/.i18n/hi.tm.jsonl @@ -93,6 +93,7 @@ {"cache_key":"0f6b1af340bd55073fddb1be59060e51ef9d7165d0829491393a536180463bf5","model":"gpt-5.5","provider":"openai","segment_id":"overview.snapshot.lastChannelsRefresh","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Last Channels Refresh","text_hash":"97a20d4f5b29914b8a08748cfc55d704a4d52ed948180cc90b7c1e06267c692f","tgt_lang":"hi","translated":"अंतिम चैनल रिफ्रेश","updated_at":"2026-06-26T21:33:15.607Z"} {"cache_key":"0fa51fb9e7d660ff75b9a91e4e0461a162f3b5b02106ba4c56c7e7c7295b7033","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.phrases.filingLooseThoughts","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"filing away loose thoughts…","text_hash":"352e9ecf138c39219228e6e09c7d8fde37b02f1dd93fe411cdf781257e9be521","tgt_lang":"hi","translated":"बिखरे विचारों को फ़ाइल किया जा रहा है…","updated_at":"2026-06-26T21:34:24.815Z"} {"cache_key":"0fba2d5dead215b9bf8cf4dbde67a0a18686ec09a3e598efe086bd06b627f0e4","model":"gpt-5.5","provider":"openai","segment_id":"profilePage.insightModel","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Most used model","text_hash":"a13c86cd4f835d2c2b5af940171ede2a27e0f805cef070d87628f1bd333bb706","tgt_lang":"hi","translated":"सबसे अधिक उपयोग किया गया model","updated_at":"2026-07-09T11:27:28.426Z"} +{"cache_key":"0fbbc3e0f5044eb45299580ae63007f92c11b5b0b6dabbbf44e6971eda686ac6","model":"gpt-5.5","provider":"openai","segment_id":"tabs.about","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"About","text_hash":"4efca0d10c5feb8e9b35eb1d994f2905bb71714e6a271f511d713b539ea5faa1","tgt_lang":"hi","translated":"परिचय","updated_at":"2026-06-26T21:29:48.427Z"} {"cache_key":"0fe85a1bf4b86ecdc3a2d90e92bc495f67df949b23bf5e5c36aea457a49c7863","model":"gpt-5.5","provider":"openai","segment_id":"chat.refreshTitle","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Refresh chat data","text_hash":"40b8edfd9a1326939cf9db6cca2b31d4af4e815606fe6d86f7982f4f9534e268","tgt_lang":"hi","translated":"चैट डेटा रीफ़्रेश करें","updated_at":"2026-06-26T21:36:16.800Z"} {"cache_key":"0ff9313f900028f3097a954f640efdb603a7a0b256f33332270ee6f08b6b6aed","model":"gpt-5.5","provider":"openai","segment_id":"execApproval.labels.cwd","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"hi","translated":"CWD","updated_at":"2026-06-26T21:31:18.653Z"} {"cache_key":"10419c56b65c6e46b45934a998bec02e871de17394057466ac7b7472ccead5a4","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.stats.promoted","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Promoted","text_hash":"0cf04463c4276a6276986c22155bd4a32ce81e8dd162a657dedfa9afb97a7371","tgt_lang":"hi","translated":"प्रमोट किया गया","updated_at":"2026-06-26T21:34:11.503Z"} @@ -1055,6 +1056,7 @@ {"cache_key":"a499cbb14cc64a92c85c56f3fe21c0458798fcf5d41c72ddfa191d928971b46e","model":"gpt-5.5","provider":"openai","segment_id":"chat.composer.microphonePageInactive","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Microphone inputs are unavailable while this page is inactive.","text_hash":"775110f07819e48dc96203ed710c4df3546892e5672d7c469dedeb1e0e163882","tgt_lang":"hi","translated":"इस पेज के निष्क्रिय होने पर माइक्रोफ़ोन इनपुट उपलब्ध नहीं होते।","updated_at":"2026-07-06T17:56:41.735Z"} {"cache_key":"a4cf89ac521759bd0db7ffb03ecb561b3661f011831f24fd96d9dd762b4c3f9b","model":"gpt-5.5","provider":"openai","segment_id":"usage.mosaic.title","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Activity by Time","text_hash":"d4f5e691d1d415aabf25860ac10b620e6f798075db0ef42c7a59a41f340c80e6","tgt_lang":"hi","translated":"समय के अनुसार गतिविधि","updated_at":"2026-06-26T21:35:33.329Z"} {"cache_key":"a52e4b9740b8ea09f561f2e1e59c345edeb999400f9c641dc20fb5b3041bb999","model":"gpt-5.5","provider":"openai","segment_id":"overview.stats.instances","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Instances","text_hash":"aa8c181ac3381dcd5890e42f64315a2540a9c7b35897570cf72f7ec1227e52e3","tgt_lang":"hi","translated":"इंस्टेंस","updated_at":"2026-06-26T21:33:15.607Z"} +{"cache_key":"a5373bbbc97e2ae1b422320057b9be9a0c0e0b0e592690b70dd9e75060c9254b","model":"gpt-5.5","provider":"openai","segment_id":"aboutPage.version","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Version","text_hash":"dd167905de0defcaf72de673ee44c07431770d129ccffab286bd2edfdaf62396","tgt_lang":"hi","translated":"संस्करण","updated_at":"2026-06-26T21:29:32.270Z"} {"cache_key":"a5bb15efc19a357825b921609b97670749ef138476b3a6cc1ef0e555acdc113f","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobDetail.delivery","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Delivery","text_hash":"52bfe584a5fc450539e2aa651b990fa2415060492a243816ab2994292089c6fd","tgt_lang":"hi","translated":"डिलीवरी","updated_at":"2026-06-26T21:38:12.145Z"} {"cache_key":"a5c6183b4802ffa0ef32cc9216b5017cd66eae2a894601a3aaf079e9f7818d0b","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"hi","translated":"प्रोटोकॉल उल्लंघन","updated_at":"2026-06-26T21:33:07.229Z"} {"cache_key":"a5c838f448dad8a4a8fa5507358bb548fa288e9200e3f6128b2b06ef8221e3e9","model":"gpt-5.5","provider":"openai","segment_id":"chat.sidebar.sortUpdated","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Last updated","text_hash":"382ac5f308f76c24b2c981e2041943bc2be2229cbd285ad362b9af1cfc386ef8","tgt_lang":"hi","translated":"अंतिम अपडेट","updated_at":"2026-07-06T15:07:02.499Z"} diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 649458eba09..628b24bf5a7 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:18.615Z", + "generatedAt": "2026-07-10T08:37:26.981Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index dbbcf6b6b10..aabbaf940a0 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:16.594Z", + "generatedAt": "2026-07-10T08:37:26.645Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index adc3f24d94f..a250cdfb947 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:13.014Z", + "generatedAt": "2026-07-10T08:37:26.060Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index f54fab2ee63..ed2286720fb 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:13.740Z", + "generatedAt": "2026-07-10T08:37:26.177Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index c6d9f4e3f06..52c9565e91a 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:21.861Z", + "generatedAt": "2026-07-10T08:37:27.430Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 249fb0cda98..c01dc88ba91 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:19.496Z", + "generatedAt": "2026-07-10T08:37:27.094Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index d40947c0904..a3059459b82 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:11.246Z", + "generatedAt": "2026-07-10T08:37:25.717Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index 9ad4be3cdbb..d6f6b83f95d 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:23.123Z", + "generatedAt": "2026-07-10T08:37:27.656Z", "locale": "ru", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.tm.jsonl b/ui/src/i18n/.i18n/ru.tm.jsonl index 99b0a8bfd84..5efde6b6f8e 100644 --- a/ui/src/i18n/.i18n/ru.tm.jsonl +++ b/ui/src/i18n/.i18n/ru.tm.jsonl @@ -193,6 +193,7 @@ {"cache_key":"1acb8f4746c621a41e875469d8ff80c85f4bc6f70ae84b0476d56ab499980628","model":"gpt-5.5","provider":"openai","segment_id":"login.failure.authFailed.title","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Auth did not match","text_hash":"fc356c09be2cf9bb83d3ceaa20507f882c15c3a3b970de3aa490102c176fb1ef","tgt_lang":"ru","translated":"Данные аутентификации не совпали","updated_at":"2026-06-26T21:41:35.342Z"} {"cache_key":"1afa63e8c13b8f530f0ec6bd986fabfb4253e3e6280f419063f14b55d2df2042","model":"gpt-5.5","provider":"openai","segment_id":"chat.composer.talkMoreInSettings","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"More in Settings","text_hash":"87f1e602d68bdc42ae3fddc0c1541d323adc718b7b9b3f889b2ca71823e319b9","tgt_lang":"ru","translated":"Больше в настройках","updated_at":"2026-07-06T20:20:02.809Z"} {"cache_key":"1b54014d5774ee4dd1b3e0d90ad2fe5ce461746beeba99bdffccfc2c7ad74418","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.tabs.scene","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Scene","text_hash":"477e5af2fd7e4472aad3064654e4aa8bdd8653d826e8a6bfbd14f3537b072df8","tgt_lang":"ru","translated":"Сцена","updated_at":"2026-06-26T21:40:37.573Z"} +{"cache_key":"1b54dc1444e7224c55d904c830d2d53ae9a512d7fc5cea69219921c02869f4a5","model":"gpt-5.5","provider":"openai","segment_id":"tabs.about","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"About","text_hash":"4efca0d10c5feb8e9b35eb1d994f2905bb71714e6a271f511d713b539ea5faa1","tgt_lang":"ru","translated":"О себе","updated_at":"2026-06-26T21:38:40.558Z"} {"cache_key":"1b7285f4f44ddfb7d59b7c74ce373a27003573af53f9005475248fa7a83550e4","model":"gpt-5.5","provider":"openai","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"ru","translated":"Выполняется","updated_at":"2026-06-26T21:39:50.999Z"} {"cache_key":"1bea0006e8ff48b6e8c2dd6ff9f1c0c4ba930a23636e3649be9f66788e22a986","model":"gpt-5.5","provider":"openai","segment_id":"usage.overview.cacheHint","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Cache hit rate = cache read / (input + cache read + cache write). Higher is better.","text_hash":"f27052f7e631b9a9897b95074717bca434b3de90e0e469526cfab6695e6ef339","tgt_lang":"ru","translated":"Доля попаданий в кеш = чтение из кеша / (ввод + чтение из кеша + запись в кеш). Чем выше, тем лучше.","updated_at":"2026-06-26T21:41:16.423Z"} {"cache_key":"1c2ab269873ac17858cb987abca957a236ee43bc1883cb2849af6113b2433896","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.scene.repairCache","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Repair Dream Cache","text_hash":"137618c99bf41b88cb335b627d02c1ad61336cfd9a4c4575c53893b167053d0a","tgt_lang":"ru","translated":"Восстановить кэш Dream","updated_at":"2026-06-26T21:40:41.209Z"} @@ -1126,6 +1127,7 @@ {"cache_key":"a68a38f23ac8d3dddff34d208691d3b5eeb226aeebfb87128e809e9df9ae3ad4","model":"gpt-5.5","provider":"openai","segment_id":"chat.workspaceFiles.search","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Search files","text_hash":"179fed85ec50a433bb23932745d18f1ade2f84a6ebe145b0025ed3ce5f89fd5a","tgt_lang":"ru","translated":"Поиск файлов","updated_at":"2026-06-26T21:42:03.305Z"} {"cache_key":"a6b26c00026d89c08c12d5028244da951ed6838f37fb1e164215ca8fa01a0bcb","model":"gpt-5.5","provider":"openai","segment_id":"overview.palette.noResults","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"No results","text_hash":"a43619f321175f57a27f2a38da381fd367f6806093031b1f82960bcbf542729d","tgt_lang":"ru","translated":"Нет результатов","updated_at":"2026-06-26T21:40:31.548Z"} {"cache_key":"a6e17eb5a2eb280401dc826fd3db5352a0091252050cdb134a55dcea3d713410","model":"gpt-5.5","provider":"openai","segment_id":"agents.alreadyDefaultTitle","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Already the default agent","text_hash":"28842ed7390eb8d6dc304fed820e1699adc70f80ac4b73777dcc8f29e5dafeb5","tgt_lang":"ru","translated":"Уже агент по умолчанию","updated_at":"2026-06-26T21:39:04.015Z"} +{"cache_key":"a6eb50e576bb793e4b98f642e046b2c27edbd0892aa380be4724aa7204360d50","model":"gpt-5.5","provider":"openai","segment_id":"aboutPage.version","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Version","text_hash":"dd167905de0defcaf72de673ee44c07431770d129ccffab286bd2edfdaf62396","tgt_lang":"ru","translated":"Версия","updated_at":"2026-06-26T21:38:30.975Z"} {"cache_key":"a6f8f8d0e62bf955801296b1562dcc0aeb36d55bc3ccabe57cc516e4417197cb","model":"gpt-5.5","provider":"openai","segment_id":"workboard.status.blocked","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Blocked","text_hash":"18f2a0947f9d6523991b29b450307f22773f57d65f7efb98d48a167df04d6b1d","tgt_lang":"ru","translated":"Заблокировано","updated_at":"2026-06-26T21:39:43.912Z"} {"cache_key":"a70552c1872a245a6752e07140580a0c671cdfd65a286e4c456cdbea8209ed26","model":"gpt-5.5","provider":"openai","segment_id":"agents.channels.notConfigured","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"not configured","text_hash":"9f33f06843e745c0bda6361e9d081672d7f4280f9ad0e8cf967e083f8ac34427","tgt_lang":"ru","translated":"не настроено","updated_at":"2026-06-26T21:39:07.415Z"} {"cache_key":"a71a86f09a31dea39aa30f81af4ddc8e67329b04c05b9f22a45009e723c51c62","model":"gpt-5.5","provider":"openai","segment_id":"chat.sidebar.sortCreated","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Created","text_hash":"d70b9e24bca26b409b9458ceca6c9e5c2b5c3171c37ff050c6f6a0d7a4420d2a","tgt_lang":"ru","translated":"Создано","updated_at":"2026-07-06T15:07:02.499Z"} diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index e77161fd84d..60df91be14d 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:20.263Z", + "generatedAt": "2026-07-10T08:37:27.205Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 78f6ad7d869..061110e518b 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:17.364Z", + "generatedAt": "2026-07-10T08:37:26.758Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index aa21274e89c..5789a726747 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:18.064Z", + "generatedAt": "2026-07-10T08:37:26.870Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 768350acb07..60b651b6484 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:21.125Z", + "generatedAt": "2026-07-10T08:37:27.320Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index c0bd107e830..c37684d8961 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:09.917Z", + "generatedAt": "2026-07-10T08:37:25.489Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index e92f0aad52d..473f6d974db 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-10T08:19:10.612Z", + "generatedAt": "2026-07-10T08:37:25.603Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "3883d1abcb57aa52cf17aab0761a1f02be8a469c52ccc72dbe551f78cd5de297", - "totalKeys": 1779, - "translatedKeys": 1779, + "sourceHash": "f6a3e03d983c828decd96c8d86d239a1ce4cb9ffd26f7989145a7614ebce30c3", + "totalKeys": 1794, + "translatedKeys": 1794, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 6bfa0740b6c..d93d97add75 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -546,6 +546,7 @@ export const ar: TranslationMap = { automation: "الأتمتة", mcp: "MCP", infrastructure: "البنية التحتية", + about: "حول", aiAgents: "الذكاء الاصطناعي والوكلاء", debug: "تصحيح الأخطاء", logs: "السجلات", @@ -575,12 +576,28 @@ export const ar: TranslationMap = { automation: "الأوامر، والخطافات، وcron، والمكونات الإضافية.", mcp: "خوادم MCP، والمصادقة، والأدوات، والتشخيصات.", infrastructure: "إعدادات Gateway، والويب، والمتصفح، والوسائط.", + about: "هوية إصدار Control UI وGateway المتصل.", aiAgents: "الوكلاء، والنماذج، والمهارات، والأدوات، والذاكرة، والجلسة.", debug: "اللقطات، والأحداث، وRPC.", logs: "سجلات Gateway المباشرة.", dreams: "حلم الذاكرة، والدمج، والتأمل.", plugin: "لوحة مقدمة من المكوّن الإضافي.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "الهوية المضمّنة عند إنشاء نسخة المتصفح هذه.", + artifactDetails: "تفاصيل إصدار Control UI", + version: "الإصدار", + commit: "الالتزام", + built: "تاريخ البناء", + unavailable: "غير متاح", + copyCommit: "نسخ تجزئة الالتزام الكاملة", + copyingCommit: "جارٍ نسخ تجزئة الالتزام", + copiedCommit: "تم نسخ تجزئة الالتزام", + copyCommitFailed: "تعذّر نسخ تجزئة الالتزام", + gatewayVersion: "إصدار Gateway المتصل", + gatewayVersionHint: "يُبلّغ عنه اتصال Gateway النشط، وهو منفصل عن إصدار Control UI هذا.", + }, profilePage: { offline: "اتصل بـ Gateway للقاء وكيلك.", loading: "نغوص بحثًا عن الإحصاءات…", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 807e018f0a6..cec98320739 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -551,6 +551,7 @@ export const de: TranslationMap = { automation: "Automatisierung", mcp: "MCP", infrastructure: "Infrastruktur", + about: "Info", aiAgents: "KI & Agenten", debug: "Debug", logs: "Protokolle", @@ -581,12 +582,28 @@ export const de: TranslationMap = { automation: "Befehle, Hooks, Cron und Plugins.", mcp: "MCP-Server, Authentifizierung, Tools und Diagnosen.", infrastructure: "Gateway-, Web-, Browser- und Medieneinstellungen.", + about: "Build-Identität der Control UI und des verbundenen Gateways.", aiAgents: "Agenten, Modelle, Skills, Tools, Speicher, Sitzung.", debug: "Gateway-Snapshots, Ereignisse und manuelle RPC-Aufrufe.", logs: "Live-Verfolgung der Gateway-Protokolldateien.", dreams: "Speicherkonsolidierung im Schlaf.", plugin: "Vom Plugin bereitgestelltes Panel.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identität, die beim Erstellen dieses Browser-Artefakts eingebettet wurde.", + artifactDetails: "Build-Details der Control UI", + version: "Versionsnummer", + commit: "Commit-Hash", + built: "Erstellt", + unavailable: "Nicht verfügbar", + copyCommit: "Vollständigen Commit-Hash kopieren", + copyingCommit: "Commit-Hash wird kopiert", + copiedCommit: "Commit-Hash kopiert", + copyCommitFailed: "Commit-Hash konnte nicht kopiert werden", + gatewayVersion: "Version des verbundenen Gateways", + gatewayVersionHint: "Vom aktiven Gateway gemeldet; unabhängig von diesem Build der Control UI.", + }, profilePage: { offline: "Verbinde dich mit der Gateway, um deinen Agenten kennenzulernen.", loading: "Suche nach Statistiken…", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1ef8a3fcb33..76beb5970a4 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -545,6 +545,7 @@ export const en: TranslationMap = { automation: "Automation", mcp: "MCP", infrastructure: "Infrastructure", + about: "About", aiAgents: "AI & Agents", debug: "Debug", logs: "Logs", @@ -574,12 +575,29 @@ export const en: TranslationMap = { automation: "Commands, hooks, cron, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", infrastructure: "Gateway, web, browser, and media settings.", + about: "Control UI and connected Gateway build identity.", aiAgents: "Agents, models, skills, tools, memory, session.", debug: "Snapshots, events, RPC.", logs: "Live gateway logs.", dreams: "Memory dreaming, consolidation, and reflection.", plugin: "Plugin-provided panel.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identity embedded when this browser artifact was built.", + artifactDetails: "Control UI build details", + version: "Version", + commit: "Commit", + built: "Built", + unavailable: "Unavailable", + copyCommit: "Copy full commit hash", + copyingCommit: "Copying commit hash", + copiedCommit: "Commit hash copied", + copyCommitFailed: "Could not copy commit hash", + gatewayVersion: "Connected Gateway version", + gatewayVersionHint: + "Reported by the active Gateway connection; separate from this Control UI build.", + }, profilePage: { offline: "Connect to the gateway to meet your agent.", loading: "Diving for stats…", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index f42cfd11268..5e25ac63f8a 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -548,6 +548,7 @@ export const es: TranslationMap = { automation: "Automatización", mcp: "MCP", infrastructure: "Infraestructura", + about: "Acerca de", aiAgents: "IA y agentes", debug: "Depuración", logs: "Registros", @@ -578,12 +579,29 @@ export const es: TranslationMap = { automation: "Comandos, hooks, cron y plugins.", mcp: "Servidores MCP, autenticación, herramientas y diagnósticos.", infrastructure: "Configuración de Gateway, web, navegador y medios.", + about: "Identidad de compilación de Control UI y del Gateway conectado.", aiAgents: "Agentes, modelos, Skills, herramientas, memoria, sesión.", debug: "Instantáneas de la puerta de enlace, eventos y llamadas RPC manuales.", logs: "Seguimiento en vivo de los registros de la puerta de enlace.", dreams: "Consolidación de la memoria durante el sueño.", plugin: "Panel proporcionado por el plugin.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identidad integrada al compilar este artefacto del navegador.", + artifactDetails: "Detalles de compilación de Control UI", + version: "Versión", + commit: "Commit", + built: "Compilado", + unavailable: "No disponible", + copyCommit: "Copiar el hash completo del commit", + copyingCommit: "Copiando el hash del commit", + copiedCommit: "Hash del commit copiado", + copyCommitFailed: "No se pudo copiar el hash del commit", + gatewayVersion: "Versión del Gateway conectado", + gatewayVersionHint: + "Informada por la conexión activa al Gateway; es independiente de esta compilación de Control UI.", + }, profilePage: { offline: "Conéctate al Gateway para conocer a tu agente.", loading: "Buceando en busca de estadísticas…", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index f52267f3de7..509e5f9063e 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -548,6 +548,7 @@ export const fa: TranslationMap = { automation: "اتوماسیون", mcp: "MCP", infrastructure: "زیرساخت", + about: "درباره", aiAgents: "هوش مصنوعی و عامل‌ها", debug: "اشکال‌زدایی", logs: "گزارش‌ها", @@ -577,12 +578,28 @@ export const fa: TranslationMap = { automation: "فرمان‌ها، قلاب‌ها، cron و پلاگین‌ها.", mcp: "سرورهای MCP، احراز هویت، ابزارها و عیب‌یابی.", infrastructure: "تنظیمات Gateway، وب، مرورگر و رسانه.", + about: "شناسهٔ ساخت Control UI و Gateway متصل.", aiAgents: "عامل‌ها، مدل‌ها، مهارت‌ها، ابزارها، حافظه، نشست.", debug: "نماهای لحظه‌ای، رویدادها، RPC.", logs: "گزارش‌های زنده Gateway.", dreams: "رؤیاپردازی حافظه، یکپارچه‌سازی و بازتاب.", plugin: "پنل ارائه‌شده توسط افزونه.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "شناسه‌ای که هنگام ساخت این خروجی مرورگر در آن جاسازی شده است.", + artifactDetails: "جزئیات ساخت Control UI", + version: "نسخه", + commit: "کامیت", + built: "زمان ساخت", + unavailable: "در دسترس نیست", + copyCommit: "کپی هش کامل کامیت", + copyingCommit: "در حال کپی هش کامیت", + copiedCommit: "هش کامیت کپی شد", + copyCommitFailed: "کپی هش کامیت ممکن نشد", + gatewayVersion: "نسخهٔ Gateway متصل", + gatewayVersionHint: "از اتصال فعال Gateway گزارش شده است و با این ساخت Control UI تفاوت دارد.", + }, profilePage: { offline: "برای دیدن عامل خود به Gateway متصل شوید.", loading: "در حال غواصی برای یافتن آمار…", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 91280a2890f..3119cf2eedf 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -553,6 +553,7 @@ export const fr: TranslationMap = { automation: "Automatisation", mcp: "MCP", infrastructure: "Infrastructure", + about: "À propos", aiAgents: "IA et agents", debug: "Débogage", logs: "Journaux", @@ -583,12 +584,29 @@ export const fr: TranslationMap = { automation: "Commandes, hooks, cron et plugins.", mcp: "Serveurs MCP, authentification, outils et diagnostics.", infrastructure: "Paramètres Gateway, web, navigateur et médias.", + about: "Identité de compilation de Control UI et du Gateway connecté.", aiAgents: "Agents, modèles, Skills, outils, mémoire, session.", debug: "Captures, événements, RPC.", logs: "Journaux Gateway en direct.", dreams: "Consolidation de la mémoire pendant le sommeil.", plugin: "Panneau fourni par le plugin.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identité intégrée lors de la compilation de cet artefact de navigateur.", + artifactDetails: "Détails de compilation de Control UI", + version: "Version", + commit: "Commit", + built: "Compilation", + unavailable: "Indisponible", + copyCommit: "Copier le hash complet du commit", + copyingCommit: "Copie du hash du commit en cours", + copiedCommit: "Hash du commit copié", + copyCommitFailed: "Impossible de copier le hash du commit", + gatewayVersion: "Version du Gateway connecté", + gatewayVersionHint: + "Indiquée par la connexion active au Gateway; distincte de cette compilation de Control UI.", + }, profilePage: { offline: "Connectez-vous au Gateway pour rencontrer votre agent.", loading: "Plongée à la recherche des statistiques…", diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index bbb8efe3adc..cf4578601f7 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -547,6 +547,7 @@ export const hi: TranslationMap = { automation: "ऑटोमेशन", mcp: "MCP", infrastructure: "इन्फ्रास्ट्रक्चर", + about: "परिचय", aiAgents: "AI और एजेंट्स", debug: "डीबग", logs: "लॉग्स", @@ -576,12 +577,28 @@ export const hi: TranslationMap = { automation: "कमांड, हुक, cron, और प्लगइन।", mcp: "MCP सर्वर, auth, टूल, और डायग्नोस्टिक्स।", infrastructure: "Gateway, web, browser, और media सेटिंग्स।", + about: "Control UI और कनेक्ट किए गए Gateway की बिल्ड पहचान।", aiAgents: "एजेंट, मॉडल, स्किल, टूल, मेमोरी, सेशन।", debug: "स्नैपशॉट, इवेंट, RPC।", logs: "लाइव gateway लॉग।", dreams: "मेमोरी ड्रीमिंग, कंसॉलिडेशन, और रिफ्लेक्शन।", plugin: "प्लगइन द्वारा उपलब्ध कराया गया पैनल।", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "इस ब्राउज़र आर्टिफ़ैक्ट के बनते समय जोड़ी गई पहचान।", + artifactDetails: "Control UI बिल्ड का विवरण", + version: "संस्करण", + commit: "कमिट", + built: "बिल्ड समय", + unavailable: "उपलब्ध नहीं", + copyCommit: "पूरा कमिट हैश कॉपी करें", + copyingCommit: "कमिट हैश कॉपी हो रहा है", + copiedCommit: "कमिट हैश कॉपी हो गया", + copyCommitFailed: "कमिट हैश कॉपी नहीं हो सका", + gatewayVersion: "कनेक्ट किए गए Gateway का संस्करण", + gatewayVersionHint: "सक्रिय Gateway कनेक्शन से प्राप्त; यह Control UI बिल्ड से अलग है।", + }, profilePage: { offline: "अपने एजेंट से मिलने के लिए Gateway से कनेक्ट करें।", loading: "आँकड़ों के लिए गोता लगा रहे हैं…", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 1aa3f9e8283..1e607de3dc4 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -548,6 +548,7 @@ export const id: TranslationMap = { automation: "Otomatisasi", mcp: "MCP", infrastructure: "Infrastruktur", + about: "Tentang", aiAgents: "AI & Agen", debug: "Debug", logs: "Log", @@ -577,12 +578,29 @@ export const id: TranslationMap = { automation: "Perintah, hook, cron, dan plugin.", mcp: "Server MCP, autentikasi, alat, dan diagnostik.", infrastructure: "Pengaturan Gateway, web, browser, dan media.", + about: "Identitas build Control UI dan Gateway yang terhubung.", aiAgents: "Agen, model, Skills, alat, memori, sesi.", debug: "Snapshot, peristiwa, RPC.", logs: "Log Gateway langsung.", dreams: "Konsolidasi memori saat tidur.", plugin: "Panel yang disediakan plugin.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identitas yang disematkan saat artefak browser ini dibuat.", + artifactDetails: "Detail build Control UI", + version: "Versi", + commit: "Commit", + built: "Dibuat", + unavailable: "Tidak tersedia", + copyCommit: "Salin hash commit lengkap", + copyingCommit: "Menyalin hash commit", + copiedCommit: "Hash commit disalin", + copyCommitFailed: "Hash commit tidak dapat disalin", + gatewayVersion: "Versi Gateway yang terhubung", + gatewayVersionHint: + "Dilaporkan oleh koneksi Gateway aktif; terpisah dari build Control UI ini.", + }, profilePage: { offline: "Hubungkan ke gateway untuk bertemu agen Anda.", loading: "Menyelam mencari statistik…", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 77e192f08f3..cf4ff13d7fa 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -553,6 +553,7 @@ export const it: TranslationMap = { automation: "Automazione", mcp: "MCP", infrastructure: "Infrastruttura", + about: "Informazioni", aiAgents: "IA e agenti", debug: "Debug", logs: "Log", @@ -582,12 +583,29 @@ export const it: TranslationMap = { automation: "Comandi, hook, cron e plugin.", mcp: "Server MCP, autenticazione, strumenti e diagnostica.", infrastructure: "Impostazioni Gateway, web, browser e media.", + about: "Identità di build di Control UI e del Gateway connesso.", aiAgents: "Agenti, modelli, skills, strumenti, memoria, sessione.", debug: "Snapshot, eventi, RPC.", logs: "Log gateway live.", dreams: "Sogni della memoria, consolidamento e riflessione.", plugin: "Pannello fornito dal plugin.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identità incorporata durante la build di questo artefatto del browser.", + artifactDetails: "Dettagli della build di Control UI", + version: "Versione", + commit: "Commit", + built: "Compilato", + unavailable: "Non disponibile", + copyCommit: "Copia l'hash completo del commit", + copyingCommit: "Copia dell'hash del commit in corso", + copiedCommit: "Hash del commit copiato", + copyCommitFailed: "Impossibile copiare l'hash del commit", + gatewayVersion: "Versione del Gateway connesso", + gatewayVersionHint: + "Segnalata dalla connessione Gateway attiva; distinta da questa build di Control UI.", + }, profilePage: { offline: "Connettiti al gateway per incontrare il tuo agente.", loading: "Immersione alla ricerca delle statistiche…", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index c92a816f894..e0a4fcd6230 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -553,6 +553,7 @@ export const ja_JP: TranslationMap = { automation: "自動化", mcp: "MCP", infrastructure: "インフラストラクチャ", + about: "概要", aiAgents: "AI とエージェント", debug: "デバッグ", logs: "ログ", @@ -582,12 +583,29 @@ export const ja_JP: TranslationMap = { automation: "コマンド、フック、cron、プラグイン。", mcp: "MCP サーバー、認証、ツール、診断。", infrastructure: "Gateway、Web、ブラウザー、メディアの設定。", + about: "Control UI と接続中の Gateway のビルド識別情報。", aiAgents: "エージェント、モデル、Skills、ツール、メモリ、セッション。", debug: "スナップショット、イベント、RPC。", logs: "ライブ Gateway ログ。", dreams: "スリープ中のメモリ統合。", plugin: "プラグインが提供するパネル。", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "このブラウザー向け成果物のビルド時に埋め込まれた識別情報。", + artifactDetails: "Control UI のビルド詳細", + version: "バージョン", + commit: "コミット", + built: "ビルド日時", + unavailable: "利用できません", + copyCommit: "完全なコミットハッシュをコピー", + copyingCommit: "コミットハッシュをコピー中", + copiedCommit: "コミットハッシュをコピーしました", + copyCommitFailed: "コミットハッシュをコピーできませんでした", + gatewayVersion: "接続中の Gateway のバージョン", + gatewayVersionHint: + "アクティブな Gateway 接続から報告された情報です。この Control UI のビルドとは異なります。", + }, profilePage: { offline: "Gateway に接続してエージェントに会いましょう。", loading: "統計を探しています…", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 05ae5908dec..190efb587e2 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -546,6 +546,7 @@ export const ko: TranslationMap = { automation: "자동화", mcp: "MCP", infrastructure: "인프라", + about: "정보", aiAgents: "AI 및 에이전트", debug: "디버그", logs: "로그", @@ -575,12 +576,28 @@ export const ko: TranslationMap = { automation: "명령, hooks, cron, plugins.", mcp: "MCP 서버, 인증, 도구 및 진단.", infrastructure: "Gateway, 웹, 브라우저, 미디어 설정.", + about: "Control UI 및 연결된 Gateway의 빌드 식별 정보입니다.", aiAgents: "에이전트, 모델, Skills, 도구, 메모리, 세션.", debug: "스냅샷, 이벤트, RPC.", logs: "실시간 Gateway 로그.", dreams: "수면 중 메모리 통합.", plugin: "플러그인이 제공하는 패널입니다.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "이 브라우저 아티팩트가 빌드될 때 포함된 식별 정보입니다.", + artifactDetails: "Control UI 빌드 세부 정보", + version: "버전", + commit: "커밋", + built: "빌드 일시", + unavailable: "사용할 수 없음", + copyCommit: "전체 커밋 해시 복사", + copyingCommit: "커밋 해시 복사 중", + copiedCommit: "커밋 해시를 복사했습니다", + copyCommitFailed: "커밋 해시를 복사할 수 없습니다", + gatewayVersion: "연결된 Gateway 버전", + gatewayVersionHint: "활성 Gateway 연결에서 보고한 버전이며, 이 Control UI 빌드와는 별개입니다.", + }, profilePage: { offline: "에이전트를 만나려면 Gateway에 연결하세요.", loading: "통계를 찾으러 잠수 중…", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index de6fe0ce965..212618219ec 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -551,6 +551,7 @@ export const nl: TranslationMap = { automation: "Automatisering", mcp: "MCP", infrastructure: "Infrastructuur", + about: "Over", aiAgents: "AI en agents", debug: "Debuggen", logs: "Logs", @@ -580,12 +581,29 @@ export const nl: TranslationMap = { automation: "Commando's, hooks, cron en plugins.", mcp: "MCP-servers, auth, tools en diagnostiek.", infrastructure: "Gateway-, web-, browser- en media-instellingen.", + about: "Buildidentiteit van de Control UI en de verbonden Gateway.", aiAgents: "Agents, modellen, skills, tools, geheugen, sessie.", debug: "Momentopnamen, gebeurtenissen, RPC.", logs: "Live Gateway-logs.", dreams: "Geheugendromen, consolidatie en reflectie.", plugin: "Door een plugin geleverd paneel.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identiteit die is ingesloten toen dit browserartefact werd gebouwd.", + artifactDetails: "Buildgegevens van de Control UI", + version: "Versie", + commit: "Commit-hash", + built: "Gebouwd", + unavailable: "Niet beschikbaar", + copyCommit: "Volledige commit-hash kopiëren", + copyingCommit: "Commit-hash wordt gekopieerd", + copiedCommit: "Commit-hash gekopieerd", + copyCommitFailed: "Commit-hash kon niet worden gekopieerd", + gatewayVersion: "Versie van de verbonden Gateway", + gatewayVersionHint: + "Gerapporteerd door de actieve Gateway-verbinding; staat los van de build van deze Control UI.", + }, profilePage: { offline: "Maak verbinding met de Gateway om je agent te ontmoeten.", loading: "Duiken naar statistieken…", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 1e0b2a10ec3..008536b6cf2 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -549,6 +549,7 @@ export const pl: TranslationMap = { automation: "Automatyzacja", mcp: "MCP", infrastructure: "Infrastruktura", + about: "Informacje", aiAgents: "AI i agenci", debug: "Debug", logs: "Logi", @@ -578,12 +579,29 @@ export const pl: TranslationMap = { automation: "Polecenia, hooki, cron i pluginy.", mcp: "Serwery MCP, uwierzytelnianie, narzędzia i diagnostyka.", infrastructure: "Ustawienia Gateway, web, przeglądarki i multimediów.", + about: "Tożsamość kompilacji Control UI i połączonego Gateway.", aiAgents: "Agenci, modele, Skills, narzędzia, pamięć, sesja.", debug: "Migawki, zdarzenia, RPC.", logs: "Logi Gateway na żywo.", dreams: "Konsolidacja pamięci podczas snu.", plugin: "Panel udostępniany przez wtyczkę.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Tożsamość osadzona podczas tworzenia tego artefaktu przeglądarki.", + artifactDetails: "Szczegóły kompilacji Control UI", + version: "Wersja", + commit: "Hash commita", + built: "Data kompilacji", + unavailable: "Niedostępne", + copyCommit: "Kopiuj pełny hash commita", + copyingCommit: "Kopiowanie hasha commita", + copiedCommit: "Skopiowano hash commita", + copyCommitFailed: "Nie udało się skopiować hasha commita", + gatewayVersion: "Wersja połączonego Gateway", + gatewayVersionHint: + "Zgłoszona przez aktywne połączenie Gateway; niezależna od tej kompilacji Control UI.", + }, profilePage: { offline: "Połącz się z Gateway, aby poznać swojego agenta.", loading: "Nurkowanie po statystyki…", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index ece32372b53..33c85487c37 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -547,6 +547,7 @@ export const pt_BR: TranslationMap = { automation: "Automação", mcp: "MCP", infrastructure: "Infraestrutura", + about: "Sobre", aiAgents: "IA e Agentes", debug: "Depuração", logs: "Logs", @@ -576,12 +577,29 @@ export const pt_BR: TranslationMap = { automation: "Configurações de comandos, hooks, cron e plugins.", mcp: "Servidores MCP, autenticação, ferramentas e diagnósticos.", infrastructure: "Configurações de gateway, web, browser e mídia.", + about: "Identidade de compilação do Control UI e do Gateway conectado.", aiAgents: "Configurações de agentes, modelos, habilidades, ferramentas, memória e sessão.", debug: "Snapshots, eventos, RPC.", logs: "Logs ao vivo do gateway.", dreams: "Consolidação de memória durante o sono.", plugin: "Painel fornecido pelo plugin.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Identidade incorporada quando este artefato do navegador foi compilado.", + artifactDetails: "Detalhes da build do Control UI", + version: "Versão", + commit: "Commit", + built: "Compilado", + unavailable: "Indisponível", + copyCommit: "Copiar o hash completo do commit", + copyingCommit: "Copiando o hash do commit", + copiedCommit: "Hash do commit copiado", + copyCommitFailed: "Não foi possível copiar o hash do commit", + gatewayVersion: "Versão do Gateway conectado", + gatewayVersionHint: + "Informada pela conexão ativa ao Gateway; separada desta build do Control UI.", + }, profilePage: { offline: "Conecte-se ao gateway para conhecer seu agente.", loading: "Mergulhando em busca de estatísticas…", diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index aad3babb25c..81514369405 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -552,6 +552,7 @@ export const ru: TranslationMap = { automation: "Автоматизация", mcp: "MCP", infrastructure: "Инфраструктура", + about: "О себе", aiAgents: "ИИ и агенты", debug: "Отладка", logs: "Журналы", @@ -582,12 +583,29 @@ export const ru: TranslationMap = { automation: "Команды, хуки, cron и плагины.", mcp: "Серверы MCP, аутентификация, инструменты и диагностика.", infrastructure: "Настройки шлюза, веба, браузера и медиа.", + about: "Идентификаторы сборки Control UI и подключённого Gateway.", aiAgents: "Агенты, модели, навыки, инструменты, память, сессия.", debug: "Снимки, события, RPC.", logs: "Живые журналы шлюза.", dreams: "Сновидения памяти, консолидация и рефлексия.", plugin: "Панель, предоставленная плагином.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Идентификатор, встроенный при сборке этого браузерного артефакта.", + artifactDetails: "Сведения о сборке Control UI", + version: "Версия", + commit: "Коммит", + built: "Собрано", + unavailable: "Недоступно", + copyCommit: "Скопировать полный хеш коммита", + copyingCommit: "Копирование хеша коммита", + copiedCommit: "Хеш коммита скопирован", + copyCommitFailed: "Не удалось скопировать хеш коммита", + gatewayVersion: "Версия подключённого Gateway", + gatewayVersionHint: + "Эту версию сообщает активное подключение к Gateway; она не относится к сборке Control UI.", + }, profilePage: { offline: "Подключитесь к Gateway, чтобы встретить своего агента.", loading: "Погружаемся за статистикой…", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 611fe740cbc..ef73ad5f0a7 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -545,6 +545,7 @@ export const th: TranslationMap = { automation: "ระบบอัตโนมัติ", mcp: "MCP", infrastructure: "โครงสร้างพื้นฐาน", + about: "เกี่ยวกับ", aiAgents: "AI และเอเจนต์", debug: "ดีบัก", logs: "บันทึก", @@ -574,12 +575,28 @@ export const th: TranslationMap = { automation: "คำสั่ง hooks cron และ plugins", mcp: "เซิร์ฟเวอร์ MCP, การยืนยันตัวตน, เครื่องมือ และการวินิจฉัย", infrastructure: "การตั้งค่าเกตเวย์ เว็บ เบราว์เซอร์ และสื่อ", + about: "ข้อมูลระบุบิลด์ของ Control UI และ Gateway ที่เชื่อมต่ออยู่", aiAgents: "เอเจนต์ โมเดล ทักษะ เครื่องมือ หน่วยความจำ เซสชัน", debug: "สแนปช็อต เหตุการณ์ และ RPC", logs: "บันทึกเกตเวย์แบบสด", dreams: "การฝันของหน่วยความจำ การรวมข้อมูล และการสะท้อนคิด", plugin: "แผงที่ปลั๊กอินจัดเตรียมไว้", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "ข้อมูลระบุที่ฝังไว้ขณะบิลด์อาร์ติแฟกต์ของเบราว์เซอร์นี้", + artifactDetails: "รายละเอียดบิลด์ Control UI", + version: "เวอร์ชัน", + commit: "คอมมิต", + built: "บิลด์เมื่อ", + unavailable: "ไม่พร้อมใช้งาน", + copyCommit: "คัดลอกแฮชคอมมิตแบบเต็ม", + copyingCommit: "กำลังคัดลอกแฮชคอมมิต", + copiedCommit: "คัดลอกแฮชคอมมิตแล้ว", + copyCommitFailed: "ไม่สามารถคัดลอกแฮชคอมมิตได้", + gatewayVersion: "เวอร์ชัน Gateway ที่เชื่อมต่ออยู่", + gatewayVersionHint: "รายงานโดยการเชื่อมต่อ Gateway ที่ใช้งานอยู่ และแยกจากบิลด์ Control UI นี้", + }, profilePage: { offline: "เชื่อมต่อกับ Gateway เพื่อพบเอเจนต์ของคุณ", loading: "กำลังดำลงไปหาสถิติ…", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 80982d1d9ee..1e36fbb2125 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -551,6 +551,7 @@ export const tr: TranslationMap = { automation: "Otomasyon", mcp: "MCP", infrastructure: "Altyapı", + about: "Hakkında", aiAgents: "AI ve Aracılar", debug: "Hata Ayıklama", logs: "Günlükler", @@ -581,12 +582,29 @@ export const tr: TranslationMap = { automation: "Komutlar, kancalar, cron ve eklentiler.", mcp: "MCP sunucuları, kimlik doğrulama, araçlar ve tanılama.", infrastructure: "Gateway, web, tarayıcı ve medya ayarları.", + about: "Control UI ile bağlı Gateway'in derleme kimliği.", aiAgents: "Aracılar, modeller, Skills, araçlar, bellek, oturum.", debug: "Anlık görüntüler, olaylar, RPC.", logs: "Canlı Gateway günlükleri.", dreams: "Uyku sırasında bellek birleştirme.", plugin: "Eklenti tarafından sağlanan panel.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Bu tarayıcı paketi derlenirken içine yerleştirilen kimlik.", + artifactDetails: "Control UI derleme ayrıntıları", + version: "Sürüm", + commit: "Commit kimliği", + built: "Derlenme tarihi", + unavailable: "Kullanılamıyor", + copyCommit: "Tam commit hash'ini kopyala", + copyingCommit: "Commit hash'i kopyalanıyor", + copiedCommit: "Commit hash'i kopyalandı", + copyCommitFailed: "Commit hash'i kopyalanamadı", + gatewayVersion: "Bağlı Gateway sürümü", + gatewayVersionHint: + "Etkin Gateway bağlantısı tarafından bildirilir; bu Control UI derlemesinden ayrıdır.", + }, profilePage: { offline: "Ajanınızla tanışmak için gateway'e bağlanın.", loading: "İstatistikler için dalınıyor…", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 60013ce9581..e01d980c951 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -548,6 +548,7 @@ export const uk: TranslationMap = { automation: "Автоматизація", mcp: "MCP", infrastructure: "Інфраструктура", + about: "Про програму", aiAgents: "AI та агенти", debug: "Налагодження", logs: "Журнали", @@ -578,12 +579,28 @@ export const uk: TranslationMap = { automation: "Команди, хуки, cron і плагіни.", mcp: "MCP-сервери, автентифікація, інструменти та діагностика.", infrastructure: "Налаштування шлюзу, вебу, браузера та медіа.", + about: "Ідентифікація збірки Control UI та підключеного Gateway.", aiAgents: "Агенти, моделі, навички, інструменти, пам’ять, сеанс.", debug: "Знімки, події, RPC.", logs: "Журнали шлюзу в реальному часі.", dreams: "Консолідація пам’яті під час сну.", plugin: "Панель, надана плагіном.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Ідентифікаційні дані, вбудовані під час створення цієї браузерної збірки.", + artifactDetails: "Відомості про збірку Control UI", + version: "Версія", + commit: "Коміт", + built: "Зібрано", + unavailable: "Недоступно", + copyCommit: "Копіювати повний хеш коміту", + copyingCommit: "Копіювання хешу коміту", + copiedCommit: "Хеш коміту скопійовано", + copyCommitFailed: "Не вдалося скопіювати хеш коміту", + gatewayVersion: "Версія підключеного Gateway", + gatewayVersionHint: "Надано активним підключенням Gateway; окремо від цієї збірки Control UI.", + }, profilePage: { offline: "Підключіться до gateway, щоб познайомитися зі своїм агентом.", loading: "Занурюємося по статистику…", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 5cacbf35ba8..8dd19309fae 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -547,6 +547,7 @@ export const vi: TranslationMap = { automation: "Tự động hóa", mcp: "MCP", infrastructure: "Hạ tầng", + about: "Giới thiệu", aiAgents: "AI & Agent", debug: "Gỡ lỗi", logs: "Nhật ký", @@ -577,12 +578,29 @@ export const vi: TranslationMap = { automation: "Lệnh, hook, cron và plugin.", mcp: "Máy chủ MCP, xác thực, công cụ và chẩn đoán.", infrastructure: "Cài đặt Gateway, web, trình duyệt và phương tiện.", + about: "Thông tin nhận dạng bản dựng của Control UI và Gateway đã kết nối.", aiAgents: "Agent, mô hình, skills, công cụ, bộ nhớ, phiên.", debug: "Ảnh chụp, sự kiện, RPC.", logs: "Nhật ký gateway trực tiếp.", dreams: "Mơ bộ nhớ, hợp nhất và phản chiếu.", plugin: "Bảng điều khiển do plugin cung cấp.", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "Thông tin nhận dạng được nhúng khi tạo bản dựng cho trình duyệt này.", + artifactDetails: "Chi tiết bản dựng Control UI", + version: "Phiên bản", + commit: "Mã commit", + built: "Ngày dựng", + unavailable: "Không có", + copyCommit: "Sao chép đầy đủ mã băm commit", + copyingCommit: "Đang sao chép mã băm commit", + copiedCommit: "Đã sao chép mã băm commit", + copyCommitFailed: "Không thể sao chép mã băm commit", + gatewayVersion: "Phiên bản Gateway đã kết nối", + gatewayVersionHint: + "Được báo cáo bởi kết nối Gateway đang hoạt động; tách biệt với bản dựng Control UI này.", + }, profilePage: { offline: "Kết nối với gateway để gặp agent của bạn.", loading: "Đang lặn tìm thống kê…", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 389c7817452..5dc89400a70 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -544,6 +544,7 @@ export const zh_CN: TranslationMap = { automation: "自动化", mcp: "MCP", infrastructure: "基础设施", + about: "关于", aiAgents: "AI 与代理", debug: "调试", logs: "日志", @@ -573,12 +574,28 @@ export const zh_CN: TranslationMap = { automation: "命令、钩子、定时任务和插件设置。", mcp: "MCP 服务器、身份验证、工具和诊断。", infrastructure: "网关、Web、浏览器和媒体设置。", + about: "Control UI 和已连接 Gateway 的构建标识。", aiAgents: "代理、模型、技能、工具、记忆和会话设置。", debug: "快照、事件、RPC。", logs: "实时网关日志。", dreams: "睡眠时进行记忆巩固。", plugin: "插件提供的面板。", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "构建此浏览器产物时嵌入的标识信息。", + artifactDetails: "Control UI 构建详情", + version: "版本", + commit: "提交", + built: "构建时间", + unavailable: "不可用", + copyCommit: "复制完整提交哈希值", + copyingCommit: "正在复制提交哈希值", + copiedCommit: "已复制提交哈希值", + copyCommitFailed: "无法复制提交哈希值", + gatewayVersion: "已连接 Gateway 的版本", + gatewayVersionHint: "由当前 Gateway 连接报告;此版本信息独立于这个 Control UI 构建。", + }, profilePage: { offline: "连接到 Gateway 以见到你的智能体。", loading: "正在潜入统计数据…", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 321cab95383..5bf0f0fc8e7 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -544,6 +544,7 @@ export const zh_TW: TranslationMap = { automation: "自動化", mcp: "MCP", infrastructure: "基礎設施", + about: "關於", aiAgents: "AI 與代理", debug: "調試", logs: "日誌", @@ -573,12 +574,28 @@ export const zh_TW: TranslationMap = { automation: "命令、鉤子、定時任務和插件設置。", mcp: "MCP 伺服器、驗證、工具與診斷。", infrastructure: "網關、Web、瀏覽器和媒體設置。", + about: "Control UI 與已連線 Gateway 的建置識別資訊。", aiAgents: "代理、模型、技能、工具、記憶和會話設置。", debug: "快照、事件、RPC。", logs: "實時網關日誌。", dreams: "睡眠期間的記憶整合。", plugin: "外掛程式提供的面板。", }, + aboutPage: { + artifactTitle: "Control UI", + artifactSubtitle: "建置此瀏覽器產物時嵌入的識別資訊。", + artifactDetails: "Control UI 建置詳細資料", + version: "版本", + commit: "提交", + built: "建置時間", + unavailable: "無法取得", + copyCommit: "複製完整提交雜湊值", + copyingCommit: "正在複製提交雜湊值", + copiedCommit: "已複製提交雜湊值", + copyCommitFailed: "無法複製提交雜湊值", + gatewayVersion: "已連線 Gateway 版本", + gatewayVersionHint: "由目前的 Gateway 連線回報;此版本資訊獨立於這個 Control UI 建置。", + }, profilePage: { offline: "連線至 Gateway 以認識你的代理程式。", loading: "正在潛入查看統計資料…", diff --git a/ui/src/main.ts b/ui/src/main.ts index af6ca91c203..2fbd5412d94 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -2,6 +2,7 @@ import "./styles.css"; import "./app/app-host.ts"; import { inferControlUiPublicAssetPath } from "./app/public-assets.ts"; +import { CONTROL_UI_BUILD_INFO } from "./build-info.ts"; type ViteImportMeta = ImportMeta & { readonly env?: { @@ -9,10 +10,8 @@ type ViteImportMeta = ImportMeta & { }; }; -declare const OPENCLAW_CONTROL_UI_BUILD_ID: string | undefined; - const isProd = (import.meta as ViteImportMeta).env?.PROD === true; -const currentControlUiBuildId = OPENCLAW_CONTROL_UI_BUILD_ID || "dev"; +const currentControlUiBuildId = CONTROL_UI_BUILD_INFO.buildId; syncDocumentPublicAssetLinks(); diff --git a/ui/src/pages/about/about-page.ts b/ui/src/pages/about/about-page.ts new file mode 100644 index 00000000000..119122b9390 --- /dev/null +++ b/ui/src/pages/about/about-page.ts @@ -0,0 +1,79 @@ +import { consume } from "@lit/context"; +import { html } from "lit"; +import { state } from "lit/decorators.js"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { CONTROL_UI_BUILD_INFO } from "../../build-info.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { copyToClipboard } from "../../lib/clipboard.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { renderAbout, type AboutCommitCopyState } from "./view.ts"; + +const COPY_RESULT_VISIBLE_MS = 1800; + +class AboutPage extends OpenClawLightDomElement { + @consume({ context: applicationContext, subscribe: true }) + private context!: ApplicationContext; + + @state() private copyState: AboutCommitCopyState = "idle"; + + private copyResetTimer: ReturnType | null = null; + private readonly subscriptions = new SubscriptionsController(this).watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + ); + + override disconnectedCallback() { + this.subscriptions.clear(); + if (this.copyResetTimer !== null) { + globalThis.clearTimeout(this.copyResetTimer); + this.copyResetTimer = null; + } + super.disconnectedCallback(); + } + + private async copyCommit() { + const commit = CONTROL_UI_BUILD_INFO.commit; + if (!commit || this.copyState === "copying") { + return; + } + this.copyState = "copying"; + const copied = await copyToClipboard(commit); + if (!this.isConnected) { + return; + } + this.copyState = copied ? "copied" : "error"; + if (this.copyResetTimer !== null) { + globalThis.clearTimeout(this.copyResetTimer); + } + this.copyResetTimer = globalThis.setTimeout(() => { + this.copyResetTimer = null; + this.copyState = "idle"; + }, COPY_RESULT_VISIBLE_MS); + } + + override render() { + const gatewaySnapshot = this.context.gateway.snapshot; + const gatewayVersion = gatewaySnapshot.connected + ? gatewaySnapshot.hello?.server?.version?.trim() || null + : null; + const body = renderAbout({ + buildInfo: CONTROL_UI_BUILD_INFO, + gatewayVersion, + copyState: this.copyState, + onCopyCommit: () => void this.copyCommit(), + }); + return html` +
+
+
${titleForRoute("about")}
+
${subtitleForRoute("about")}
+
+
+ ${renderSettingsWorkspace(body)} + `; + } +} + +customElements.define("openclaw-about-page", AboutPage); diff --git a/ui/src/pages/about/route.ts b/ui/src/pages/about/route.ts new file mode 100644 index 00000000000..e47f5e6416f --- /dev/null +++ b/ui/src/pages/about/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "about", + path: "/settings/about", + component: () => + import("./about-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/pages/about/view.browser.test.ts b/ui/src/pages/about/view.browser.test.ts new file mode 100644 index 00000000000..9d0e19279aa --- /dev/null +++ b/ui/src/pages/about/view.browser.test.ts @@ -0,0 +1,48 @@ +// Control UI browser tests cover About layout behavior. +import { nothing, render } from "lit"; +import { describe, expect, it } from "vitest"; +import "../../styles.css"; +import { renderAbout } from "./view.ts"; + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; + +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +describe("About responsive browser layout", () => { + it("keeps the desktop build identity in one row without horizontal overflow", async () => { + const container = document.createElement("div"); + container.style.width = "760px"; + container.style.setProperty("--control-ui-text-scale", "2"); + document.body.append(container); + try { + render( + renderAbout({ + buildInfo: { + version: "2026.7.10", + commit: COMMIT, + builtAt: "2026-07-10T12:34:56.000Z", + buildId: "test", + }, + gatewayVersion: "2026.7.9", + copyState: "idle", + onCopyCommit: () => undefined, + }), + container, + ); + await nextFrame(); + + const strip = container.querySelector(".about-build-strip"); + const items = Array.from(container.querySelectorAll(".about-build-strip__item")); + expect(strip).not.toBeNull(); + expect(new Set(items.map((item) => item.offsetTop)).size).toBe(1); + expect((strip?.scrollWidth ?? 1) - (strip?.clientWidth ?? 0)).toBeLessThanOrEqual(1); + } finally { + render(nothing, container); + container.remove(); + } + }); +}); diff --git a/ui/src/pages/about/view.test.ts b/ui/src/pages/about/view.test.ts new file mode 100644 index 00000000000..bfb80a9a9c4 --- /dev/null +++ b/ui/src/pages/about/view.test.ts @@ -0,0 +1,94 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../../i18n/index.ts"; +import { formatControlUiBuildDate, renderAbout, type AboutProps } from "./view.ts"; + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const BUILT_AT = "2026-07-10T12:34:56.000Z"; + +function createProps(overrides: Partial = {}): AboutProps { + return { + buildInfo: { + version: "2026.7.10", + commit: COMMIT, + builtAt: BUILT_AT, + buildId: "test", + }, + gatewayVersion: "2026.7.9", + copyState: "idle", + onCopyCommit: vi.fn(), + ...overrides, + }; +} + +describe("renderAbout", () => { + beforeEach(async () => { + document.body.innerHTML = ""; + await i18n.setLocale("en"); + }); + + it("keeps version, short commit, and localized UTC build date in one definition strip", () => { + const container = document.createElement("div"); + render(renderAbout(createProps()), container); + + const strip = container.querySelector(".about-build-strip"); + const items = strip?.querySelectorAll(":scope > div"); + expect(strip?.getAttribute("role")).toBe("group"); + expect(strip?.getAttribute("aria-label")).toBe("Control UI build details"); + expect(items).toHaveLength(3); + expect(items?.[0]?.textContent).toContain("2026.7.10"); + expect(items?.[1]?.querySelector("code")?.textContent).toBe(COMMIT.slice(0, 12)); + expect(items?.[1]?.querySelector("code")?.getAttribute("title")).toBe(COMMIT); + expect(items?.[1]?.querySelector("code")?.getAttribute("dir")).toBe("ltr"); + + const time = items?.[2]?.querySelector("time"); + expect(time?.getAttribute("datetime")).toBe(BUILT_AT); + expect(time?.getAttribute("title")).toBe(BUILT_AT); + expect(time?.getAttribute("dir")).toBe("auto"); + expect(time?.textContent).toBe(formatControlUiBuildDate(BUILT_AT, "en")); + }); + + it("keeps the connected Gateway version separate from the browser artifact", () => { + const container = document.createElement("div"); + render(renderAbout(createProps()), container); + + expect(container.querySelector(".about-build-strip")?.textContent).not.toContain("2026.7.9"); + expect(container.querySelector(".about-gateway-row")?.textContent).toContain("2026.7.9"); + expect(container.querySelector(".about-gateway-row")?.textContent).toContain( + "separate from this Control UI build", + ); + }); + + it("copies the full commit while announcing success accessibly", () => { + const onCopyCommit = vi.fn(); + const container = document.createElement("div"); + render(renderAbout(createProps({ copyState: "copied", onCopyCommit })), container); + + const button = container.querySelector(".about-build-strip__copy"); + expect(button?.getAttribute("aria-label")).toBe("Commit hash copied"); + expect(container.querySelector("[role='status']")?.textContent?.trim()).toBe( + "Commit hash copied", + ); + button?.click(); + expect(onCopyCommit).toHaveBeenCalledOnce(); + }); + + it("states when artifact identity and Gateway version are unavailable", () => { + const container = document.createElement("div"); + render( + renderAbout( + createProps({ + buildInfo: { version: null, commit: null, builtAt: null, buildId: "dev" }, + gatewayVersion: null, + }), + ), + container, + ); + + expect(container.querySelectorAll(".about-build-strip__unavailable")).toHaveLength(4); + expect(container.querySelector(".about-build-strip__copy")).toBeNull(); + expect(container.textContent).not.toContain("Unknown build"); + }); +}); diff --git a/ui/src/pages/about/view.ts b/ui/src/pages/about/view.ts new file mode 100644 index 00000000000..0c5bda3db44 --- /dev/null +++ b/ui/src/pages/about/view.ts @@ -0,0 +1,152 @@ +import { html, nothing } from "lit"; +import type { ControlUiBuildInfo } from "../../build-info.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; +import { i18n, t } from "../../i18n/index.ts"; + +export type AboutCommitCopyState = "idle" | "copying" | "copied" | "error"; + +export type AboutProps = { + buildInfo: ControlUiBuildInfo; + gatewayVersion: string | null; + copyState: AboutCommitCopyState; + onCopyCommit: () => void; +}; + +const SHORT_COMMIT_LENGTH = 12; + +export function formatControlUiBuildDate( + value: string | null, + locales?: Intl.LocalesArgument, +): string | null { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return new Intl.DateTimeFormat(locales, { + dateStyle: "medium", + timeZone: "UTC", + }).format(date); +} + +function copyButtonLabel(state: AboutCommitCopyState): string { + if (state === "copying") { + return t("aboutPage.copyingCommit"); + } + if (state === "copied") { + return t("aboutPage.copiedCommit"); + } + if (state === "error") { + return t("aboutPage.copyCommitFailed"); + } + return t("aboutPage.copyCommit"); +} + +function copyStatus(state: AboutCommitCopyState): string { + return state === "copied" + ? t("aboutPage.copiedCommit") + : state === "error" + ? t("aboutPage.copyCommitFailed") + : ""; +} + +function renderCommit(props: AboutProps) { + const commit = props.buildInfo.commit; + if (!commit) { + return html`${t("aboutPage.unavailable")}`; + } + const label = copyButtonLabel(props.copyState); + return html` + + ${commit.slice(0, SHORT_COMMIT_LENGTH)} + + + + ${copyStatus(props.copyState)} + + `; +} + +export function renderAbout(props: AboutProps) { + const buildDate = formatControlUiBuildDate(props.buildInfo.builtAt, i18n.getLocale()); + return html` +
+
+
+

${t("aboutPage.artifactTitle")}

+

${t("aboutPage.artifactSubtitle")}

+
+ +
+
+
${t("aboutPage.version")}
+
+ ${props.buildInfo.version + ? html`${props.buildInfo.version}` + : html`${t("aboutPage.unavailable")}`} +
+
+
+
${t("aboutPage.commit")}
+
${renderCommit(props)}
+
+
+
${t("aboutPage.built")}
+
+ ${buildDate && props.buildInfo.builtAt + ? html`` + : html`${t("aboutPage.unavailable")}`} +
+
+
+ +
+
+
+
${t("aboutPage.gatewayVersion")}
+
+ ${props.gatewayVersion + ? html`${props.gatewayVersion}` + : html`${t("aboutPage.unavailable")}`} +
+
+
+

${t("aboutPage.gatewayVersionHint")}

+
+
+
+ `; +} diff --git a/ui/src/styles.css b/ui/src/styles.css index 5e2dd23e45e..beb7d173024 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3,6 +3,7 @@ @import "./styles/layout.mobile.css"; @import "./styles/components.css"; @import "./styles/activity.css"; +@import "./styles/about.css"; @import "./styles/chat.css"; @import "./styles/config.css"; @import "./styles/config-quick.css"; diff --git a/ui/src/styles/about.css b/ui/src/styles/about.css new file mode 100644 index 00000000000..42370912e29 --- /dev/null +++ b/ui/src/styles/about.css @@ -0,0 +1,195 @@ +/* Settings > System > About */ + +.about-page { + container-type: inline-size; + width: 100%; + max-width: 880px; + margin-inline: auto; +} + +.about-card { + padding: 0; + overflow: hidden; +} + +.about-card__intro { + padding: 20px 22px; +} + +.about-card__intro .card-sub { + margin-bottom: 0; +} + +.about-build-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; + border-block: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-elevated) 48%, var(--card) 52%); +} + +.about-build-strip__item { + min-width: 0; + padding: 16px 18px; +} + +.about-build-strip__item + .about-build-strip__item { + border-inline-start: 1px solid var(--border); +} + +.about-build-strip dt, +.about-gateway-row dt { + color: var(--muted); + font-size: var(--control-ui-text-xs); + font-weight: 650; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.about-build-strip dd, +.about-gateway-row dd { + min-width: 0; + margin: 7px 0 0; + color: var(--text-strong); + font-size: var(--control-ui-text-sm); + font-variant-numeric: tabular-nums; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.about-build-strip code, +.about-gateway-row code, +.about-build-strip time { + font: inherit; +} + +.about-build-strip__commit { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.about-build-strip__commit code { + min-width: 0; + overflow-wrap: anywhere; + user-select: all; +} + +.about-build-strip__copy { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 30px; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); +} + +.about-build-strip__copy:hover { + border-color: var(--border); + background: var(--bg-hover); + color: var(--text); +} + +.about-build-strip__copy:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.about-build-strip__copy--copied { + color: var(--ok); +} + +.about-build-strip__copy--error { + color: var(--danger); +} + +.about-build-strip__copy span, +.about-build-strip__copy svg { + width: 15px; + height: 15px; +} + +.about-build-strip__copy svg { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.about-build-strip__unavailable { + color: var(--muted); +} + +.about-gateway-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0; + padding: 16px 22px 18px; +} + +.about-gateway-row dl { + flex: 0 0 auto; + min-width: 0; + margin: 0; +} + +.about-gateway-row p { + max-width: 480px; + margin: 0; + color: var(--muted); + font-size: var(--control-ui-text-sm); + line-height: 1.45; + text-align: end; +} + +.about-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@container (max-width: 560px) { + .about-card__intro { + padding: 18px 16px; + } + + .about-build-strip { + grid-template-columns: minmax(0, 1fr); + } + + .about-build-strip__item { + padding: 14px 16px; + } + + .about-build-strip__item + .about-build-strip__item { + border-block-start: 1px solid var(--border); + border-inline-start: 0; + } + + .about-gateway-row { + align-items: flex-start; + flex-direction: column; + gap: 10px; + padding-inline: 16px; + } + + .about-gateway-row p { + text-align: start; + } +} diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index e315f0bce7a..a55eab6cf73 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -144,7 +144,12 @@ export async function startControlUiE2eServer(): Promise { clearScreen: false, configFile: false, define: { - OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify("e2e"), + "globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify({ + version: "2026.7.10", + commit: "0123456789abcdef0123456789abcdef01234567", + builtAt: "2026-07-10T12:34:56.000Z", + buildId: "e2e", + }), }, logLevel: "error", optimizeDeps: { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index cfb8f6c4c79..8c7bada7e04 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -6,6 +6,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Plugin, UserConfig } from "vite"; import { controlUiManualChunk } from "./config/control-ui-chunking.ts"; +import { + deriveControlUiBuildId, + normalizeControlUiBuildId, + normalizeControlUiBuildTimestamp, + normalizeControlUiCommit, + type ControlUiBuildInfo, +} from "./src/build-info.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); @@ -48,26 +55,21 @@ function normalizeBase(input: string): string { return `${trimmed}/`; } -function normalizeBuildId(input: string): string { - const normalized = input.trim().replace(/[^a-zA-Z0-9._-]+/g, "-"); - return normalized.slice(0, 96) || "dev"; -} - -function readPackageVersion(): string { +function readPackageVersion(): string | null { try { const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"); const parsed = JSON.parse(raw) as { version?: unknown }; return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() - : "dev"; + : null; } catch { - return "dev"; + return null; } } -function readGitShortSha(): string | null { +function readGitCommit(): string | null { try { - const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "--short=12", "HEAD"], { + const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); @@ -77,15 +79,66 @@ function readGitShortSha(): string | null { } } -function resolveControlUiBuildId(): string { - const explicit = - process.env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim() || process.env.OPENCLAW_VERSION?.trim(); +type ControlUiBuildInfoSources = { + env?: NodeJS.ProcessEnv; + now?: () => Date; + readPackageVersion?: () => string | null; + readGitCommit?: () => string | null; +}; + +function normalizeBuildTimestamp(value: string | undefined, now: () => Date): string | null { + const explicit = value?.trim(); if (explicit) { - return normalizeBuildId(explicit); + const timestamp = normalizeControlUiBuildTimestamp(explicit); + if (!timestamp) { + throw new Error( + "OPENCLAW_BUILD_TIMESTAMP must be a valid UTC ISO-8601 timestamp ending in Z", + ); + } + return timestamp; } - const version = readPackageVersion(); - const gitSha = readGitShortSha(); - return normalizeBuildId(gitSha ? `${version}-${gitSha}` : version); + const candidate = now(); + return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString(); +} + +export function resolveControlUiBuildInfo( + sources: ControlUiBuildInfoSources = {}, +): ControlUiBuildInfo { + const env = sources.env ?? process.env; + const version = (sources.readPackageVersion ?? readPackageVersion)(); + const explicitCommitSource = [ + { name: "GIT_COMMIT", value: env.GIT_COMMIT?.trim() }, + { name: "GIT_SHA", value: env.GIT_SHA?.trim() }, + ].find((source) => source.value); + const explicitCommit = explicitCommitSource?.value; + const envCommit = explicitCommit ? normalizeControlUiCommit(explicitCommit) : null; + if (explicitCommitSource && !envCommit) { + throw new Error(`${explicitCommitSource.name} must be a full 40-character hexadecimal SHA`); + } + const gitCommit = explicitCommit ? null : (sources.readGitCommit ?? readGitCommit)(); + const normalizedGitCommit = normalizeControlUiCommit(gitCommit); + if (gitCommit?.trim() && !normalizedGitCommit) { + throw new Error("git rev-parse HEAD must return a full 40-character hexadecimal SHA"); + } + // GITHUB_SHA names the workflow invocation and can differ from a checked-out tag. + const githubCommit = explicitCommit || gitCommit?.trim() ? null : env.GITHUB_SHA?.trim(); + const normalizedGithubCommit = normalizeControlUiCommit(githubCommit); + if (githubCommit && !normalizedGithubCommit) { + throw new Error("GITHUB_SHA must be a full 40-character hexadecimal SHA"); + } + const commit = envCommit ?? normalizedGitCommit ?? normalizedGithubCommit; + const builtAt = normalizeBuildTimestamp( + env.OPENCLAW_BUILD_TIMESTAMP, + sources.now ?? (() => new Date()), + ); + const metadata = { version, commit, builtAt }; + const explicitBuildId = env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim(); + return { + ...metadata, + buildId: explicitBuildId + ? normalizeControlUiBuildId(explicitBuildId) + : deriveControlUiBuildId(metadata), + }; } function escapeRegExp(input: string): string { @@ -251,11 +304,11 @@ export default function controlUiViteConfig(): UserConfig { const base = envBase ? normalizeBase(envBase) : "./"; const bootstrapConfigPath = base === "./" ? "/control-ui-config.json" : `${base}control-ui-config.json`; - const controlUiBuildId = resolveControlUiBuildId(); + const buildInfo = resolveControlUiBuildInfo(); return { base, define: { - OPENCLAW_CONTROL_UI_BUILD_ID: JSON.stringify(controlUiBuildId), + "globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify(buildInfo), }, publicDir: path.resolve(here, "public"), optimizeDeps: { @@ -293,7 +346,7 @@ export default function controlUiViteConfig(): UserConfig { }, plugins: [ controlUiBrowserOnlySharedModuleAliases(), - controlUiServiceWorkerBuildIdPlugin(controlUiBuildId), + controlUiServiceWorkerBuildIdPlugin(buildInfo.buildId), { name: "control-ui-dev-stubs", configureServer(server) {