diff --git a/.cursor/skills/ode-synk-bundle-publish/SKILL.md b/.cursor/skills/ode-synk-bundle-publish/SKILL.md index 2b4e1ddf9..6d6f1c1b9 100644 --- a/.cursor/skills/ode-synk-bundle-publish/SKILL.md +++ b/.cursor/skills/ode-synk-bundle-publish/SKILL.md @@ -21,6 +21,31 @@ description: >- 3. Store **API URLs and tokens** in CI secrets; never embed production credentials in source. 4. For bundle structure reminders, see **[CONTEXT_BUNDLE_AND_CI.md](https://github.com/OpenDataEnsemble/custom_app/blob/main/CONTEXT_BUNDLE_AND_CI.md)** in **custom_app** (summary only). +## CI / GitHub Actions (recommended pattern) + +Do **not** build synk from source in app repos. Pin an ODE release: + +```yaml +env: + SYNK_CLI_VERSION: v1.1.2 # bump intentionally + +- name: Install synk + run: | + curl -fsSL "https://github.com/OpenDataEnsemble/ode/releases/download/${SYNK_CLI_VERSION}/synkronus-cli-linux-amd64.tar.gz" \ + | tar -xz -C /tmp + install -m 0755 /tmp/synkronus-cli-linux-amd64 /usr/local/bin/synk +``` + +Deploy steps: + +```bash +synk --config "$CONFIG_FILE" login -u "$USER" --password "$PASS" +synk --config "$CONFIG_FILE" app-bundle upload bundle-v1.0.0.zip +synk --config "$CONFIG_FILE" app-bundle switch "$VERSION" +``` + +Alternative: `ghcr.io/opendataensemble/synkronus-cli:vX.Y.Z` for Docker-based local tooling. + ## Related - [App bundles (using)](https://opendataensemble.org/docs/using/app-bundles) diff --git a/.github/CICD.md b/.github/CICD.md index dc45ad62f..fa0b39673 100644 --- a/.github/CICD.md +++ b/.github/CICD.md @@ -73,6 +73,32 @@ The workflow requires these permissions: - `GITHUB_TOKEN` - Automatically provided by GitHub Actions +### Synkronus CLI Docker Build & Publish + +**Workflow File**: `.github/workflows/synkronus-cli-docker.yml` + +#### Triggers + +- **Push to `main` / `dev`** when `synkronus-cli/**` or `Dockerfile.cli.slim` changes +- **Pull Requests** — build only, no publish +- **Release published** — versioned tags +- **Manual dispatch** + +#### Image Registry + +- Image: `ghcr.io/opendataensemble/synkronus-cli` +- Dockerfile: `Dockerfile.cli.slim` (pre-built static `synk` binary, Alpine 3.23) + +Tagging follows the same strategy as the Synkronus server image (`latest`, `dev`, `main`, `v{X.Y.Z}`, `sha-{short}`, etc.). + +#### CLI binaries (primary install path) + +**Workflow File**: `.github/workflows/synkronus-cli.yml` + +Release assets: `synkronus-cli-{os}-{arch}.tar.gz` attached to GitHub Releases. Custom app CI should pin a release tag via `SYNK_CLI_VERSION` and download `synkronus-cli-linux-amd64.tar.gz`. + +Non-interactive CI login: `synk login -u USER --password "$SYNK_PASSWORD"` (v1.1.2+). + ### Formulus Android Build **Workflow File**: `.github/workflows/formulus-android.yml` diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index e1cf54db0..6c55c71de 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Label PR based on changed files - uses: actions/labeler@v5 + uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 with: repo-token: ${{ secrets.GITHUB_TOKEN }} configuration-path: .github/labeler.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca05a838f..2bc3ac474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,12 +37,12 @@ jobs: synkronus-cli: ${{ steps.filter.outputs.synkronus-cli }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Detect changed components - uses: dorny/paths-filter@v2 + uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2 id: filter with: filters: | @@ -68,15 +68,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -111,15 +111,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -160,10 +160,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: synkronus/go.sum @@ -212,10 +212,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: synkronus-cli/go.sum diff --git a/.github/workflows/e2e-attachments.yml b/.github/workflows/e2e-attachments.yml index d9a40c939..0ad9d62fe 100644 --- a/.github/workflows/e2e-attachments.yml +++ b/.github/workflows/e2e-attachments.yml @@ -32,10 +32,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: synkronus/go.mod cache: true @@ -73,15 +73,15 @@ jobs: timeout-minutes: 20 needs: contract steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 diff --git a/.github/workflows/formulus-android.yml b/.github/workflows/formulus-android.yml index 5c020c844..0a8024c9c 100644 --- a/.github/workflows/formulus-android.yml +++ b/.github/workflows/formulus-android.yml @@ -39,15 +39,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js for assets - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -68,7 +68,7 @@ jobs: run: pnpm run build:copy - name: Upload formplayer assets artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: formplayer-assets path: formulus/android/app/src/main/assets/formplayer_dist @@ -83,21 +83,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download formplayer assets artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: formplayer-assets path: formulus/android/app/src/main/assets/formplayer_dist - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -132,7 +132,7 @@ jobs: run: pnpm run generate - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: 'temurin' java-version: '17' @@ -182,7 +182,7 @@ jobs: run: ./gradlew bundleRelease --no-daemon - name: Upload APK artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: formulus-android-apk-${{ github.event_name }}-${{ github.run_id }} path: | @@ -190,7 +190,7 @@ jobs: - name: Upload AAB artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: formulus-android-aab-${{ github.event_name }}-${{ github.run_id }} path: | @@ -198,7 +198,7 @@ jobs: - name: Upload APK to GitHub Release if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: | formulus/android/app/build/outputs/apk/**/**/*.apk diff --git a/.github/workflows/ode-desktop.yml b/.github/workflows/ode-desktop.yml index 3fbfa2bea..e768b4678 100644 --- a/.github/workflows/ode-desktop.yml +++ b/.github/workflows/ode-desktop.yml @@ -45,15 +45,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -97,7 +97,7 @@ jobs: xdg-utils - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: rustfmt, clippy @@ -117,15 +117,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -153,7 +153,7 @@ jobs: cp -a formulus-formplayer/build/. desktop/public/formplayer_dist/ - name: Upload formplayer dist - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-formplayer-dist path: desktop/public/formplayer_dist @@ -194,21 +194,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download embedded formplayer - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: desktop-formplayer-dist path: desktop/public/formplayer_dist - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -234,12 +234,12 @@ jobs: xdg-utils - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: ${{ matrix.rust_target }} - name: Rust cache - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: desktop/src-tauri shared-key: ode-desktop-${{ matrix.platform }} @@ -249,20 +249,28 @@ jobs: env: CI: 'true' RUST_TARGET: ${{ matrix.rust_target }} + CARGO_NET_RETRY: '10' + CARGO_HTTP_MULTIPLEXING: 'false' run: | set -euo pipefail if [[ "${RUNNER_OS}" == "macOS" ]]; then hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true fi - if ! pnpm exec tauri build --verbose --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}'; then - if [[ "${RUNNER_OS}" != "macOS" ]]; then - exit 1 + build_tauri() { + pnpm exec tauri build --verbose --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}' "$@" + } + if ! build_tauri; then + if [[ "${RUNNER_OS}" == "macOS" ]]; then + echo "::warning::Tauri build failed; retrying DMG bundle on macOS" + hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true + find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true + build_tauri --bundles dmg + else + echo "::warning::Tauri build failed; retrying after crates.io network glitch" + sleep 15 + build_tauri fi - echo "::warning::Tauri build failed; retrying DMG bundle on macOS" - hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true - find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true - pnpm exec tauri build --verbose --bundles dmg --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}' fi - name: Collect installers for upload @@ -304,7 +312,7 @@ jobs: fi - name: Upload installer artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ode-desktop-${{ matrix.platform }} path: desktop/dist-ci/* @@ -347,21 +355,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download embedded formplayer - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: desktop-formplayer-dist path: desktop/public/formplayer_dist - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -387,12 +395,12 @@ jobs: xdg-utils - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: targets: ${{ matrix.rust_target }} - name: Rust cache - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: desktop/src-tauri shared-key: ode-desktop-release-${{ matrix.platform }} @@ -402,20 +410,28 @@ jobs: env: CI: 'true' RUST_TARGET: ${{ matrix.rust_target }} + CARGO_NET_RETRY: '10' + CARGO_HTTP_MULTIPLEXING: 'false' run: | set -euo pipefail if [[ "${RUNNER_OS}" == "macOS" ]]; then hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true fi - if ! pnpm exec tauri build --verbose --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}'; then - if [[ "${RUNNER_OS}" != "macOS" ]]; then - exit 1 + build_tauri() { + pnpm exec tauri build --verbose --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}' "$@" + } + if ! build_tauri; then + if [[ "${RUNNER_OS}" == "macOS" ]]; then + echo "::warning::Tauri build failed; retrying DMG bundle on macOS" + hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true + find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true + build_tauri --bundles dmg + else + echo "::warning::Tauri build failed; retrying after crates.io network glitch" + sleep 15 + build_tauri fi - echo "::warning::Tauri build failed; retrying DMG bundle on macOS" - hdiutil detach "/Volumes/ODE Desktop" 2>/dev/null || true - find "src-tauri/target/${RUST_TARGET}/release/bundle" -name 'rw.*.dmg' -delete 2>/dev/null || true - pnpm exec tauri build --verbose --bundles dmg --target "${RUST_TARGET}" -c '${{ env.TAURI_BEFORE_BUILD }}' fi - name: Collect installers for GitHub Release @@ -457,7 +473,7 @@ jobs: fi - name: Attach installers to Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ github.event.release.tag_name }} files: desktop/dist-release/* diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index b3bd30782..74bb2ffa9 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Check PR Title Format - uses: amannn/action-semantic-pull-request@v5 + uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml index ae9a66905..7ebc6566a 100644 --- a/.github/workflows/sbom-release.yml +++ b/.github/workflows/sbom-release.yml @@ -23,16 +23,16 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm # scripts/sbom/generate-sboms.mjs shells out to `pnpm list`; needs pnpm on PATH. - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm @@ -46,7 +46,7 @@ jobs: desktop/pnpm-lock.yaml - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} @@ -63,7 +63,7 @@ jobs: - name: Upload SBOM artifact (manual runs) if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: cyclonedx-sbom path: sbom-dist/*.cdx.json @@ -71,7 +71,7 @@ jobs: - name: Upload SBOMs to GitHub Release if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ github.event.release.tag_name }} files: sbom-dist/*.cdx.json diff --git a/.github/workflows/synkronus-cli-docker.yml b/.github/workflows/synkronus-cli-docker.yml new file mode 100644 index 000000000..bc783ec4e --- /dev/null +++ b/.github/workflows/synkronus-cli-docker.yml @@ -0,0 +1,290 @@ +name: Synkronus CLI Docker Build & Publish + +on: + push: + branches: + - main + - dev + paths: + - 'synkronus-cli/**' + - 'Dockerfile.cli.slim' + - '.github/workflows/synkronus-cli-docker.yml' + pull_request: + paths: + - 'synkronus-cli/**' + - 'Dockerfile.cli.slim' + - '.github/workflows/synkronus-cli-docker.yml' + workflow_dispatch: + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: opendataensemble/synkronus-cli + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build: + runs-on: ubuntu-latest + name: Cross-compile synkronus-cli + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine CLI version from git + id: version + run: | + if [ "${{ github.event_name }}" == "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "1.0.0") + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Building synkronus-cli with version: ${VERSION}" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + cache-dependency-path: synkronus-cli/go.sum + + - name: Build synkronus-cli binaries + working-directory: synkronus-cli + env: + CLI_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + ROOT="${GITHUB_WORKSPACE}" + LD_FLAGS="-w -s -X github.com/OpenDataEnsemble/ode/synkronus-cli/internal/cmd.Version=${CLI_VERSION#v}" + mkdir -p "${ROOT}/docker-dist" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LD_FLAGS}" -o "${ROOT}/docker-dist/synk-linux-amd64" ./cmd/synkronus + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LD_FLAGS}" -o "${ROOT}/docker-dist/synk-linux-arm64" ./cmd/synkronus + + - name: Upload docker build context artifact + uses: actions/upload-artifact@v4 + with: + name: synkronus-cli-docker-dist + path: docker-dist/ + if-no-files-found: error + + image-amd64: + needs: build + runs-on: ubuntu-latest + name: Build linux/amd64 image + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download docker build context + uses: actions/download-artifact@v4 + with: + name: synkronus-cli-docker-dist + path: docker-dist/ + + - name: Stage build context for Dockerfile.cli.slim + run: | + set -euo pipefail + mkdir -p docker-context + cp docker-dist/synk-linux-amd64 docker-context/synk + chmod +x docker-context/synk + + - name: Log in to Github Container Registry + if: github.event_name != 'pull_request' + uses: redhat-actions/podman-login@v1 + with: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ env.REGISTRY }} + + - name: Compute image metadata (scratch tag) + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=tmp-${{ github.run_id }}-amd64 + + - name: Build image with Buildah + id: build-image + uses: redhat-actions/buildah-build@v2 + with: + image: ${{ env.IMAGE_NAME }} + tags: ${{ steps.meta.outputs.tag-names }} + labels: ${{ steps.meta.outputs.labels }} + archs: amd64 + containerfiles: | + ./Dockerfile.cli.slim + context: docker-context + + - name: Push image to registry + id: push + if: github.event_name != 'pull_request' + uses: redhat-actions/push-to-registry@v2 + with: + image: ${{ steps.build-image.outputs.image }} + tags: ${{ steps.build-image.outputs.tags }} + registry: ${{ env.REGISTRY }} + + image-arm64: + needs: build + runs-on: ubuntu-24.04-arm + name: Build linux/arm64 image + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download docker build context + uses: actions/download-artifact@v4 + with: + name: synkronus-cli-docker-dist + path: docker-dist/ + + - name: Stage build context for Dockerfile.cli.slim + run: | + set -euo pipefail + mkdir -p docker-context + cp docker-dist/synk-linux-arm64 docker-context/synk + chmod +x docker-context/synk + + - name: Log in to Github Container Registry + if: github.event_name != 'pull_request' + uses: redhat-actions/podman-login@v1 + with: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ env.REGISTRY }} + + - name: Compute image metadata (scratch tag) + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=tmp-${{ github.run_id }}-arm64 + + - name: Build image with Buildah + id: build-image + uses: redhat-actions/buildah-build@v2 + with: + image: ${{ env.IMAGE_NAME }} + tags: ${{ steps.meta.outputs.tag-names }} + labels: ${{ steps.meta.outputs.labels }} + archs: arm64 + containerfiles: | + ./Dockerfile.cli.slim + context: docker-context + + - name: Push image to registry + id: push + if: github.event_name != 'pull_request' + uses: redhat-actions/push-to-registry@v2 + with: + image: ${{ steps.build-image.outputs.image }} + tags: ${{ steps.build-image.outputs.tags }} + registry: ${{ env.REGISTRY }} + + merge-manifests: + if: github.event_name != 'pull_request' + needs: [image-amd64, image-arm64] + runs-on: ubuntu-latest + name: Merge multi-arch manifest and publish tags + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - name: Log in to Github Container Registry (docker) + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags and labels + id: tags + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=semver,pattern=v{{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }},priority=1100 + type=semver,pattern=v{{major}}.{{minor}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=900 + type=semver,pattern=v{{major}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=800 + type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=700 + type=raw,value=latest-pre-release,enable=${{ github.event_name == 'release' && github.event.release.prerelease == true }},priority=700 + type=raw,value=main,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }},priority=1000 + type=raw,value=dev,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }},priority=1000 + type=ref,event=branch,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }},priority=900 + type=ref,event=pr,priority=1000 + type=sha,enable=${{ github.event_name != 'release' && github.event_name != 'pull_request' }},priority=100 + + - name: Create and push multi-arch manifest + id: merge + env: + AMD64_DIGEST: ${{ needs.image-amd64.outputs.digest }} + ARM64_DIGEST: ${{ needs.image-arm64.outputs.digest }} + TAG_NAMES: ${{ steps.tags.outputs.tag-names }} + run: | + set -euo pipefail + IMG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + AMD64_REF="${IMG}@${AMD64_DIGEST}" + ARM64_REF="${IMG}@${ARM64_DIGEST}" + mapfile -t TAGS < <(echo "${TAG_NAMES}" | sed '/^$/d') + if [ "${#TAGS[@]}" -eq 0 ]; then + echo "No tags from metadata-action" >&2 + exit 1 + fi + TAG_ARGS=() + for t in "${TAGS[@]}"; do + TAG_ARGS+=(-t "${IMG}:${t}") + done + docker buildx imagetools create "${TAG_ARGS[@]}" "${AMD64_REF}" "${ARM64_REF}" + + - name: Verify image + shell: bash + run: | + set -euo pipefail + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + PRIMARY_TAG="${{ steps.tags.outputs.version }}" + + docker manifest inspect "${IMAGE}:${PRIMARY_TAG}" | \ + jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | index("linux/amd64") and index("linux/arm64")' >/dev/null + echo "Verified manifest platforms for ${IMAGE}:${PRIMARY_TAG}" + + docker pull --platform linux/amd64 "${IMAGE}:${PRIMARY_TAG}" + docker pull --platform linux/arm64 "${IMAGE}:${PRIMARY_TAG}" + + - name: Capture manifest digest for attestation + id: manifest-digest + run: | + set -euo pipefail + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + PRIMARY_TAG="${{ steps.tags.outputs.version }}" + DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${PRIMARY_TAG}" --format '{{json .}}' | jq -r '.manifest.digest') + echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest-digest.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/synkronus-cli.yml b/.github/workflows/synkronus-cli.yml index 54acb0b6c..d02286a0f 100644 --- a/.github/workflows/synkronus-cli.yml +++ b/.github/workflows/synkronus-cli.yml @@ -31,10 +31,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.x' cache-dependency-path: synkronus-cli/go.sum @@ -58,7 +58,7 @@ jobs: rm dist/${FILENAME}${EXT} - name: Upload build artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: synkronus-cli-${{ matrix.goos }}-${{ matrix.goarch }} path: synkronus-cli/dist/synkronus-cli-${{ matrix.goos }}-${{ matrix.goarch }}* @@ -81,10 +81,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.x' cache-dependency-path: synkronus-cli/go.sum @@ -100,7 +100,7 @@ jobs: ls -R dist - name: Upload assets to GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: | synkronus-cli/dist/synkronus-cli-* diff --git a/.github/workflows/synkronus-docker.yml b/.github/workflows/synkronus-docker.yml index bb635d929..89709578e 100644 --- a/.github/workflows/synkronus-docker.yml +++ b/.github/workflows/synkronus-docker.yml @@ -41,7 +41,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 @@ -57,18 +57,18 @@ jobs: echo "Building Synkronus with version: ${VERSION}" - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.25.x' cache-dependency-path: synkronus/go.sum - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.33.2 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '24' cache: pnpm @@ -78,7 +78,7 @@ jobs: packages/components/pnpm-lock.yaml - name: Set up Java (OpenAPI Generator) - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: '21' @@ -109,7 +109,7 @@ jobs: cp -a static "${ROOT}/docker-dist/" - name: Upload docker build context artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: synkronus-docker-dist path: docker-dist/ @@ -126,10 +126,10 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download docker build context - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: synkronus-docker-dist path: docker-dist/ @@ -145,7 +145,7 @@ jobs: - name: Log in to Github Container Registry if: github.event_name != 'pull_request' - uses: redhat-actions/podman-login@v1 + uses: redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603 # v1 with: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -153,7 +153,7 @@ jobs: - name: Compute image metadata (scratch tag) id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} @@ -162,7 +162,7 @@ jobs: - name: Build image with Buildah id: build-image - uses: redhat-actions/buildah-build@v2 + uses: redhat-actions/buildah-build@7a95fa7ee0f02d552a32753e7414641a04307056 # v2 with: image: ${{ env.IMAGE_NAME }} tags: ${{ steps.meta.outputs.tag-names }} @@ -175,7 +175,7 @@ jobs: - name: Push image to registry id: push if: github.event_name != 'pull_request' - uses: redhat-actions/push-to-registry@v2 + uses: redhat-actions/push-to-registry@5ed88d269cf581ea9ef6dd6806d01562096bee9c # v2 with: image: ${{ steps.build-image.outputs.image }} tags: ${{ steps.build-image.outputs.tags }} @@ -192,10 +192,10 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download docker build context - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: synkronus-docker-dist path: docker-dist/ @@ -211,7 +211,7 @@ jobs: - name: Log in to Github Container Registry if: github.event_name != 'pull_request' - uses: redhat-actions/podman-login@v1 + uses: redhat-actions/podman-login@4934294ad0449894bcd1e9f191899d7292469603 # v1 with: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -219,7 +219,7 @@ jobs: - name: Compute image metadata (scratch tag) id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} @@ -228,7 +228,7 @@ jobs: - name: Build image with Buildah id: build-image - uses: redhat-actions/buildah-build@v2 + uses: redhat-actions/buildah-build@7a95fa7ee0f02d552a32753e7414641a04307056 # v2 with: image: ${{ env.IMAGE_NAME }} tags: ${{ steps.meta.outputs.tag-names }} @@ -241,7 +241,7 @@ jobs: - name: Push image to registry id: push if: github.event_name != 'pull_request' - uses: redhat-actions/push-to-registry@v2 + uses: redhat-actions/push-to-registry@5ed88d269cf581ea9ef6dd6806d01562096bee9c # v2 with: image: ${{ steps.build-image.outputs.image }} tags: ${{ steps.build-image.outputs.tags }} @@ -259,7 +259,7 @@ jobs: attestations: write steps: - name: Log in to Github Container Registry (docker) - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -267,7 +267,7 @@ jobs: - name: Compute image tags and labels id: tags - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} @@ -331,7 +331,7 @@ jobs: echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.manifest-digest.outputs.digest }} diff --git a/AGENTS.md b/AGENTS.md index 3a8faf404..a0f08d97a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,73 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa --- +## Release version bump checklist + +Use this when preparing a new ODE release (pre-release or stable). Full tagging and CI behaviour: [RELEASE.md](RELEASE.md). Android Play/F-Droid `versionCode` rules: [formulus/android/ANDROID_RELEASE.md](formulus/android/ANDROID_RELEASE.md). + +### Pre-release vs stable + +| Layer | Pre-release (e.g. `v1.1.1-alpha.3`) | Stable (e.g. `v1.1.1`) | +|-------|--------------------------------------|-------------------------| +| Client manifests (`package.json`, `versionName`, CLI, Desktop, Portal) | Target semver **without** suffix (`1.1.1`) | Same (`1.1.1`) | +| Git tag + GitHub release | `v1.1.1-alpha.3` (mark **pre-release**) | `v1.1.1` | +| Synkronus Docker / server `BuildVersion()` | From release tag via CI ldflags | From release tag | + +For stable, you usually **do not** re-bump client manifests if they already match the target version; bump Android `versionCode` only when shipping a new Play build. + +### What to edit + +| File | Field | Purpose | +|------|-------|---------| +| `formulus/package.json` | `version` | Source for `ODE_VERSION` / `x-ode-version` ([`formulus/src/version.ts`](formulus/src/version.ts)) | +| `formulus/android/app/build.gradle` | `versionCode`, `versionName` | Google Play; run `pnpm run sync:version` from `formulus/` after `package.json` bump for `versionName` | +| `formulus/ios/Formulus.xcodeproj/project.pbxproj` | `MARKETING_VERSION`, `CURRENT_PROJECT_VERSION` | iOS display + build number (align `CURRENT_PROJECT_VERSION` with Android `versionCode`) | +| `synkronus-cli/internal/cmd/version.go` | `Version` | `synk version` output | +| `synkronus-cli/versioninfo.json` | Windows file/product version | Windows binary metadata | +| `desktop/package.json`, `desktop/src-tauri/tauri.conf.json`, `desktop/src-tauri/Cargo.toml` | `version` | ODE Desktop app version (keep all three in sync) | +| `desktop/src/lib/synkConstants.ts` | `SYNKRONUS_CLIENT_VERSION` | Desktop `x-ode-version` header | +| `synkronus-portal/package.json` | `version` | Portal `x-ode-version` ([`synkronus-portal/src/version.ts`](synkronus-portal/src/version.ts)) | + +**Synkronus server** version is **not** edited in source for releases — CI injects it from the git tag ([`.github/workflows/synkronus-docker.yml`](.github/workflows/synkronus-docker.yml)). + +**Synkronus CLI** Docker image (`ghcr.io/opendataensemble/synkronus-cli`) is published by [`.github/workflows/synkronus-cli-docker.yml`](.github/workflows/synkronus-cli-docker.yml) on release; confirm GHCR tags alongside GitHub Release CLI binaries. + +### Increment rules + +- **Semver:** bump `MAJOR.MINOR.PATCH` in client manifests to match the release line (e.g. `1.1.1`). +- **Android `versionCode`:** must increase monotonically for Google Play (+10 per release is a common convention; +1 per shipped alpha build is also fine). +- **In-app version display:** Formulus About/Settings use native `versionName` + `versionCode` via [`AppVersionService`](formulus/src/services/AppVersionService.ts); Desktop About uses Tauri `getVersion()`. + +### Commands + +```bash +# After bumping formulus/package.json +cd formulus && pnpm run sync:version + +# Pre-flight on touched JS packages +cd formulus-formplayer && pnpm run lint && pnpm run format:check +cd formulus && pnpm run lint && pnpm run format:check +cd desktop && pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm test +cd synkronus-cli && go build ./cmd/synkronus && ./synk version # or synkronus-cli.exe on Windows +``` + +### Do not bump + +- `FORMULUS_INTERFACE_VERSION` in [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) — WebView bridge API contract, not app release version +- `formulus-formplayer/package.json` — embedded library semver +- OpenAPI document version comments in generated API clients +- `synkronus-cli/internal/config/config.go` default `api.version` — Synkronus **API contract** major version for compatibility checks, not CLI display version + +### Tag and publish + +```bash +git tag v1.1.1-alpha.3 # or v1.1.1 for stable +git push origin v1.1.1-alpha.3 +# GitHub → Releases → publish (pre-release checkbox for alpha/rc tags) +``` + +--- + ## Cross-cutting contracts - **Formulus ↔ WebView (custom apps + formplayer):** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) is the **source of truth** for the injected JavaScript API. Formplayer copies a synced TypeScript snapshot via `pnpm run sync-interface` in `formulus-formplayer` (see [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md)). @@ -75,7 +142,7 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa - **Pipelines:** [.github/CICD.md](.github/CICD.md). - **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). -- **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). +- **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). - **Commits/PRs:** Conventional Commits and PR expectations are documented in [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md) (project-wide convention). --- diff --git a/Dockerfile.cli.slim b/Dockerfile.cli.slim new file mode 100644 index 000000000..48a1c0fa1 --- /dev/null +++ b/Dockerfile.cli.slim @@ -0,0 +1,13 @@ +# CI/runtime-only image: expects a pre-built synk binary in the build context. +# Build context layout: ./synk (static binary) + +FROM alpine:3.23 + +RUN apk --no-cache add ca-certificates + +COPY synk /usr/local/bin/synk +RUN chmod +x /usr/local/bin/synk + +WORKDIR /workspace + +ENTRYPOINT ["/usr/local/bin/synk"] diff --git a/RELEASE.md b/RELEASE.md index fc7924a2a..562f993ca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -67,7 +67,7 @@ Avoid tags like `v.1.2.0` (extra dot after `v`), which are not semver-style and The ODE monorepo contains multiple artefacts that must stay in sync: - **Synkronus** backend Docker image -- **Synkronus CLI** (multi-platform binaries) +- **Synkronus CLI** (multi-platform binaries and GHCR image `ghcr.io/opendataensemble/synkronus-cli`) - **Formulus** React Native Android app (signed APK) All share the same **semantic version tag**: @@ -77,7 +77,7 @@ All share the same **semantic version tag**: These tags are used consistently by the GitHub Actions workflows to: -- Build and tag Docker images in GHCR +- Build and tag Docker images in GHCR (Synkronus server and CLI) - Attach CLI binaries to GitHub Releases - Attach signed Android APKs to GitHub Releases diff --git a/ROADMAP.md b/ROADMAP.md index a16e15568..b4fdcb946 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,8 @@ Same Synkronus API as everything else. Form behaviour should match **Formulus** Detailed requirements live in the **ODE Desktop** project plan ([`desktop/`](desktop/) in this repo). Implementation is modular, not phased. +**Backlog:** [Custom data-management apps](desktop/docs/CUSTOM_DATA_APPS.md) — optional Workbench mini-apps shipped in the bundle for choice-list admin, randomization, cleaning, etc. + --- ## Product map diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 7a19520a3..039a5e07f 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -4,6 +4,8 @@ Published docs: [ODE Desktop developer mode](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode) (local custom app iteration). +**Release version bumps:** see [../AGENTS.md#release-version-bump-checklist](../AGENTS.md#release-version-bump-checklist) (`package.json`, `tauri.conf.json`, `Cargo.toml`, `synkConstants.ts` — keep in sync). + --- ## Layout @@ -95,4 +97,14 @@ pnpm typecheck cd src-tauri && cargo test ``` +**Pre-flight before a PR** (from `desktop/`): + +```bash +pnpm run lint +pnpm run format +pnpm run format:check +pnpm test +pnpm typecheck +``` + Conventional Commits; see root [AGENTS.md](../AGENTS.md) and [.github/CICD.md](../.github/CICD.md). diff --git a/desktop/README.md b/desktop/README.md index 7625a7f19..47673495d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -93,7 +93,7 @@ User guide: [ODE Desktop developer mode](https://opendataensemble.org/docs/guide - **Bridge contract**: [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts) — source of truth for `formulusAPI` / postMessage. After changes, run **`sync-interface`** in `formulus-formplayer` and mirror behavior in the desktop WebView host. - **Dev mirror paths**: `bundles/dev-local/app/`, `bundles/dev-local/forms/` when developer mode is on; `bundles/active/` otherwise (see [AGENTS.md](AGENTS.md)). -- **Form preview host** (Workbench → Form preview): `public/formulus-injection.js` + iframe shim; parent handles `postMessage` in **`src/lib/formPreviewBridge.ts`** (explicit matrix per `FormulusInjectionScript` request `type`; device APIs including camera, audio, and video are stubbed in preview; observations + URIs use Tauri where applicable). Nested **sub-observation** flows (`openFormplayer` + `options.subObservationMode`) open a stacked Form preview iframe and resolve the parent promise with `FormCompletionResult` without persisting the child as a top-level observation. `skipFinalize` and `skipDraftSelection` from `openFormplayer` options are forwarded into nested `FormInitData` when supported. Child sessions validate their own schema on Done (`skipFinalize` only omits the Finalize page); see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators). +- **Form preview host** (Workbench → Form preview): `public/formulus-injection.js` + iframe shim; parent handles `postMessage` in **`src/lib/formPreviewBridge.ts`** (explicit matrix per `FormulusInjectionScript` request `type`; device APIs including camera, audio, and video are stubbed in preview; observations + URIs use Tauri where applicable). **`persistObservation`** writes headlessly to the workspace DB (no finalize dialog) — used by custom apps for orchestrated writes such as inclusion decisions. Nested **sub-observation** flows (`openFormplayer` + `options.subObservationMode`) open a stacked Form preview iframe and resolve the parent promise with `FormCompletionResult` without persisting the child as a top-level observation. `skipFinalize` and `skipDraftSelection` from `openFormplayer` options are forwarded into nested `FormInitData` when supported. Child sessions validate their own schema on Done (`skipFinalize` only omits the Finalize page); see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators). - **Bundle extensions**: merge rules for `forms/ext.json` and `forms/{form}/ext.json` follow Formulus `ExtensionService`; see `src/lib/bundleResolution.ts`. - **Embedded formplayer**: production build copied into `public/formplayer_dist/`; load in a WebView with the same **`FormInitData`** expectations as mobile (see `src/lib/formplayerHost.ts` for placeholder types). diff --git a/desktop/docs/CUSTOM_DATA_APPS.md b/desktop/docs/CUSTOM_DATA_APPS.md new file mode 100644 index 000000000..560c036df --- /dev/null +++ b/desktop/docs/CUSTOM_DATA_APPS.md @@ -0,0 +1,104 @@ +# Custom data-management apps (ODE Desktop backlog) + +**Status:** design only — not implemented. + +## Goal + +Load optional **custom data-management mini-apps** in the ODE Desktop Workbench alongside the Synkronus-downloaded **custom_app** bundle. These tools support study stewards and developers who need specialized UIs that are not part of the field collection app: + +- Choice-list CRUD stored as observations +- Randomization administration +- Data cleaning / QA workflows +- Export prep or one-off transforms + +Field enumerators continue to use the **custom_app** (e.g. GBMIS) in Formulus. Data-management apps are for **desktop custody** work against the same workspace SQLite database. + +## Relationship to existing Workbench surfaces + +| Surface | Audience | Data path | +| ---------------------------------------- | --------------------- | ----------------------------------------------- | +| **Custom app** (`/workbench/custom-app`) | Field workflow mirror | `bundles/active/app/` or dev mirror | +| **Form preview** | Form authors | `bundles/active/forms/` + formplayer | +| **Observations** (Data management) | Custodians | Workspace DB via Tauri | +| **Data apps** (proposed) | Study admins / devs | Bundle-shipped HTML + same bridge as custom app | + +## Proposed bundle layout + +``` +bundles/active/ + app/ # existing custom_app (field) + forms/ # existing forms + data_apps/ + manifest.json # registry + choice_admin/ + index.html + randomization/ + index.html +``` + +Example `manifest.json`: + +```json +{ + "dataApps": [ + { + "id": "choice_admin", + "title": "Choice list editor", + "entry": "data_apps/choice_admin/index.html", + "description": "Edit shared lookup observations" + } + ] +} +``` + +Synkronus bundle upload would include `data_apps/` when present; Desktop scans the active bundle on load. + +## Runtime architecture + +```mermaid +flowchart LR + BundleZip[App bundle zip] + BundleZip --> ActiveApp[bundles/active/app] + BundleZip --> DataApps[bundles/active/data_apps/manifest.json] + Workbench[ODE Desktop Workbench] + Workbench --> CustomAppEmbed + Workbench --> DataAppHost[DataAppHost iframe] + DataAppHost --> SameBridge[formulus bridge subset] + SameBridge --> TauriDB[workspace SQLite] +``` + +### Host components (to build) + +1. **`DataAppManifestLoader`** — read `bundles/active/data_apps/manifest.json` (and dev mirror equivalent when developer mode mirrors `data_apps/`). +2. **`DataAppHostPage`** — route `/workbench/data-apps/:id`; embed via reused `CustomAppEmbed` pattern with `indexRelativePath` pointing at manifest `entry`. +3. **Navigation** — sidebar or Bundles page section listing registered data apps for the active bundle. +4. **Bridge** — reuse [`formPreviewBridge.ts`](../src/lib/formPreviewBridge.ts) + `formulus-injection.js`. Subset is sufficient for admin tools: + - `getObservations` / `getObservationsByQuery` + - `persistObservation` (headless writes) + - `submitObservation` / `updateObservation` (optional finalize dialog) + - `sync` (when implemented on Desktop) + - `getCustomAppUri` / `getFormSpecsUri` (if app needs form specs) + - Stub or omit device APIs (camera, QR, GPS) + +### Security model + +- Load HTML **only** from paths declared in the signed bundle manifest (same trust model as `custom_app`). +- No arbitrary folder picker for data apps (unlike developer-mode custom app mirror). +- Data apps run in an iframe with the same `ReactNativeWebView` shim as the field custom app. + +## Developer mode + +When **custom app developer mode** is on, optionally mirror `data_apps/` from the local source folder if present (`/data_apps/`). Same pattern as `bundles/dev-local/app/` today. + +## Open questions + +1. Should data apps ship in the **same** Synkronus zip as the field app, or as a separate bundle type? +2. Manifest versioning and compatibility checks (bridge API version). +3. Whether data apps need Formplayer embed (nested forms) or only observation CRUD. +4. Portal UI for uploading / validating `data_apps/manifest.json`. + +## References + +- [desktop/AGENTS.md](../AGENTS.md) — Workbench layout, developer mode, bridge +- [formPreviewBridge.ts](../src/lib/formPreviewBridge.ts) — bridge message matrix +- Formulus `FormulusInterfaceDefinition.ts` — contract source of truth diff --git a/desktop/package.json b/desktop/package.json index a969a6444..e27af5081 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ode-desktop", "private": true, - "version": "1.0.1", + "version": "1.1.1", "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { diff --git a/desktop/public/formplayer-host-stub.js b/desktop/public/formplayer-host-stub.js index df3518a0a..c284f4060 100644 --- a/desktop/public/formplayer-host-stub.js +++ b/desktop/public/formplayer-host-stub.js @@ -34,4 +34,43 @@ } }, }; + + /** + * ODE Desktop: deliver bridge *_response to pending Formulus promises without + * relying on postMessage from the outer shell (unreliable for srcdoc iframes in WebView2). + */ + function deliverBridgeResponseBody(body) { + if (!body || !body.type || !body.messageId) { + return; + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(body), + }), + ); + } + + window.__odeFormplayerDeliverBridgeResponse = function ( + requestType, + messageId, + payload, + ) { + var responseType = requestType + '_response'; + var body = { type: responseType, messageId: messageId }; + if (payload && typeof payload === 'object') { + if ('result' in payload) body.result = payload.result; + if ('error' in payload) body.error = payload.error; + } + deliverBridgeResponseBody(body); + }; + + /** Same-origin broadcast fallback when the outer shell cannot postMessage into srcdoc iframes (WebView2). */ + if (typeof BroadcastChannel !== 'undefined') { + var bridgeResponseChannel = new BroadcastChannel( + 'ode-formplayer-bridge-response', + ); + bridgeResponseChannel.onmessage = function (event) { + deliverBridgeResponseBody(event.data); + }; + } })(); diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 4a93337c0..7207daf0d 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,6 +1,6 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-05-02T16:53:14.030Z +// Last generated: 2026-06-19T12:32:54.430Z (function () { // Enhanced API availability detection and recovery @@ -229,7 +229,7 @@ }); }, - // openFormplayer: formType, params, savedData, options?: { subObservationMode?, skipFinalize?, skipDraftSelection? } + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -356,7 +356,7 @@ }); }, - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise getObservationsByQuery: function (options) { return new Promise((resolve, reject) => { const messageId = @@ -664,6 +664,251 @@ }); }, + // getCachedLocation: fieldId: string => Promise + getCachedLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCachedLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCachedLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getCachedLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCachedLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise + allocateSequence: function (scopeKey, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'allocateSequence_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'allocateSequence', + messageId, + scopeKey: scopeKey, + options: options, + }), + ); + }); + }, + + // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'watchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'watchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'watchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'watchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // stopWatchLocation: fieldId: string => Promise + stopWatchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'stopWatchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'stopWatchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'stopWatchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'stopWatchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + // requestFile: fieldId: string => Promise requestFile: function (fieldId) { return new Promise((resolve, reject) => { @@ -917,15 +1162,18 @@ const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); + // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener reject( new Error( 'requestVideo callback: Received response with unexpected data type. Raw: ' + @@ -952,12 +1200,13 @@ 'Raw event.data:', event.data, ); - window.removeEventListener('message', callback); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'requestVideo', @@ -1633,6 +1882,245 @@ ); }); }, + + // persistObservation: input: PersistObservationInput => Promise + persistObservation: function (input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'persistObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'persistObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'persistObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'persistObservation', + messageId, + input: input, + }), + ); + }); + }, + + // sync: options: { includeAttachments?: boolean; } => Promise + sync: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'sync callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if (data.type === 'sync_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'sync' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'sync', + messageId, + options: options, + }), + ); + }); + }, + + // getConnectivityStatus: => Promise + getConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getConnectivityStatus', + messageId, + }), + ); + }); + }, + + // getCurrentDataRevisionCount: => Promise + getCurrentDataRevisionCount: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentDataRevisionCount_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getCurrentDataRevisionCount' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCurrentDataRevisionCount', + messageId, + }), + ); + }); + }, }; // Register the callback handler with the window object diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 598c70279..93b92c6ba 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2600,7 +2600,7 @@ dependencies = [ [[package]] name = "odedesktop" -version = "1.0.1" +version = "1.1.1" dependencies = [ "chrono", "keyring", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0a8ec74fb..60fbc37b9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "odedesktop" -version = "1.0.1" +version = "1.1.1" description = "ODE Desktop" authors = ["OpenDataEnsemble.org"] edition = "2024" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e31a5d2b2..c0abc3a63 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1021,6 +1021,21 @@ fn mirror_custom_app_dev_folder(ws: &Path, source: &Path) -> Result void; }; function defaultIndexRelativePath(mode: CustomAppEmbedMode): string { @@ -93,10 +95,12 @@ export const CustomAppEmbed = forwardRef< HTMLIFrameElement, CustomAppEmbedProps >(function CustomAppEmbed( - { mountKey, mode, indexRelativePath, loadingLabel }, + { mountKey, mode, indexRelativePath, loadingLabel, onContentWindowReady }, ref, ) { const innerRef = useRef(null); + const onContentWindowReadyRef = useRef(onContentWindowReady); + onContentWindowReadyRef.current = onContentWindowReady; const setRefs = useCallback( (el: HTMLIFrameElement | null) => { (innerRef as MutableRefObject).current = el; @@ -149,6 +153,7 @@ export const CustomAppEmbed = forwardRef< // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; el.onload = () => { + onContentWindowReadyRef.current?.(el.contentWindow); setLoading(false); }; el.src = url; diff --git a/desktop/src/components/FormplayerEmbed.tsx b/desktop/src/components/FormplayerEmbed.tsx index e655d2a4d..0b691fb4d 100644 --- a/desktop/src/components/FormplayerEmbed.tsx +++ b/desktop/src/components/FormplayerEmbed.tsx @@ -2,10 +2,11 @@ import { forwardRef, useCallback, useEffect, + useImperativeHandle, useRef, useState, - type MutableRefObject, } from 'react'; +import { postFormplayerBridgeReply } from '../lib/formPreviewBridge'; import type { FormInitData } from '../lib/formplayerHost'; const FORMSPLAYER_INDEX = `${import.meta.env.BASE_URL}formplayer_dist/index.html`; @@ -40,6 +41,18 @@ export type FormplayerEmbedProps = { /** Full `FormInitData` for the embedded formplayer; `null` shows `emptyMessage` only. */ formInitData: FormInitData | null; emptyMessage?: string; + /** Fired when the iframe document loads (used to register `contentWindow` for bridge routing). */ + onContentWindowReady?: (contentWindow: Window | null) => void; +}; + +/** Imperative handle for bridge delivery into the iframe document (WebView2-safe). */ +export type FormplayerEmbedHandle = { + getIframe: () => HTMLIFrameElement | null; + deliverBridgeResponse: ( + requestType: string, + messageId: string, + payload: { result?: unknown; error?: string }, + ) => void; }; /** @@ -48,27 +61,40 @@ export type FormplayerEmbedProps = { * base href) so Finalize / `submitObservation` and extension APIs work. */ export const FormplayerEmbed = forwardRef< - HTMLIFrameElement, + FormplayerEmbedHandle, FormplayerEmbedProps >(function FormplayerEmbed( { formInitData, emptyMessage = 'Select a form type and apply params/saved JSON to load the preview.', + onContentWindowReady, }, ref, ) { const innerRef = useRef(null); const timeoutRef = useRef(null); - const setRefs = useCallback( - (el: HTMLIFrameElement | null) => { - (innerRef as MutableRefObject).current = el; - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - (ref as MutableRefObject).current = el; - } - }, - [ref], + const onContentWindowReadyRef = useRef(onContentWindowReady); + onContentWindowReadyRef.current = onContentWindowReady; + + useImperativeHandle( + ref, + () => ({ + getIframe: () => innerRef.current, + deliverBridgeResponse: (requestType, messageId, payload) => { + const win = innerRef.current?.contentWindow ?? null; + if (!win) { + return; + } + postFormplayerBridgeReply( + innerRef.current, + requestType, + messageId, + payload, + win, + ); + }, + }), + [], ); const [error, setError] = useState(null); @@ -122,6 +148,7 @@ export const FormplayerEmbed = forwardRef< window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } + onContentWindowReadyRef.current?.(el.contentWindow); setLoading(false); }; // WebView2 can behave inconsistently with blob: + module scripts in packaged apps. @@ -160,7 +187,9 @@ export const FormplayerEmbed = forwardRef< {error ?

{error}

: null} {loading && !error ?

Loading formplayer…

: null}