From 46960d1e351d3af87e6e9f1d97f446e742bfb2be Mon Sep 17 00:00:00 2001 From: Raphael <68374617+raphaelscholle@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:25:04 +0200 Subject: [PATCH 1/3] Prepare ImageWriter application and installer signing through OpenHD SignPath --- .github/workflows/build.yml | 240 +----------------- .github/workflows/windows.yml | 306 +++++++++++++++++++++++ doc/signpath.md | 57 +++++ doc/signpath/imagewriter-application.xml | 8 + doc/signpath/imagewriter-installer.xml | 6 + scripts/verify_windows_signature.ps1 | 11 + src/windows/openhdimagewriter.nsi.in | 27 +- 7 files changed, 421 insertions(+), 234 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 doc/signpath.md create mode 100644 doc/signpath/imagewriter-application.xml create mode 100644 doc/signpath/imagewriter-installer.xml create mode 100644 scripts/verify_windows_signature.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54208213a..0fcc4c194 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -137,239 +137,13 @@ jobs: republish: "true" file: "*.deb" windows: - runs-on: windows-latest - - defaults: - run: - working-directory: src - - steps: - - uses: actions/checkout@v5 - - - name: Install Qt - id: qt-install - uses: jurplel/install-qt-action@v4 - with: - version: '5.15.2' - aqtversion: '==3.1.*' - host: windows - target: desktop - arch: win32_mingw81 - setup-python: false - tools: tools_cmake tools_mingw81,qt.tools.win32_mingw810 - - - name: Download and Extract OpenSSL - shell: pwsh - working-directory: ${{ github.workspace }} - run: | - Invoke-WebRequest ` - -Uri "https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-1.1.1w.zip" ` - -OutFile openssl-1.1.1w.zip - Expand-Archive openssl-1.1.1w.zip -DestinationPath openssl111 - - - name: Copy OpenSSL to Qt MinGW path - shell: pwsh - working-directory: ${{ github.workspace }} - run: | - dir ${{ github.workspace }}\..\Qt\ - dir ${{ github.workspace }}\..\Qt\Tools\ - dir ${{ github.workspace }}\..\Qt\Tools\mingw810_32\ - - $openssl = (Get-ChildItem -Directory -Path "${{ github.workspace }}\openssl111" | Where-Object { $_.Name -like "openssl-*" } | Select-Object -First 1).FullName + "\x86" - $mingwRoot = "${{ github.workspace }}\..\Qt\Tools\mingw810_32" - - Write-Host "Resolved OpenSSL path: $openssl" - if (Test-Path $openssl) { - Get-ChildItem -Recurse "$openssl" | ForEach-Object { Write-Host $_.FullName } - } else { - Write-Error "OpenSSL path not found: $openssl" - } - - Copy-Item "$openssl\bin\libcrypto-1_1.dll" "$mingwRoot\bin\" -Force - Copy-Item "$openssl\bin\libssl-1_1.dll" "$mingwRoot\bin\" -Force - Copy-Item "$openssl\lib\libcrypto.lib" "$mingwRoot\lib\" -Force - Copy-Item "$openssl\lib\libssl.lib" "$mingwRoot\lib\" -Force - New-Item -ItemType Directory -Force -Path "$mingwRoot\include\openssl" - Write-Host "Copying OpenSSL headers from: $openssl\include\openssl\." - Copy-Item "$openssl\include\openssl\." "$mingwRoot\include\" -Recurse -Force -Verbose - dir "$mingwRoot\include\openssl\" - - - name: Install NSIS - run: choco install nsis --x86 --force -y - - - name: Show Qt & CMake Info - run: | - where gcc - where g++ - cmake --version - - - name: Debug OpenSSL include headers - shell: pwsh - working-directory: ${{ github.workspace }} - run: | - $opensslInclude = "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\include\openssl" - $openssl = (Get-ChildItem -Directory -Path "${{ github.workspace }}\openssl111" | Where-Object { $_.Name -like "openssl-*" } | Select-Object -First 1).FullName + "\x86" - - Write-Host "Looking in: $opensslInclude" - if (Test-Path $opensslInclude) { - Get-ChildItem $opensslInclude | Select-Object -First 20 - } else { - Write-Error "OpenSSL include directory not found!" - } - - dir "$openssl\include\openssl\" - - - - name: Configure CMake - shell: cmd - working-directory: src - run: | - dir "%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\include" - mkdir build - cd build - cmake .. -G "MinGW Makefiles" ^ - -DCMAKE_BUILD_TYPE=Release ^ - -DCMAKE_C_COMPILER="%GITHUB_WORKSPACE%\..\Qt\Tools\mingw810_32\bin\gcc.exe" ^ - -DCMAKE_CXX_COMPILER="%GITHUB_WORKSPACE%\..\Qt\Tools\mingw810_32\bin\g++.exe" ^ - -DOPENSSL_INCLUDE_DIR="%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\include" ^ - -DOPENSSL_LIBRARIES="%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\lib" - - - - name: Check CMake Cache - run: | - dir build - type build\CMakeCache.txt || echo "Cache missing" - - - name: Build - shell: pwsh - run: | - $env:PATH = "$Env:GITHUB_WORKSPACE\..\Qt\5.15.2\mingw81_32\bin;$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin;$env:PATH" - cd build - cmake --build . --verbose - - - name: Copy OpenSSL DLLs to build folder - shell: pwsh - run: | - Copy-Item "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin\libssl-1_1.dll" -Destination "$Env:GITHUB_WORKSPACE\src\build\" - Copy-Item "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin\libcrypto-1_1.dll" -Destination "$Env:GITHUB_WORKSPACE\src\build\" - - - name: Validate Windows signing configuration - shell: pwsh - working-directory: ${{ github.workspace }} - env: - WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} - WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} - run: | - $hasCertificate = -not [string]::IsNullOrWhiteSpace($Env:WINDOWS_SIGNING_CERTIFICATE_BASE64) - $hasPassword = -not [string]::IsNullOrWhiteSpace($Env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD) - if ($hasCertificate -xor $hasPassword) { - throw "Configure both Windows signing secrets or neither of them" - } - if (-not $hasCertificate) { - Write-Warning "Windows signing secrets are not configured; this CI artifact will be unsigned" - } - - # Sign the executable before NSIS packages it. The two repository secrets - # are intentionally optional so pull requests and forks can still build. - - name: Sign Windows application - shell: pwsh - working-directory: ${{ github.workspace }} - env: - WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} - WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} - run: | - if ([string]::IsNullOrWhiteSpace($Env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) { - Write-Host "Windows signing is not configured; skipping application signing" - exit 0 - } - - $certificatePath = Join-Path $Env:RUNNER_TEMP "openhd-imagewriter-signing.pfx" - [IO.File]::WriteAllBytes( - $certificatePath, - [Convert]::FromBase64String($Env:WINDOWS_SIGNING_CERTIFICATE_BASE64) - ) - - $signTool = Get-ChildItem "${Env:ProgramFiles(x86)}\Windows Kits\10\bin" ` - -Filter signtool.exe -Recurse | - Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | - Sort-Object FullName -Descending | - Select-Object -First 1 -ExpandProperty FullName - if (-not $signTool) { - throw "signtool.exe was not found" - } - - & $signTool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` - /f $certificatePath /p $Env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD ` - /d "OpenHD ImageWriter" /du "https://openhd.tech" ` - "src\build\deploy\OpenHDImageWriter.exe" - if ($LASTEXITCODE -ne 0) { - throw "Signing OpenHDImageWriter.exe failed with exit code $LASTEXITCODE" - } - & $signTool verify /pa /v "src\build\deploy\OpenHDImageWriter.exe" - if ($LASTEXITCODE -ne 0) { - throw "Signature verification for OpenHDImageWriter.exe failed with exit code $LASTEXITCODE" - } - - - name: Build NSIS Installer - shell: pwsh - working-directory: src\build - run: | - & "${Env:ProgramFiles(x86)}\NSIS\makensis.exe" "openhdimagewriter.nsi" - - - name: Sign Windows installer - shell: pwsh - working-directory: ${{ github.workspace }} - env: - WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} - WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} - run: | - if ([string]::IsNullOrWhiteSpace($Env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) { - Write-Host "Windows signing is not configured; skipping installer signing" - exit 0 - } - - $certificatePath = Join-Path $Env:RUNNER_TEMP "openhd-imagewriter-signing.pfx" - $signTool = Get-ChildItem "${Env:ProgramFiles(x86)}\Windows Kits\10\bin" ` - -Filter signtool.exe -Recurse | - Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | - Sort-Object FullName -Descending | - Select-Object -First 1 -ExpandProperty FullName - if (-not $signTool) { - throw "signtool.exe was not found" - } - - $installer = Get-ChildItem "src\build\OpenHD-ImageWriter-*.exe" | - Select-Object -First 1 - if (-not $installer) { - throw "NSIS installer was not found" - } - - & $signTool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` - /f $certificatePath /p $Env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD ` - /d "OpenHD ImageWriter" /du "https://openhd.tech" ` - $installer.FullName - if ($LASTEXITCODE -ne 0) { - throw "Signing the NSIS installer failed with exit code $LASTEXITCODE" - } - & $signTool verify /pa /v $installer.FullName - if ($LASTEXITCODE -ne 0) { - throw "Signature verification for the NSIS installer failed with exit code $LASTEXITCODE" - } - - - name: Remove temporary signing certificate - if: always() - shell: pwsh - working-directory: ${{ github.workspace }} - run: | - Remove-Item -LiteralPath (Join-Path $Env:RUNNER_TEMP "openhd-imagewriter-signing.pfx") ` - -Force -ErrorAction SilentlyContinue - - - name: Upload Installer - uses: actions/upload-artifact@v7 - with: - name: OpenHDImageWriter-Build-Full - path: src/build/OpenHD-ImageWriter-* - if-no-files-found: error + permissions: + contents: read + actions: read + uses: ./.github/workflows/windows.yml + with: + source-repository: ${{ github.repository }} + source-ref: ${{ github.sha }} macos: name: Build (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 000000000..b233dcade --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,306 @@ +name: Windows ImageWriter build and SignPath signing + +on: + workflow_call: + inputs: + source-repository: + description: Source repository; signed builds use the canonical ImageWriter repository + type: string + default: OpenHD/OpenHD-ImageWriter + source-ref: + description: Full OpenHD-ImageWriter source commit SHA + type: string + required: true + sign: + description: Sign using the caller's approved OpenHD SignPath project + type: boolean + default: false + organization-id: + type: string + default: '' + project-slug: + type: string + default: '' + signing-policy-slug: + type: string + default: '' + secrets: + SIGNPATH_API_TOKEN: + required: false + +permissions: + contents: read + actions: read + +env: + BUILD_TYPE: Release + +jobs: + windows: + runs-on: windows-latest + + # Signing is requested only by the workflow in the approved OpenHD origin. + # Pull requests and normal ImageWriter builds always remain unsigned. + + defaults: + run: + working-directory: src + + steps: + - name: Validate SignPath configuration + if: inputs.sign + shell: pwsh + working-directory: ${{ github.workspace }} + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORGANIZATION_ID: ${{ inputs.organization-id }} + SIGNPATH_PROJECT_SLUG: ${{ inputs.project-slug }} + SIGNPATH_SIGNING_POLICY_SLUG: ${{ inputs.signing-policy-slug }} + SOURCE_REPOSITORY: ${{ inputs.source-repository }} + run: | + if ($Env:GITHUB_REPOSITORY -ne 'OpenHD/OpenHD' -or $Env:GITHUB_EVENT_NAME -ne 'workflow_dispatch') { + throw "Signing must be requested by the manual OpenHD/OpenHD workflow" + } + if ($Env:SOURCE_REPOSITORY -ne 'OpenHD/OpenHD-ImageWriter') { + throw "Signed builds must use the canonical ImageWriter source repository" + } + foreach ($name in @('SIGNPATH_API_TOKEN', 'SIGNPATH_ORGANIZATION_ID', 'SIGNPATH_PROJECT_SLUG', 'SIGNPATH_SIGNING_POLICY_SLUG')) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Missing required SignPath setting: $name" + } + } + + - name: Validate source revision + shell: pwsh + working-directory: ${{ github.workspace }} + env: + SOURCE_REF: ${{ inputs.source-ref }} + run: | + if ($Env:SOURCE_REF -notmatch '^[0-9a-fA-F]{40}$') { + throw "source-ref must be the full ImageWriter commit SHA" + } + + - uses: actions/checkout@v5 + with: + repository: ${{ inputs.source-repository }} + ref: ${{ inputs.source-ref }} + persist-credentials: false + + - name: Install Qt + id: qt-install + uses: jurplel/install-qt-action@v4 + with: + version: '5.15.2' + aqtversion: '==3.1.*' + host: windows + target: desktop + arch: win32_mingw81 + setup-python: false + tools: tools_cmake tools_mingw81,qt.tools.win32_mingw810 + + - name: Download and Extract OpenSSL + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + Invoke-WebRequest ` + -Uri "https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-1.1.1w.zip" ` + -OutFile openssl-1.1.1w.zip + Expand-Archive openssl-1.1.1w.zip -DestinationPath openssl111 + + - name: Copy OpenSSL to Qt MinGW path + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + dir ${{ github.workspace }}\..\Qt\ + dir ${{ github.workspace }}\..\Qt\Tools\ + dir ${{ github.workspace }}\..\Qt\Tools\mingw810_32\ + + $openssl = (Get-ChildItem -Directory -Path "${{ github.workspace }}\openssl111" | Where-Object { $_.Name -like "openssl-*" } | Select-Object -First 1).FullName + "\x86" + $mingwRoot = "${{ github.workspace }}\..\Qt\Tools\mingw810_32" + + Write-Host "Resolved OpenSSL path: $openssl" + if (Test-Path $openssl) { + Get-ChildItem -Recurse "$openssl" | ForEach-Object { Write-Host $_.FullName } + } else { + Write-Error "OpenSSL path not found: $openssl" + } + + Copy-Item "$openssl\bin\libcrypto-1_1.dll" "$mingwRoot\bin\" -Force + Copy-Item "$openssl\bin\libssl-1_1.dll" "$mingwRoot\bin\" -Force + Copy-Item "$openssl\lib\libcrypto.lib" "$mingwRoot\lib\" -Force + Copy-Item "$openssl\lib\libssl.lib" "$mingwRoot\lib\" -Force + New-Item -ItemType Directory -Force -Path "$mingwRoot\include\openssl" + Write-Host "Copying OpenSSL headers from: $openssl\include\openssl\." + Copy-Item "$openssl\include\openssl\." "$mingwRoot\include\" -Recurse -Force -Verbose + dir "$mingwRoot\include\openssl\" + + - name: Install NSIS + run: choco install nsis --x86 --force -y + + - name: Show Qt & CMake Info + run: | + where gcc + where g++ + cmake --version + + - name: Debug OpenSSL include headers + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + $opensslInclude = "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\include\openssl" + $openssl = (Get-ChildItem -Directory -Path "${{ github.workspace }}\openssl111" | Where-Object { $_.Name -like "openssl-*" } | Select-Object -First 1).FullName + "\x86" + + Write-Host "Looking in: $opensslInclude" + if (Test-Path $opensslInclude) { + Get-ChildItem $opensslInclude | Select-Object -First 20 + } else { + Write-Error "OpenSSL include directory not found!" + } + + dir "$openssl\include\openssl\" + + + - name: Configure CMake + shell: cmd + working-directory: src + run: | + dir "%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\include" + mkdir build + cd build + cmake .. -G "MinGW Makefiles" ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER="%GITHUB_WORKSPACE%\..\Qt\Tools\mingw810_32\bin\gcc.exe" ^ + -DCMAKE_CXX_COMPILER="%GITHUB_WORKSPACE%\..\Qt\Tools\mingw810_32\bin\g++.exe" ^ + -DOPENSSL_INCLUDE_DIR="%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\include" ^ + -DOPENSSL_LIBRARIES="%GITHUB_WORKSPACE%\openssl111\openssl-1.1\x86\lib" + + + - name: Check CMake Cache + run: | + dir build + type build\CMakeCache.txt || echo "Cache missing" + + - name: Build + shell: pwsh + run: | + $env:PATH = "$Env:GITHUB_WORKSPACE\..\Qt\5.15.2\mingw81_32\bin;$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin;$env:PATH" + cd build + cmake --build . --verbose + + - name: Copy OpenSSL DLLs to build folder + shell: pwsh + run: | + Copy-Item "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin\libssl-1_1.dll" -Destination "$Env:GITHUB_WORKSPACE\src\build\" + Copy-Item "$Env:GITHUB_WORKSPACE\..\Qt\Tools\mingw810_32\bin\libcrypto-1_1.dll" -Destination "$Env:GITHUB_WORKSPACE\src\build\" + + - name: Prepare application and uninstaller for signing + if: inputs.sign + shell: pwsh + working-directory: src/build + run: | + $stage = Join-Path $Env:RUNNER_TEMP 'imagewriter-unsigned-application' + New-Item -ItemType Directory -Force -Path $stage | Out-Null + & "${Env:ProgramFiles(x86)}\NSIS\makensis.exe" /DINNER "/DUNINSTALLER_OUTPUT=$stage\uninstall.exe" openhdimagewriter.nsi + if ($LASTEXITCODE -ne 0) { throw 'Building the uninstaller generator failed' } + $generator = Start-Process -FilePath (Join-Path $PWD 'ImageWriter-uninstaller-generator.exe') -WindowStyle Hidden -Wait -PassThru + if ($generator.ExitCode -ne 0 -or -not (Test-Path -LiteralPath "$stage\uninstall.exe")) { + throw 'Generating the uninstaller failed' + } + Copy-Item deploy/OpenHDImageWriter.exe, deploy/fat32format.exe -Destination $stage + + - name: Upload unsigned application + if: inputs.sign + id: unsigned-application + uses: actions/upload-artifact@v4 + with: + name: ImageWriter-unsigned-application + path: ${{ runner.temp }}/imagewriter-unsigned-application/* + if-no-files-found: error + retention-days: 7 + + - name: Sign application, formatter and uninstaller with SignPath + if: inputs.sign + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ inputs.organization-id }} + project-slug: ${{ inputs.project-slug }} + signing-policy-slug: ${{ inputs.signing-policy-slug }} + artifact-configuration-slug: imagewriter-application + github-artifact-id: ${{ steps.unsigned-application.outputs.artifact-id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 1800 + output-artifact-directory: ${{ runner.temp }}/imagewriter-signed-application + + - name: Verify and stage signed application + if: inputs.sign + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + foreach ($file in @('OpenHDImageWriter.exe', 'fat32format.exe', 'uninstall.exe')) { + $source = Join-Path $Env:RUNNER_TEMP "imagewriter-signed-application/$file" + & scripts/verify_windows_signature.ps1 -Path $source + Copy-Item -LiteralPath $source -Destination "src/build/deploy/$file" -Force + } + + - name: Build NSIS installer + shell: pwsh + working-directory: src/build + env: + SIGN_BUILD: ${{ inputs.sign }} + run: | + $arguments = @('openhdimagewriter.nsi') + if ($Env:SIGN_BUILD -eq 'true') { + $arguments = @('/DSIGNED_UNINSTALLER=deploy\uninstall.exe') + $arguments + } + & "${Env:ProgramFiles(x86)}\NSIS\makensis.exe" @arguments + if ($LASTEXITCODE -ne 0) { throw 'Building the NSIS installer failed' } + + - name: Upload unsigned installer + if: inputs.sign + id: unsigned-installer + uses: actions/upload-artifact@v4 + with: + name: ImageWriter-unsigned-installer + path: src/build/OpenHD-ImageWriter-*.exe + if-no-files-found: error + retention-days: 7 + + - name: Sign installer with SignPath + if: inputs.sign + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ inputs.organization-id }} + project-slug: ${{ inputs.project-slug }} + signing-policy-slug: ${{ inputs.signing-policy-slug }} + artifact-configuration-slug: imagewriter-installer + github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 1800 + output-artifact-directory: ${{ runner.temp }}/imagewriter-signed-installer + + - name: Verify and stage signed installer + if: inputs.sign + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + $installers = @(Get-ChildItem "$Env:RUNNER_TEMP/imagewriter-signed-installer/OpenHD-ImageWriter-*.exe") + if ($installers.Count -ne 1) { throw 'Expected exactly one signed installer' } + & scripts/verify_windows_signature.ps1 -Path $installers[0].FullName + Copy-Item -LiteralPath $installers[0].FullName -Destination src/build -Force + + - name: Upload installer + uses: actions/upload-artifact@v4 + with: + name: OpenHDImageWriter-Build-Full${{ inputs.sign && '-Signed' || '-Unsigned' }} + path: src/build/OpenHD-ImageWriter-*.exe + if-no-files-found: error + + - name: Upload portable application + uses: actions/upload-artifact@v4 + with: + name: OpenHDImageWriter-Windows-Portable${{ inputs.sign && '-Signed' || '-Unsigned' }} + path: src/build/deploy/ + if-no-files-found: error + diff --git a/doc/signpath.md b/doc/signpath.md new file mode 100644 index 000000000..a1323d041 --- /dev/null +++ b/doc/signpath.md @@ -0,0 +1,57 @@ +# Windows signing using the existing OpenHD SignPath project + +The ordinary ImageWriter build calls `.github/workflows/windows.yml` with signing +disabled. Pull requests and forks do not receive signing credentials. Signed +builds are requested manually from `OpenHD/OpenHD` using +`.github/workflows/imagewriter-signing.yml`. That caller is prepared in the OpenHD +repository, and uses the existing project rather than a second signing project. + +The caller supplies an explicit, full ImageWriter commit SHA. All compilation, +artifact uploads, and signing requests run on a GitHub-hosted Windows runner. +The workflow signs ImageWriter, the locally built FAT32 formatter, and the NSIS +uninstaller first, verifies their timestamped signatures, packages them, then +signs and verifies the outer installer. Third-party Qt/OpenSSL/UUU binaries are +redistributed as supplied; this workflow does not sign them as OpenHD binaries. + +## Activate the prepared integration + +1. Confirm that the existing SignPath project's approved source coverage includes + `https://github.com/OpenHD/OpenHD-ImageWriter` when built through the OpenHD + caller workflow. The GitHub connector still validates the source/build origin; + reusing the OpenHD project does not itself extend approval to ImageWriter. + Link the GitHub trusted build system and grant the SignPath GitHub App access + to both repositories as required by the project configuration. +2. Add these artifact configurations to the existing project, using the exact + slugs referenced by the workflow: + - `imagewriter-application`: [application XML](signpath/imagewriter-application.xml) + - `imagewriter-installer`: [installer XML](signpath/imagewriter-installer.xml) + Both expect the ZIP artifacts produced by `actions/upload-artifact@v4`. +3. Ensure the intended signing policy is valid, permits both artifact + configurations, and grants the API-token user submitter rights. For Windows + trust verification, use a policy with a publicly trusted certificate and + timestamping. A private test certificate will fail the final trust checks + unless the runner explicitly trusts it. +4. In **OpenHD/OpenHD**, set repository or organization variables + `SIGNPATH_ORGANIZATION_ID`, `SIGNPATH_PROJECT_SLUG`, and + `SIGNPATH_SIGNING_POLICY_SLUG` to the existing project's values. Add secret + `SIGNPATH_API_TOKEN` with the permitted submitter token. Do not put the token + in a variable, source file, or workflow input. +5. Publish the ImageWriter workflow and NSIS changes. Pin the OpenHD caller's + `uses: .../windows.yml@dev-branch` reference to the reviewed ImageWriter commit + SHA before activating signing. Publish the OpenHD caller and run **Sign + Windows ImageWriter**, passing an ImageWriter commit containing these changes. + +Missing configuration, rejected signing requests, invalid signatures, or missing +timestamps fail the signed build. It never falls back to publishing an unsigned +artifact as signed. Download `OpenHDImageWriter-Build-Full-Signed` from the +**OpenHD** workflow run. Normal builds publish +`OpenHDImageWriter-Build-Full-Unsigned` from ImageWriter. +The same run also publishes `OpenHDImageWriter-Windows-Portable-Signed`, which +includes the signed application executable and its runtime dependencies. + +The former optional PFX certificate/password workflow is replaced by SignPath; +no private signing certificate is exported onto the runner. Signed builds are +manual so ordinary development pushes do not create approval requests. + +See the [SignPath GitHub integration](https://docs.signpath.io/trusted-build-systems/github) +and [artifact configuration syntax](https://docs.signpath.io/artifact-configuration/syntax). diff --git a/doc/signpath/imagewriter-application.xml b/doc/signpath/imagewriter-application.xml new file mode 100644 index 000000000..3d008a61a --- /dev/null +++ b/doc/signpath/imagewriter-application.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/doc/signpath/imagewriter-installer.xml b/doc/signpath/imagewriter-installer.xml new file mode 100644 index 000000000..4683de355 --- /dev/null +++ b/doc/signpath/imagewriter-installer.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/scripts/verify_windows_signature.ps1 b/scripts/verify_windows_signature.ps1 new file mode 100644 index 000000000..4d3463969 --- /dev/null +++ b/scripts/verify_windows_signature.ps1 @@ -0,0 +1,11 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Path +) + +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -LiteralPath $Path +if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { + throw "Expected a valid, timestamped Authenticode signature on $Path; status: $($signature.Status)" +} +Write-Host "Verified timestamped signature: $Path" diff --git a/src/windows/openhdimagewriter.nsi.in b/src/windows/openhdimagewriter.nsi.in index abed32421..6b76aa894 100644 --- a/src/windows/openhdimagewriter.nsi.in +++ b/src/windows/openhdimagewriter.nsi.in @@ -34,7 +34,12 @@ VIAddVersionKey "FileVersion" "${VERSION}" SetCompressor LZMA Name "${APP_NAME}" Caption "${APP_NAME}" +!ifdef INNER +RequestExecutionLevel user +OutFile "ImageWriter-uninstaller-generator.exe" +!else OutFile "${INSTALLER_NAME}" +!endif BrandingText "${APP_NAME}" XPStyle on InstallDirRegKey "${REG_ROOT}" "${REG_APP_PATH}" "" @@ -181,6 +186,7 @@ LangString win7Msg ${LANG_ITALIAN} "Per l'esecuzione del programma è richiesto ###################################################################### +!ifndef INNER Section -MainProgram ${INSTALL_TYPE} !insertmacro TerminateApp @@ -285,8 +291,12 @@ SectionEnd Section -Icons_Reg !ifndef INNER SetOutPath "$INSTDIR" +!ifdef SIGNED_UNINSTALLER +File /oname=uninstall.exe "${SIGNED_UNINSTALLER}" +!else WriteUninstaller "$INSTDIR\uninstall.exe" !endif +!endif !ifdef REG_START_MENU !insertmacro MUI_STARTMENU_WRITE_BEGIN Application @@ -332,6 +342,12 @@ SectionEnd ###################################################################### +!else +; The generator needs only the uninstaller code, not the application payload. +Section -GenerateUninstaller +SectionEnd +!endif + Section Uninstall ${INSTALL_TYPE} Delete "$INSTDIR\D3Dcompiler_47.dll" @@ -446,8 +462,17 @@ Function .onInit ; the uninstaller. This is better than processing a command line option as it means ; this entire code path is not present in the final (real) installer. SetSilent silent - WriteUninstaller "$%TEMP%\uninstall.exe" + !ifndef UNINSTALLER_OUTPUT + !error "INNER requires UNINSTALLER_OUTPUT" + !endif + ClearErrors + WriteUninstaller "${UNINSTALLER_OUTPUT}" + IfErrors generator_failed + SetErrorLevel 0 Quit ; just bail out quickly when running the "inner" installer +generator_failed: + SetErrorLevel 1 + Quit !else ${IfNot} ${AtLeastWin7} MessageBox MB_OK "$(win7Msg)" From d0dc0c223e4c84611a917c9bc3d495278dc9350f Mon Sep 17 00:00:00 2001 From: Raphael <68374617+raphaelscholle@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:50:34 +0200 Subject: [PATCH 2/3] Run SignPath onboarding and release signing in the ImageWriter repository --- .github/workflows/sign-windows.yml | 30 +++++ .github/workflows/windows.yml | 49 +++++++-- doc/signpath.md | 115 +++++++++++--------- scripts/trust_signpath_test_certificate.ps1 | 39 +++++++ scripts/verify_windows_signature.ps1 | 11 +- 5 files changed, 183 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/sign-windows.yml create mode 100644 scripts/trust_signpath_test_certificate.ps1 diff --git a/.github/workflows/sign-windows.yml b/.github/workflows/sign-windows.yml new file mode 100644 index 000000000..ed3a3fae5 --- /dev/null +++ b/.github/workflows/sign-windows.yml @@ -0,0 +1,30 @@ +name: Sign Windows ImageWriter + +on: + workflow_dispatch: + inputs: + signing_mode: + description: Test for SignPath onboarding, release for public distribution + type: choice + options: [test, release] + default: test + required: true + +permissions: + contents: read + actions: read + +jobs: + imagewriter: + if: github.repository == 'OpenHD/OpenHD-ImageWriter' + uses: ./.github/workflows/windows.yml + with: + source-ref: ${{ github.sha }} + sign: true + signing-mode: ${{ inputs.signing_mode }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ inputs.signing_mode == 'test' && vars.SIGNPATH_TEST_SIGNING_POLICY_SLUG || vars.SIGNPATH_RELEASE_SIGNING_POLICY_SLUG }} + test-certificate-base64: ${{ vars.SIGNPATH_TEST_CERTIFICATE_BASE64 }} + secrets: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index b233dcade..28f0b8ee0 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -12,9 +12,17 @@ on: type: string required: true sign: - description: Sign using the caller's approved OpenHD SignPath project + description: Sign using ImageWriter's approved SignPath project type: boolean default: false + signing-mode: + description: Test or release signing; test artifacts are not for distribution + type: string + default: release + test-certificate-base64: + description: Public self-signed onboarding certificate (DER, base64) + type: string + default: '' organization-id: type: string default: '' @@ -39,7 +47,7 @@ jobs: windows: runs-on: windows-latest - # Signing is requested only by the workflow in the approved OpenHD origin. + # Signing is requested only by the manual workflow in the ImageWriter origin. # Pull requests and normal ImageWriter builds always remain unsigned. defaults: @@ -57,9 +65,21 @@ jobs: SIGNPATH_PROJECT_SLUG: ${{ inputs.project-slug }} SIGNPATH_SIGNING_POLICY_SLUG: ${{ inputs.signing-policy-slug }} SOURCE_REPOSITORY: ${{ inputs.source-repository }} + SOURCE_REF: ${{ inputs.source-ref }} + SIGNING_MODE: ${{ inputs.signing-mode }} + TEST_CERTIFICATE_BASE64: ${{ inputs.test-certificate-base64 }} run: | - if ($Env:GITHUB_REPOSITORY -ne 'OpenHD/OpenHD' -or $Env:GITHUB_EVENT_NAME -ne 'workflow_dispatch') { - throw "Signing must be requested by the manual OpenHD/OpenHD workflow" + if ($Env:GITHUB_REPOSITORY -ne 'OpenHD/OpenHD-ImageWriter' -or $Env:GITHUB_EVENT_NAME -ne 'workflow_dispatch') { + throw "Signing must be requested by the manual ImageWriter workflow" + } + if ($Env:SOURCE_REF -ne $Env:GITHUB_SHA) { + throw 'Signed source must match the workflow revision for origin verification' + } + if ($Env:SIGNING_MODE -notin @('test', 'release')) { + throw 'signing-mode must be test or release' + } + if ($Env:SIGNING_MODE -eq 'test' -and [string]::IsNullOrWhiteSpace($Env:TEST_CERTIFICATE_BASE64)) { + throw 'Test signing requires the public SignPath test certificate' } if ($Env:SOURCE_REPOSITORY -ne 'OpenHD/OpenHD-ImageWriter') { throw "Signed builds must use the canonical ImageWriter source repository" @@ -86,6 +106,15 @@ jobs: ref: ${{ inputs.source-ref }} persist-credentials: false + - name: Trust the pinned onboarding certificate on this test runner + if: inputs.sign && inputs.signing-mode == 'test' + shell: pwsh + working-directory: ${{ github.workspace }} + env: + TEST_CERTIFICATE_BASE64: ${{ inputs.test-certificate-base64 }} + run: | + & scripts/trust_signpath_test_certificate.ps1 -CertificateBase64 $Env:TEST_CERTIFICATE_BASE64 + - name: Install Qt id: qt-install uses: jurplel/install-qt-action@v4 @@ -236,10 +265,12 @@ jobs: if: inputs.sign shell: pwsh working-directory: ${{ github.workspace }} + env: + SIGNING_MODE: ${{ inputs.signing-mode }} run: | foreach ($file in @('OpenHDImageWriter.exe', 'fat32format.exe', 'uninstall.exe')) { $source = Join-Path $Env:RUNNER_TEMP "imagewriter-signed-application/$file" - & scripts/verify_windows_signature.ps1 -Path $source + & scripts/verify_windows_signature.ps1 -Path $source -SigningMode $Env:SIGNING_MODE -ExpectedSignerThumbprint $Env:SIGNPATH_TEST_CERTIFICATE_THUMBPRINT Copy-Item -LiteralPath $source -Destination "src/build/deploy/$file" -Force } @@ -284,23 +315,25 @@ jobs: if: inputs.sign shell: pwsh working-directory: ${{ github.workspace }} + env: + SIGNING_MODE: ${{ inputs.signing-mode }} run: | $installers = @(Get-ChildItem "$Env:RUNNER_TEMP/imagewriter-signed-installer/OpenHD-ImageWriter-*.exe") if ($installers.Count -ne 1) { throw 'Expected exactly one signed installer' } - & scripts/verify_windows_signature.ps1 -Path $installers[0].FullName + & scripts/verify_windows_signature.ps1 -Path $installers[0].FullName -SigningMode $Env:SIGNING_MODE -ExpectedSignerThumbprint $Env:SIGNPATH_TEST_CERTIFICATE_THUMBPRINT Copy-Item -LiteralPath $installers[0].FullName -Destination src/build -Force - name: Upload installer uses: actions/upload-artifact@v4 with: - name: OpenHDImageWriter-Build-Full${{ inputs.sign && '-Signed' || '-Unsigned' }} + name: OpenHDImageWriter-Build-Full${{ inputs.sign && (inputs.signing-mode == 'test' && '-Test-Signed' || '-Signed') || '-Unsigned' }} path: src/build/OpenHD-ImageWriter-*.exe if-no-files-found: error - name: Upload portable application uses: actions/upload-artifact@v4 with: - name: OpenHDImageWriter-Windows-Portable${{ inputs.sign && '-Signed' || '-Unsigned' }} + name: OpenHDImageWriter-Windows-Portable${{ inputs.sign && (inputs.signing-mode == 'test' && '-Test-Signed' || '-Signed') || '-Unsigned' }} path: src/build/deploy/ if-no-files-found: error diff --git a/doc/signpath.md b/doc/signpath.md index a1323d041..cde506ee0 100644 --- a/doc/signpath.md +++ b/doc/signpath.md @@ -1,57 +1,68 @@ -# Windows signing using the existing OpenHD SignPath project - -The ordinary ImageWriter build calls `.github/workflows/windows.yml` with signing -disabled. Pull requests and forks do not receive signing credentials. Signed -builds are requested manually from `OpenHD/OpenHD` using -`.github/workflows/imagewriter-signing.yml`. That caller is prepared in the OpenHD -repository, and uses the existing project rather than a second signing project. - -The caller supplies an explicit, full ImageWriter commit SHA. All compilation, -artifact uploads, and signing requests run on a GitHub-hosted Windows runner. -The workflow signs ImageWriter, the locally built FAT32 formatter, and the NSIS -uninstaller first, verifies their timestamped signatures, packages them, then -signs and verifies the outer installer. Third-party Qt/OpenSSL/UUU binaries are -redistributed as supplied; this workflow does not sign them as OpenHD binaries. - -## Activate the prepared integration - -1. Confirm that the existing SignPath project's approved source coverage includes - `https://github.com/OpenHD/OpenHD-ImageWriter` when built through the OpenHD - caller workflow. The GitHub connector still validates the source/build origin; - reusing the OpenHD project does not itself extend approval to ImageWriter. - Link the GitHub trusted build system and grant the SignPath GitHub App access - to both repositories as required by the project configuration. -2. Add these artifact configurations to the existing project, using the exact - slugs referenced by the workflow: +# Windows ImageWriter signing with SignPath + +SignPath Foundation's confirmed arrangement is a separate ImageWriter project +inside the existing OpenHD OSS organization, sharing its production certificate. +The project's source repository and GitHub Actions origin are +`https://github.com/OpenHD/OpenHD-ImageWriter`. An OpenHD repository caller is +not required. + +Ordinary pushes, pull requests and fork builds call `windows.yml` with signing +disabled. Run **Sign Windows ImageWriter** (`sign-windows.yml`) manually in the +canonical ImageWriter repository, selecting the branch containing the reviewed +workflow and the `test` or `release` mode. The workflow builds its own exact +commit (`github.sha`), so the source matches the build's origin metadata. + +## Configure the ImageWriter project + +1. Wait for SignPath to add ImageWriter as a separate project, then link its + GitHub trusted build system and enable SignPath GitHub App access to the + ImageWriter repository as required by their configuration. +2. Add these artifact configurations to the **ImageWriter** project: - `imagewriter-application`: [application XML](signpath/imagewriter-application.xml) - `imagewriter-installer`: [installer XML](signpath/imagewriter-installer.xml) - Both expect the ZIP artifacts produced by `actions/upload-artifact@v4`. -3. Ensure the intended signing policy is valid, permits both artifact - configurations, and grants the API-token user submitter rights. For Windows - trust verification, use a policy with a publicly trusted certificate and - timestamping. A private test certificate will fail the final trust checks - unless the runner explicitly trusts it. -4. In **OpenHD/OpenHD**, set repository or organization variables - `SIGNPATH_ORGANIZATION_ID`, `SIGNPATH_PROJECT_SLUG`, and - `SIGNPATH_SIGNING_POLICY_SLUG` to the existing project's values. Add secret - `SIGNPATH_API_TOKEN` with the permitted submitter token. Do not put the token - in a variable, source file, or workflow input. -5. Publish the ImageWriter workflow and NSIS changes. Pin the OpenHD caller's - `uses: .../windows.yml@dev-branch` reference to the reviewed ImageWriter commit - SHA before activating signing. Publish the OpenHD caller and run **Sign - Windows ImageWriter**, passing an ImageWriter commit containing these changes. - -Missing configuration, rejected signing requests, invalid signatures, or missing -timestamps fail the signed build. It never falls back to publishing an unsigned -artifact as signed. Download `OpenHDImageWriter-Build-Full-Signed` from the -**OpenHD** workflow run. Normal builds publish -`OpenHDImageWriter-Build-Full-Unsigned` from ImageWriter. -The same run also publishes `OpenHDImageWriter-Windows-Portable-Signed`, which -includes the signed application executable and its runtime dependencies. - -The former optional PFX certificate/password workflow is replaced by SignPath; -no private signing certificate is exported onto the runner. Signed builds are -manual so ordinary development pushes do not create approval requests. + Both expect ZIP artifacts from `actions/upload-artifact@v4`. Configurations + previously added to the OpenHD project do not configure the new project. +3. In **OpenHD/OpenHD-ImageWriter**, configure these repository variables with + the exact values from the new project's settings: + - `SIGNPATH_ORGANIZATION_ID` (the existing OpenHD OSS organization ID) + - `SIGNPATH_PROJECT_SLUG` (the new ImageWriter project's slug) + - `SIGNPATH_TEST_SIGNING_POLICY_SLUG` (its test policy) + - `SIGNPATH_RELEASE_SIGNING_POLICY_SLUG` (its release policy) + - `SIGNPATH_TEST_CERTIFICATE_BASE64` (the public test certificate exported + as DER and base64 encoded; never include a private key or PFX) +4. Add repository secret `SIGNPATH_API_TOKEN` for the CI submitter permitted + by the ImageWriter project's signing policies. Never put it in a variable, + source file or workflow input. + +## Test signing and production review + +Run with `test` first. Only this ephemeral GitHub-hosted runner imports the +configured public self-signed test certificate into its current-user trust +stores. Each signed file must have a valid timestamped signature from exactly +that certificate. Test outputs are labelled `-Test-Signed`; they are for +onboarding verification and are not publicly trusted distribution packages. +Do not install this certificate on end-user computers. + +Once integration and origin verification work, send SignPath the successful +workflow/signing request links for their technical review. Their onboarding +email states that they then order and import the production certificate. + +Run with `release` only once its policy is valid. Release builds never import +the test certificate; they require normal Windows trust and timestamping. + +## Signed artifacts + +All compilation and signing run on GitHub-hosted Windows. The application, +locally built FAT32 formatter and generated NSIS uninstaller are signed first. +The installer embeds those files, then the outer installer is signed. Third-party +Qt/OpenSSL/UUU binaries are redistributed as supplied. + +The run publishes `OpenHDImageWriter-Build-Full-Signed` and +`OpenHDImageWriter-Windows-Portable-Signed` for release mode. Test mode publishes +those names with `-Test-Signed` instead; ordinary builds use `-Unsigned`. +The portable archive includes the application and its runtime dependencies. +Missing settings, failed requests, invalid signatures or missing timestamps +fail the build without falling back to unsigned publication as signed. See the [SignPath GitHub integration](https://docs.signpath.io/trusted-build-systems/github) and [artifact configuration syntax](https://docs.signpath.io/artifact-configuration/syntax). diff --git a/scripts/trust_signpath_test_certificate.ps1 b/scripts/trust_signpath_test_certificate.ps1 new file mode 100644 index 000000000..0d67baf21 --- /dev/null +++ b/scripts/trust_signpath_test_certificate.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory = $true)] + [string] $CertificateBase64 +) + +$ErrorActionPreference = 'Stop' +if ($Env:GITHUB_ACTIONS -ne 'true' -or $Env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'Test certificate trust is restricted to ephemeral GitHub-hosted Actions runners' +} +$certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + [Convert]::FromBase64String($CertificateBase64) +) +try { + if ($certificate.HasPrivateKey -or $certificate.Subject -ne $certificate.Issuer) { + throw 'Expected the public self-signed SignPath test certificate' + } + if ($certificate.NotBefore -gt (Get-Date) -or $certificate.NotAfter -lt (Get-Date)) { + throw 'The test certificate is outside its validity period' + } + $eku = $certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } + if (-not $eku -or '1.3.6.1.5.5.7.3.3' -notin @($eku.EnhancedKeyUsages | ForEach-Object { $_.Value })) { + throw 'The test certificate must have the code signing EKU' + } + # Only the ephemeral GitHub-hosted test job trusts this public certificate. + # Release jobs never execute this script and retain normal Windows trust. + foreach ($storeName in @('Root', 'TrustedPublisher')) { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($storeName, 'CurrentUser') + try { + $store.Open('ReadWrite') + $store.Add($certificate) + } finally { + $store.Dispose() + } + } + "SIGNPATH_TEST_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $Env:GITHUB_ENV + Write-Host "Configured onboarding certificate: $($certificate.Thumbprint)" +} finally { + $certificate.Dispose() +} diff --git a/scripts/verify_windows_signature.ps1 b/scripts/verify_windows_signature.ps1 index 4d3463969..cbf7fe759 100644 --- a/scripts/verify_windows_signature.ps1 +++ b/scripts/verify_windows_signature.ps1 @@ -1,11 +1,20 @@ param( [Parameter(Mandatory = $true)] - [string] $Path + [string] $Path, + [ValidateSet('test', 'release')] + [string] $SigningMode = 'release', + [string] $ExpectedSignerThumbprint ) $ErrorActionPreference = 'Stop' +if ($SigningMode -eq 'test' -and [string]::IsNullOrWhiteSpace($ExpectedSignerThumbprint)) { + throw 'Test verification requires the configured test certificate thumbprint' +} $signature = Get-AuthenticodeSignature -LiteralPath $Path if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { throw "Expected a valid, timestamped Authenticode signature on $Path; status: $($signature.Status)" } +if ($SigningMode -eq 'test' -and $signature.SignerCertificate.Thumbprint -ne $ExpectedSignerThumbprint) { + throw "Unexpected test signing certificate on $Path" +} Write-Host "Verified timestamped signature: $Path" From 5988dfea93f9faa163a3a014eaf81bebe45c3e2e Mon Sep 17 00:00:00 2001 From: Raphael <68374617+raphaelscholle@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:37:24 +0200 Subject: [PATCH 3/3] settings: use shared platform catalog and save craft initial configuration --- doc/openhd_settings_map.json | 1307 +++++++++++++++++--- doc/shared-settings.md | 26 + src/ImageOptionsPage.qml | 127 +- src/configure.qml | 57 +- src/imagewriter.cpp | 6 + src/imagewriter.h | 2 + src/openhdimagecustomizer.cpp | 116 +- src/openhdimagecustomizer.h | 1 + src/qml.qrc | 1 + src/qmlcomponents/FleetControlPage.qml | 82 +- src/qmlcomponents/FleetProfilesHelper.js | 68 +- src/qmlcomponents/InitialSettingsPanel.qml | 93 ++ src/test/CMakeLists.txt | 9 + src/test/fleet_profiles_test.js | 25 + src/test/initialsettings_qml_test.cpp | 43 + src/test/openhdbackend_test.cpp | 17 + src/test/settings-test.qrc | 1 + 17 files changed, 1629 insertions(+), 352 deletions(-) create mode 100644 doc/shared-settings.md create mode 100644 src/qmlcomponents/InitialSettingsPanel.qml create mode 100644 src/test/fleet_profiles_test.js create mode 100644 src/test/initialsettings_qml_test.cpp create mode 100644 src/test/settings-test.qrc diff --git a/doc/openhd_settings_map.json b/doc/openhd_settings_map.json index 04a7cef8e..7a3fb816a 100644 --- a/doc/openhd_settings_map.json +++ b/doc/openhd_settings_map.json @@ -28,25 +28,82 @@ "sbc": { "_comment": "Detected SBC marker files written as empty files.", "options": [ - { "id": "rpi", "file": "openhd/rpi.txt" }, - { "id": "rock-5a", "file": "openhd/rock-5a.txt" }, - { "id": "rock-5b", "file": "openhd/rock-5b.txt" }, - { "id": "zero3w", "file": "openhd/zero3w.txt" }, - { "id": "radxa-cm3", "file": "openhd/rock-rk3566.txt" }, - { "id": "radxa-cm5", "file": "openhd/radxa-cm5.txt" }, - { "id": "x20", "file": "openhd/x20.txt" }, - { "id": "x21", "file": "openhd/x21.txt" }, - { "id": "cubie", "file": "openhd/cubie.txt" }, - { "id": "x86", "file": "openhd/x86.txt" }, - { "id": "qcs405", "file": "openhd/qcs405.txt" }, - { "id": "qrb5165", "file": "openhd/qrb5165.txt" }, - { "id": "openipc", "file": "openhd/openipc.txt" }, - { "id": "rockchip-rv", "file": "openhd/rockchip-rv.txt" }, - { "id": "orqa", "file": "openhd/orqa.txt" }, - { "id": "nvidia-xavier", "file": "openhd/nvidia-xavier.txt" }, - { "id": "uvx", "file": "openhd/uvx.txt" }, - { "id": "nxp-imx8", "file": "openhd/nxp-imx8.txt" }, - { "id": "unknown", "file": "openhd/unknown.txt" } + { + "id": "rpi", + "file": "openhd/rpi.txt" + }, + { + "id": "rock-5a", + "file": "openhd/rock-5a.txt" + }, + { + "id": "rock-5b", + "file": "openhd/rock-5b.txt" + }, + { + "id": "zero3w", + "file": "openhd/zero3w.txt" + }, + { + "id": "radxa-cm3", + "file": "openhd/rock-rk3566.txt" + }, + { + "id": "radxa-cm5", + "file": "openhd/radxa-cm5.txt" + }, + { + "id": "x20", + "file": "openhd/x20.txt" + }, + { + "id": "x21", + "file": "openhd/x21.txt" + }, + { + "id": "cubie", + "file": "openhd/cubie.txt" + }, + { + "id": "x86", + "file": "openhd/x86.txt" + }, + { + "id": "qcs405", + "file": "openhd/qcs405.txt" + }, + { + "id": "qrb5165", + "file": "openhd/qrb5165.txt" + }, + { + "id": "openipc", + "file": "openhd/openipc.txt" + }, + { + "id": "rockchip-rv", + "file": "openhd/rockchip-rv.txt" + }, + { + "id": "orqa", + "file": "openhd/orqa.txt" + }, + { + "id": "nvidia-xavier", + "file": "openhd/nvidia-xavier.txt" + }, + { + "id": "uvx", + "file": "openhd/uvx.txt" + }, + { + "id": "nxp-imx8", + "file": "openhd/nxp-imx8.txt" + }, + { + "id": "unknown", + "file": "openhd/unknown.txt" + } ] }, "mode": { @@ -63,28 +120,69 @@ "_comment": "Camera selection depends on SBC and vendor/category shown in the UI.", "valueEncoding": "stringified OpenHD camera type integers written to openhd/settings.json", "secondaryOptions": [ - { "id": "USB", "displayName": "USB", "valueWritten": "10" }, - { "id": "IP-CAMERA", "displayName": "IP CAMERA", "valueWritten": "3" }, - { "id": "TESTPATTERN", "displayName": "DEV CAMERA", "valueWritten": "0" }, - { "id": "INFIRAY", "displayName": "INFIRAY", "valueWritten": "11" }, - { "id": "INFIRAY_T2", "displayName": "INFIRAY T2", "valueWritten": "12" }, - { "id": "INFIRAY_X2", "displayName": "INFIRAY X2", "valueWritten": "13" }, - { "id": "INFIRAY_P2_PRO", "displayName": "INFIRAY P2 PRO", "valueWritten": "14" }, - { "id": "FLIR_VUE", "displayName": "FLIR VUE", "valueWritten": "15" }, - { "id": "FLIR_BOSON", "displayName": "FLIR BOSON", "valueWritten": "16" } + { + "id": "USB", + "displayName": "USB", + "valueWritten": "10" + }, + { + "id": "IP-CAMERA", + "displayName": "IP CAMERA", + "valueWritten": "3" + }, + { + "id": "TESTPATTERN", + "displayName": "DEV CAMERA", + "valueWritten": "0" + }, + { + "id": "INFIRAY", + "displayName": "INFIRAY", + "valueWritten": "11" + }, + { + "id": "INFIRAY_T2", + "displayName": "INFIRAY T2", + "valueWritten": "12" + }, + { + "id": "INFIRAY_X2", + "displayName": "INFIRAY X2", + "valueWritten": "13" + }, + { + "id": "INFIRAY_P2_PRO", + "displayName": "INFIRAY P2 PRO", + "valueWritten": "14" + }, + { + "id": "FLIR_VUE", + "displayName": "FLIR VUE", + "valueWritten": "15" + }, + { + "id": "FLIR_BOSON", + "displayName": "FLIR BOSON", + "valueWritten": "16" + } ], "commonVendors": [ { "id": "network", "displayName": "Network", "options": [ - { "id": "IP-CAMERA", "valueWritten": "3" } + { + "id": "IP-CAMERA", + "valueWritten": "3" + } ] } ], "sbcGroups": [ { - "sbc": ["rpi"], + "sbc": [ + "rpi" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "secondaryCsi": true, @@ -93,52 +191,122 @@ "id": "raspberry", "displayName": "Raspberry", "options": [ - { "id": "HDMI", "valueWritten": "20" }, - { "id": "OV5647", "valueWritten": "30" }, - { "id": "IMX219", "valueWritten": "31" }, - { "id": "IMX708", "valueWritten": "32" }, - { "id": "IMX477", "valueWritten": "33" } + { + "id": "HDMI", + "valueWritten": "20" + }, + { + "id": "OV5647", + "valueWritten": "30" + }, + { + "id": "IMX219", + "valueWritten": "31" + }, + { + "id": "IMX708", + "valueWritten": "32" + }, + { + "id": "IMX477", + "valueWritten": "33" + } ] }, { "id": "arducam", "displayName": "Arducam", "options": [ - { "id": "SkyMasterHDR708", "valueWritten": "40" }, - { "id": "SkyVisionPro519", "valueWritten": "41" }, - { "id": "IMX477m", "valueWritten": "42" }, - { "id": "IMX462", "valueWritten": "43" }, - { "id": "IMX327", "valueWritten": "44" }, - { "id": "IMX290", "valueWritten": "45" }, - { "id": "IMX462MINI", "valueWritten": "46" }, - { "id": "IMX662", "valueWritten": "47" } + { + "id": "SkyMasterHDR708", + "valueWritten": "40" + }, + { + "id": "SkyVisionPro519", + "valueWritten": "41" + }, + { + "id": "IMX477m", + "valueWritten": "42" + }, + { + "id": "IMX462", + "valueWritten": "43" + }, + { + "id": "IMX327", + "valueWritten": "44" + }, + { + "id": "IMX290", + "valueWritten": "45" + }, + { + "id": "IMX462MINI", + "valueWritten": "46" + }, + { + "id": "IMX662", + "valueWritten": "47" + } ] }, { "id": "veye", "displayName": "Veye", "options": [ - { "id": "2MPCAMERAS", "valueWritten": "60" }, - { "id": "CSIMX307", "valueWritten": "61" }, - { "id": "CSSC137", "valueWritten": "62" }, - { "id": "MVCAM", "valueWritten": "63" }, - { "id": "GX/GXC IMX662 (ISP)", "valueWritten": "64" } + { + "id": "2MPCAMERAS", + "valueWritten": "60" + }, + { + "id": "CSIMX307", + "valueWritten": "61" + }, + { + "id": "CSSC137", + "valueWritten": "62" + }, + { + "id": "MVCAM", + "valueWritten": "63" + }, + { + "id": "GX/GXC IMX662 (ISP)", + "valueWritten": "64" + } ] }, { "id": "advanced", "displayName": "Advanced", "options": [ - { "id": "USB", "valueWritten": "10" }, - { "id": "FILESRC", "valueWritten": "4" }, - { "id": "EXTERNAL", "valueWritten": "2" }, - { "id": "TESTPATTERN", "valueWritten": "0" } + { + "id": "USB", + "valueWritten": "10" + }, + { + "id": "FILESRC", + "valueWritten": "4" + }, + { + "id": "EXTERNAL", + "valueWritten": "2" + }, + { + "id": "TESTPATTERN", + "valueWritten": "0" + } ] } ] }, { - "sbc": ["rock-5a", "rock-5b", "radxa-cm5"], + "sbc": [ + "rock-5a", + "rock-5b", + "radxa-cm5" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -146,21 +314,51 @@ "id": "rock-default", "displayName": "Rock5", "options": [ - { "id": "HDMI", "valueWritten": "80" }, - { "id": "OV5647", "valueWritten": "81" }, - { "id": "IMX219", "valueWritten": "82" }, - { "id": "IMX708", "valueWritten": "83" }, - { "id": "IMX462", "valueWritten": "84" }, - { "id": "IMX415", "valueWritten": "85" }, - { "id": "IMX477", "valueWritten": "86" }, - { "id": "IMX519", "valueWritten": "87" }, - { "id": "OHD-JAGUAR", "valueWritten": "88" } + { + "id": "HDMI", + "valueWritten": "80" + }, + { + "id": "OV5647", + "valueWritten": "81" + }, + { + "id": "IMX219", + "valueWritten": "82" + }, + { + "id": "IMX708", + "valueWritten": "83" + }, + { + "id": "IMX462", + "valueWritten": "84" + }, + { + "id": "IMX415", + "valueWritten": "85" + }, + { + "id": "IMX477", + "valueWritten": "86" + }, + { + "id": "IMX519", + "valueWritten": "87" + }, + { + "id": "OHD-JAGUAR", + "valueWritten": "88" + } ] } ] }, { - "sbc": ["zero3w", "radxa-cm3"], + "sbc": [ + "zero3w", + "radxa-cm3" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -168,20 +366,47 @@ "id": "rock3-default", "displayName": "Zero3W", "options": [ - { "id": "HDMI", "valueWritten": "90" }, - { "id": "OV5647", "valueWritten": "91" }, - { "id": "IMX219", "valueWritten": "92" }, - { "id": "IMX708", "valueWritten": "93" }, - { "id": "IMX462", "valueWritten": "94" }, - { "id": "IMX519", "valueWritten": "95" }, - { "id": "OHD-JAGUAR", "valueWritten": "96" }, - { "id": "VEYE", "valueWritten": "97" } + { + "id": "HDMI", + "valueWritten": "90" + }, + { + "id": "OV5647", + "valueWritten": "91" + }, + { + "id": "IMX219", + "valueWritten": "92" + }, + { + "id": "IMX708", + "valueWritten": "93" + }, + { + "id": "IMX462", + "valueWritten": "94" + }, + { + "id": "IMX519", + "valueWritten": "95" + }, + { + "id": "OHD-JAGUAR", + "valueWritten": "96" + }, + { + "id": "VEYE", + "valueWritten": "97" + } ] } ] }, { - "sbc": ["x86", "unknown"], + "sbc": [ + "x86", + "unknown" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -189,16 +414,30 @@ "id": "generic", "displayName": "Generic", "options": [ - { "id": "USB", "valueWritten": "10" }, - { "id": "EXTERNAL", "valueWritten": "2" }, - { "id": "FILESRC", "valueWritten": "4" }, - { "id": "TESTPATTERN", "valueWritten": "0" } + { + "id": "USB", + "valueWritten": "10" + }, + { + "id": "EXTERNAL", + "valueWritten": "2" + }, + { + "id": "FILESRC", + "valueWritten": "4" + }, + { + "id": "TESTPATTERN", + "valueWritten": "0" + } ] } ] }, { - "sbc": ["x20"], + "sbc": [ + "x20" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -206,18 +445,38 @@ "id": "x20", "displayName": "X20", "options": [ - { "id": "HDZERO", "valueWritten": "70" }, - { "id": "RUNCAM_V1", "valueWritten": "71" }, - { "id": "RUNCAM_V2", "valueWritten": "72" }, - { "id": "RUNCAM_V3", "valueWritten": "73" }, - { "id": "RUNCAM_NANO_90", "valueWritten": "74" }, - { "id": "OHD-JAGUAR", "valueWritten": "75" } + { + "id": "HDZERO", + "valueWritten": "70" + }, + { + "id": "RUNCAM_V1", + "valueWritten": "71" + }, + { + "id": "RUNCAM_V2", + "valueWritten": "72" + }, + { + "id": "RUNCAM_V3", + "valueWritten": "73" + }, + { + "id": "RUNCAM_NANO_90", + "valueWritten": "74" + }, + { + "id": "OHD-JAGUAR", + "valueWritten": "75" + } ] } ] }, { - "sbc": ["x21"], + "sbc": [ + "x21" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -225,13 +484,19 @@ "id": "x21", "displayName": "X21", "options": [ - { "id": "OHD-JAGUAR-X21", "valueWritten": "76" } + { + "id": "OHD-JAGUAR-X21", + "valueWritten": "76" + } ] } ] }, { - "sbc": ["qcs405", "qrb5165"], + "sbc": [ + "qcs405", + "qrb5165" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -239,14 +504,22 @@ "id": "qualcomm", "displayName": "Qualcomm", "options": [ - { "id": "IMX577", "valueWritten": "120" }, - { "id": "OV9282", "valueWritten": "121" } + { + "id": "IMX577", + "valueWritten": "120" + }, + { + "id": "OV9282", + "valueWritten": "121" + } ] } ] }, { - "sbc": ["openipc"], + "sbc": [ + "openipc" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -254,13 +527,18 @@ "id": "integrated", "displayName": "Integrated camera", "options": [ - { "id": "OPENIPC", "valueWritten": "110" } + { + "id": "OPENIPC", + "valueWritten": "110" + } ] } ] }, { - "sbc": ["orqa"], + "sbc": [ + "orqa" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -268,15 +546,26 @@ "id": "orqa", "displayName": "Orqa", "options": [ - { "id": "ORQA-HORNET", "valueWritten": "122" }, - { "id": "ORQA-JAGUAR", "valueWritten": "123" }, - { "id": "ORQA-REKINDLE", "valueWritten": "124" } + { + "id": "ORQA-HORNET", + "valueWritten": "122" + }, + { + "id": "ORQA-JAGUAR", + "valueWritten": "123" + }, + { + "id": "ORQA-REKINDLE", + "valueWritten": "124" + } ] } ] }, { - "sbc": ["rockchip-rv"], + "sbc": [ + "rockchip-rv" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -284,13 +573,18 @@ "id": "integrated", "displayName": "Integrated camera", "options": [ - { "id": "ROCKCHIP-RV", "valueWritten": "125" } + { + "id": "ROCKCHIP-RV", + "valueWritten": "125" + } ] } ] }, { - "sbc": ["nvidia-xavier"], + "sbc": [ + "nvidia-xavier" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [ @@ -298,13 +592,20 @@ "id": "nvidia", "displayName": "Nvidia", "options": [ - { "id": "XAVIER-IMX577", "valueWritten": "101" } + { + "id": "XAVIER-IMX577", + "valueWritten": "101" + } ] } ] }, { - "sbc": ["cubie", "uvx", "nxp-imx8"], + "sbc": [ + "cubie", + "uvx", + "nxp-imx8" + ], "bootTypeRequired": "Air", "file": "openhd/camera1.txt", "vendors": [] @@ -315,62 +616,265 @@ "_comment": "Resolution/fps choices keyed by OpenHD camera type value (widthxheight@fps).", "valueEncoding": "string written to openhd/settings.json as camera_resolution_fps and camera2_resolution_fps", "byCameraValue": { - "0": ["640x480@30", "1280x720@30", "1280x720@60"], - "4": ["848x480@60", "1280x720@60", "1920x1080@60"], - "10": ["640x480@30", "0x0@0"], - "11": ["384x292@25"], - "12": ["256x192@25", "0x0@0"], - "13": ["384x292@50"], - "14": ["256x192@25"], - "15": ["640x512@30"], - "16": ["640x512@60"], - "20": ["1280x720@30", "1920x1080@25", "1280x720@60"], - "30": ["1280x720@30", "1920x1080@30"], - "31": ["640x480@47", "896x504@47", "1280x720@47", "1920x1080@30"], - "32": ["640x480@60", "896x504@60", "1280x720@60", "1920x1080@30"], - "33": ["640x480@50", "896x504@50", "1280x720@50", "1920x1080@30"], - "40": ["640x480@60", "896x504@60", "1280x720@60", "1920x1080@30"], - "41": ["640x480@60", "896x504@60", "1280x720@60", "1920x1080@30"], - "42": ["640x480@50", "896x504@50", "1280x720@50", "1920x1080@30"], - "43": ["640x480@60", "896x504@30", "896x504@60", "1280x720@30", "1280x720@60", "1920x1080@30"], - "44": ["640x480@60", "896x504@60", "1280x720@60", "1920x1080@30"], - "46": ["640x480@60", "896x504@30", "896x504@60", "1280x720@30", "1280x720@60", "1920x1080@30"], - "47": ["1280x720@60", "1920x1080@30"], - "60": ["1920x1080@30"], - "61": ["1280x720@60", "1920x1080@30"], - "62": ["1920x1080@30"], - "63": ["1920x1080@30"], - "70": ["1280x720@60"], - "71": ["1280x720@60"], - "72": ["1280x720@60"], - "73": ["1280x720@60"], - "74": ["1280x720@60"], - "75": ["1280x720@60"], - "76": ["1280x720@60"], - "80": ["1280x720@30", "1280x720@60", "1920x1080@30", "1920x1080@60", "3840x2160@30", "3840x2160@60"], - "81": ["640x480@60", "1280x720@30", "1920x1080@30"], - "82": ["640x480@30", "848x480@30", "1280x720@30", "1920x1080@30"], - "83": ["1280x720@30", "1280x720@60", "1920x1080@60", "4608x2592@14"], - "84": ["1280x720@30", "1280x720@60", "1920x1080@30", "1920x1080@60"], - "85": ["1280x720@30", "1280x720@30", "1920x1080@30", "3864x2192@30"], - "86": ["1280x720@60", "1920x1080@60", "3840x2160@20", "4056x3040@10"], - "87": ["1280x720@60", "1920x1080@60", "3840x2160@20", "4056x3040@9"], - "88": ["1280x720@120", "1920x1080@100", "3840x2160@90"], - "90": ["1280x720@30", "1280x720@60"], - "91": ["640x480@60", "1280x720@30", "1920x1080@30"], - "92": ["640x480@30", "848x480@30", "1280x720@30", "1920x1080@30"], - "93": ["1280x720@30", "1280x720@60", "1920x1080@60"], - "94": ["1280x720@60", "1280x720@30", "1920x1080@30", "1920x1080@60"], - "95": ["1280x720@60", "1280x720@30", "1920x1080@30", "1920x1080@60"], - "96": ["1280x720@60", "1280x720@120", "1920x1080@60", "1920x1080@100"], - "101": ["1280x720@60", "1920x1080@60"], - "110": ["1280x720@60"], - "120": ["1280x720@30", "1920x1080@30"], - "121": ["1280x720@30"], - "122": ["960x720@120"], - "123": ["1280x720@120"], - "124": ["1280x720@60"], - "125": ["1280x720@60", "1296x968@60", "1920x1080@25", "2592x1944@25"] + "0": [ + "640x480@30", + "1280x720@30", + "1280x720@60" + ], + "4": [ + "848x480@60", + "1280x720@60", + "1920x1080@60" + ], + "10": [ + "640x480@30", + "0x0@0" + ], + "11": [ + "384x292@25" + ], + "12": [ + "256x192@25", + "0x0@0" + ], + "13": [ + "384x292@50" + ], + "14": [ + "256x192@25" + ], + "15": [ + "640x512@30" + ], + "16": [ + "640x512@60" + ], + "20": [ + "1280x720@30", + "1920x1080@25", + "1280x720@60" + ], + "30": [ + "1280x720@30", + "1920x1080@30" + ], + "31": [ + "640x480@47", + "896x504@47", + "1280x720@47", + "1920x1080@30" + ], + "32": [ + "640x480@60", + "896x504@60", + "1280x720@60", + "1920x1080@30" + ], + "33": [ + "640x480@50", + "896x504@50", + "1280x720@50", + "1920x1080@30" + ], + "40": [ + "640x480@60", + "896x504@60", + "1280x720@60", + "1920x1080@30" + ], + "41": [ + "640x480@60", + "896x504@60", + "1280x720@60", + "1920x1080@30" + ], + "42": [ + "640x480@50", + "896x504@50", + "1280x720@50", + "1920x1080@30" + ], + "43": [ + "640x480@60", + "896x504@30", + "896x504@60", + "1280x720@30", + "1280x720@60", + "1920x1080@30" + ], + "44": [ + "640x480@60", + "896x504@60", + "1280x720@60", + "1920x1080@30" + ], + "46": [ + "640x480@60", + "896x504@30", + "896x504@60", + "1280x720@30", + "1280x720@60", + "1920x1080@30" + ], + "47": [ + "1280x720@60", + "1920x1080@30" + ], + "60": [ + "1920x1080@30" + ], + "61": [ + "1280x720@60", + "1920x1080@30" + ], + "62": [ + "1920x1080@30" + ], + "63": [ + "1920x1080@30" + ], + "70": [ + "1280x720@60" + ], + "71": [ + "1280x720@60" + ], + "72": [ + "1280x720@60" + ], + "73": [ + "1280x720@60" + ], + "74": [ + "1280x720@60" + ], + "75": [ + "1280x720@60" + ], + "76": [ + "1280x720@60" + ], + "80": [ + "1280x720@30", + "1280x720@60", + "1920x1080@30", + "1920x1080@60", + "3840x2160@30", + "3840x2160@60" + ], + "81": [ + "640x480@60", + "1280x720@30", + "1920x1080@30" + ], + "82": [ + "640x480@30", + "848x480@30", + "1280x720@30", + "1920x1080@30" + ], + "83": [ + "1280x720@30", + "1280x720@60", + "1920x1080@60", + "4608x2592@14" + ], + "84": [ + "1280x720@30", + "1280x720@60", + "1920x1080@30", + "1920x1080@60" + ], + "85": [ + "1280x720@30", + "1280x720@30", + "1920x1080@30", + "3864x2192@30" + ], + "86": [ + "1280x720@60", + "1920x1080@60", + "3840x2160@20", + "4056x3040@10" + ], + "87": [ + "1280x720@60", + "1920x1080@60", + "3840x2160@20", + "4056x3040@9" + ], + "88": [ + "1280x720@120", + "1920x1080@100", + "3840x2160@90" + ], + "90": [ + "1280x720@30", + "1280x720@60" + ], + "91": [ + "640x480@60", + "1280x720@30", + "1920x1080@30" + ], + "92": [ + "640x480@30", + "848x480@30", + "1280x720@30", + "1920x1080@30" + ], + "93": [ + "1280x720@30", + "1280x720@60", + "1920x1080@60" + ], + "94": [ + "1280x720@60", + "1280x720@30", + "1920x1080@30", + "1920x1080@60" + ], + "95": [ + "1280x720@60", + "1280x720@30", + "1920x1080@30", + "1920x1080@60" + ], + "96": [ + "1280x720@60", + "1280x720@120", + "1920x1080@60", + "1920x1080@100" + ], + "101": [ + "1280x720@60", + "1920x1080@60" + ], + "110": [ + "1280x720@60" + ], + "120": [ + "1280x720@30", + "1920x1080@30" + ], + "121": [ + "1280x720@30" + ], + "122": [ + "960x720@120" + ], + "123": [ + "1280x720@120" + ], + "124": [ + "1280x720@60" + ], + "125": [ + "1280x720@60", + "1296x968@60", + "1920x1080@25", + "2592x1944@25" + ] } }, "ipCamera": { @@ -382,14 +886,20 @@ "reservedBitrateKey": "ip_camera_bitrate_mbits", "defaultAddress": "192.168.144.108", "defaultPipeline": "rtspsrc location=rtsp://{IP}:554/stream=0 latency=0 ! rtph264depay", - "reservedBitrateRangeMbits": [1, 20], + "reservedBitrateRangeMbits": [ + 1, + 20 + ], "defaultReservedBitrateMbits": 2 }, "rpi5CameraConnectors": { "_comment": "For Raspberry Pi CSI cameras, ImageWriter asks which Pi 5 connector is used. Sysutils applies ,cam0 for CAM0; CAM1 uses the overlay default.", "primaryKey": "camera_port", "secondaryKey": "camera2_port", - "options": ["cam0", "cam1"], + "options": [ + "cam0", + "cam1" + ], "primaryDefault": "cam1", "secondaryDefault": "cam0" }, @@ -401,9 +911,18 @@ "refreshKey": "display_refresh_hz", "connectorKey": "display_connector", "defaultConnector": "HDMI-A-1", - "widthRange": [320, 7680], - "heightRange": [240, 4320], - "refreshRangeHz": [20, 240] + "widthRange": [ + 320, + 7680 + ], + "heightRange": [ + 240, + 4320 + ], + "refreshRangeHz": [ + 20, + 240 + ] }, "qopenhdConf": { "_comment": "Optional full configuration file copied verbatim when provided by the user.", @@ -428,5 +947,471 @@ "eject": { "_comment": "Stored in settings but not written to FAT32 partition.", "options": [] - } + }, + "platforms": [ + { + "id": "x86", + "label": "PC / x86_64", + "platformId": 1, + "sbc": "x86", + "aliases": [ + "PC / x86_64 Ground Station", + "x86_64" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi3", + "label": "Raspberry Pi 3", + "platformId": 10, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi4", + "label": "Raspberry Pi 4", + "platformId": 11, + "sbc": "rpi", + "aliases": [ + "raspberry-pi-4" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "cm4", + "label": "Raspberry Pi CM4", + "platformId": 12, + "sbc": "rpi", + "aliases": [ + "raspberry-pi-cm4" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi5", + "label": "Raspberry Pi 5", + "platformId": 13, + "sbc": "rpi", + "aliases": [ + "raspberry-pi-5" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "zero3w", + "label": "Radxa Zero 3W", + "platformId": 20, + "sbc": "zero3w", + "aliases": [ + "radxa-zero3w", + "radxa-zero-3w" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rock5a", + "label": "Radxa Rock 5A", + "platformId": 21, + "sbc": "rock-5a", + "aliases": [ + "radxa-rock5a", + "rock-5a" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rock5b", + "label": "Radxa Rock 5B", + "platformId": 22, + "sbc": "rock-5b", + "aliases": [ + "radxa-rock5b", + "rock-5b" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "cm5", + "label": "Radxa CM5", + "platformId": 23, + "sbc": "radxa-cm5", + "aliases": [ + "radxa-cm5" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "cm3", + "label": "Radxa CM3", + "platformId": 24, + "sbc": "radxa-cm3", + "aliases": [ + "radxa-cm3" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "luckfox-pico", + "label": "Luckfox Pico / RV110X", + "platformId": 25, + "sbc": "rockchip-rv", + "aliases": [ + "rockchip-rv", + "rv110x", + "luckfox-pico" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "luckfox-lyra", + "label": "Luckfox Lyra", + "platformId": 27, + "sbc": "rockchip-rv", + "aliases": [ + "luckfox-lyra" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "x21b", + "label": "OpenHD Core X21", + "platformId": 28, + "sbc": "x21", + "aliases": [ + "x21", + "OpenHD X21" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "luckfox-aura", + "label": "Luckfox Aura", + "platformId": 29, + "sbc": "rockchip-rv", + "aliases": [ + "luckfox-aura" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "x20", + "label": "OpenHD Core X20", + "platformId": 30, + "sbc": "x20", + "aliases": [ + "OpenHD X20", + "x20" + ], + "roles": [ + "air" + ] + }, + { + "id": "cubie-a7s", + "label": "Radxa Cubie A7S", + "platformId": 31, + "sbc": "cubie", + "aliases": [ + "cubie-a7s" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "cubie-a7z", + "label": "Radxa Cubie A7Z", + "platformId": 32, + "sbc": "cubie", + "aliases": [ + "cubie-a7z" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "openipc", + "label": "OpenIPC SigmaStar", + "platformId": 36, + "sbc": "openipc", + "aliases": [ + "openipc" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "nvidia-xavier", + "label": "NVIDIA Xavier", + "platformId": 40, + "sbc": "nvidia-xavier", + "aliases": [ + "nvidia-xavier" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "qrb5165", + "label": "Qualcomm QRB5165", + "platformId": 46, + "sbc": "qrb5165", + "aliases": [ + "qrb5165" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "qcs405", + "label": "Qualcomm QCS405", + "platformId": 47, + "sbc": "qcs405", + "aliases": [ + "qcs405" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "orqa", + "label": "Orqa", + "platformId": 51, + "sbc": "orqa", + "aliases": [ + "orqa" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "uvx", + "label": "UVX Mod", + "platformId": 52, + "sbc": "uvx", + "aliases": [ + "uvx" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "nxp-imx8", + "label": "NXP i.MX8", + "platformId": 61, + "sbc": "nxp-imx8", + "aliases": [ + "nxp-imx8" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi-zero", + "label": "Raspberry Pi Zero", + "platformId": 10, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi-zero-w", + "label": "Raspberry Pi Zero W", + "platformId": 10, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi-zero2w", + "label": "Raspberry Pi Zero 2W", + "platformId": 10, + "sbc": "rpi", + "aliases": [ + "raspberry-pi-zero2w" + ], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi2", + "label": "Raspberry Pi 2", + "platformId": 10, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi-cm3", + "label": "Raspberry Pi CM3", + "platformId": 10, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rpi-cm5", + "label": "Raspberry Pi CM5", + "platformId": 13, + "sbc": "rpi", + "aliases": [], + "roles": [ + "air", + "ground" + ] + }, + { + "id": "rock3", + "label": "Radxa Rock 3", + "platformId": 20, + "sbc": "zero3w", + "aliases": [ + "radxa-rock3" + ], + "roles": [ + "air", + "ground" + ] + } + ], + "_settingsContract": { + "version": 1, + "catalogHash": "6171a437338e86373c8ccd5372172af5e2609ab2351856803ca7a98668303e15" + }, + "initialFields": [ + { + "choices": [ + { + "label": "DISABLE", + "value": 0 + }, + { + "label": "ENABLE", + "value": 1 + }, + { + "label": "AUTO(armed)", + "value": 2 + } + ], + "description": "Record video data locally on your air unit. You can find the files under /home/openhd/Videos on the SD card and/or download them via the web ui.When AUTO is set, air recording automatically starts (and stops) when you arm/disarm your drone (requires inav / ardupilot FC).", + "group": "General", + "hidden": false, + "id": "AIR_RECORDING_E", + "label": "Air recording", + "maximum": 2, + "minimum": 0, + "readOnly": false, + "requiresReboot": false, + "type": "int32", + "provision": { + "componentId": 100, + "roles": [ + "air" + ] + } + }, + { + "choices": [ + { + "label": "Disable", + "value": 0 + }, + { + "label": "Enable", + "value": 1 + } + ], + "description": "Enable / disable streaming for this camera. Note that this setting is persistent at run time - once you disable streaming for a camera, you won't have video until you re-enable streaming or reboot your air unit. On by default", + "group": "General", + "hidden": true, + "id": "STREAMING_E", + "label": "Video streaming", + "maximum": 1, + "minimum": 0, + "readOnly": false, + "requiresReboot": false, + "type": "int32", + "provision": { + "componentId": 100, + "roles": [ + "air" + ] + } + } + ] } diff --git a/doc/shared-settings.md b/doc/shared-settings.md new file mode 100644 index 000000000..6804e7856 --- /dev/null +++ b/doc/shared-settings.md @@ -0,0 +1,26 @@ +# Initial configuration and FleetControl + +Select a craft in FleetControl, configure its image options, then use **Save +configuration** to save a version-1 initial snapshot on that craft. **Export JSON** +saves the initial `settings.json` locally. The snapshot uses the image-writing +builder and excludes FleetControl login credentials and enrollment tokens. + +Selecting the craft again restores the editable settings and typed +`parameter_defaults`. A profile without a plan clears the previous craft's plan. +Open initial-default controls follow profile changes; Ground removes camera +component defaults. OpenHD seeds new persistent owners only, preserving existing +device preferences. + +Hardware selectors use generated platform IDs and labels from OpenHD's +`settings/platforms.json`. The online craft editor refreshes the platform catalog +from `/api/imagewriter/platforms`; offline use retains the bundled list. Combined +legacy board names require a concrete selection. Image availability and flashing +transport still depend on the selected image. + +FleetControl's Hangar editor shows and downloads the captured initial file. +Ordinary craft edits preserve that snapshot until it is explicitly saved again. +The full contract is documented in OpenHD's `OpenHD/settings/README.md`. + +Tests: `node src/test/fleet_profiles_test.js`, `node src/test/catalog_test.js`, +`node src/test/fleetcontrol_catalog_test.js`, and the CMake targets +`openhd_backend_tests` / `initialsettings_qml_test` under `src/test`. diff --git a/src/ImageOptionsPage.qml b/src/ImageOptionsPage.qml index c51ade2ac..a53308064 100644 --- a/src/ImageOptionsPage.qml +++ b/src/ImageOptionsPage.qml @@ -11,6 +11,7 @@ import Qt.labs.settings 1.0 import QtQuick.Dialogs 1.3 import "qmlcomponents" import "qmlcomponents/SettingsLabels.js" as SettingsLabels +import "qmlcomponents/FleetProfilesHelper.js" as FleetProfilesHelper Rectangle { id: page @@ -29,10 +30,14 @@ Rectangle { property var settingsMap: ({}) property bool settingsMapLoaded: false + onVisibleChanged: { + if (visible && settingsMapLoaded) settingsMap = JSON.parse(JSON.stringify(FleetProfilesHelper.getSettingsMap(imageWriter))) + } // refactored settings property string bootType property string fileName + property string platform: imageWriter.getValue("platform") || "" property string sbc property string camera property string camera2 @@ -165,6 +170,33 @@ Rectangle { width: deviceRoleCard.availableWidth spacing: 10 + Label { text: qsTr("Hardware platform"); Layout.fillWidth: true } + ModernComboBox { + Layout.fillWidth: true + model: settingsMap.platforms || [] + textRole: "label" + currentIndex: { + for (var n=0; n= 0 ? currentText : qsTr("Choose hardware platform") + onActivated: { + var board = model[currentIndex] + page.platform = board.id + sbc = board.sbc + imageWriter.setSetting("platform", board.id) + imageWriter.setSetting("sbc", board.sbc) + if (board.roles.indexOf(bootType.toLowerCase()) < 0) bootType = board.roles[0] === "air" ? "Air" : "Ground" + rpi = sbc === "rpi"; rock5 = sbc === "rock-5a" || sbc === "rock-5b" || sbc === "radxa-cm5"; rock3 = sbc === "zero3w" || sbc === "radxa-cm3"; page.refreshCameraOptions() + } + } + InitialSettingsPanel { + id: initialDefaults + Layout.fillWidth: true + settingsOwner: imageWriter + fields: settingsMap.initialFields || [] + role: String(bootType).toLowerCase() + } ModernComboBox { id: bootTypeSelector Layout.fillWidth: true @@ -1074,86 +1106,15 @@ Rectangle { else if (!supportsAir) bootType = "Ground" - if (normalizedPlatform.indexOf("raspberry-pi") === 0) { - imageWriter.setSetting("sbc", "rpi"); - sbc = "rpi" - rpi=true; - rock5=false; - rock3=false; - } - else if (normalizedFileName.includes("rock5a") || normalizedFileName.includes("rock-5a")) { - imageWriter.setSetting("sbc", "rock-5a"); - sbc = "rock-5a" - rpi=false; rock5=true; rock3=false; - } - else if (normalizedFileName.includes("rock5b") || normalizedFileName.includes("rock-5b")) { - imageWriter.setSetting("sbc", "rock-5b"); - sbc = "rock-5b" - rpi=false; rock5=true; rock3=false; - } - else if (normalizedFileName.includes("zero3") || normalizedFileName.includes("zero-3")) { - imageWriter.setSetting("sbc", "zero3w"); - sbc = "zero3w" - rpi=false; rock5=false; rock3=true; - } - else if (normalizedFileName.includes("radxa-cm5") || normalizedFileName.includes("radxa_cm5")) { - sbc = "radxa-cm5"; rpi=false; rock5=true; rock3=false; - } - else if (normalizedFileName.includes("radxa-cm3") || normalizedFileName.includes("radxa_cm3")) { - imageWriter.setSetting("sbc", "radxa-cm3"); - sbc = "radxa-cm3" - rpi=false; rock5=false; rock3=true; - } - else if (normalizedFileName.includes("qrb5165")) { - sbc = "qrb5165"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("qcs405")) { - sbc = "qcs405"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("openipc")) { - sbc = "openipc"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("rv1103") || normalizedFileName.includes("rv1106") || normalizedFileName.includes("rv1126") || normalizedFileName.includes("luckfox")) { - sbc = "rockchip-rv"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("orqa")) { - sbc = "orqa"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("x20")) { - sbc = "x20"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("x21")) { - sbc = "x21"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("cubie") || normalizedFileName.includes("a733")) { - sbc = "cubie"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("xavier") || normalizedFileName.includes("jetson")) { - sbc = "nvidia-xavier"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("uvx") || normalizedFileName.includes("uvx-mod")) { - sbc = "uvx"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("imx8") || normalizedFileName.includes("nxp")) { - sbc = "nxp-imx8"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("x86") || normalizedFileName.includes("amd64")) { - sbc = "x86"; rpi=false; rock5=false; rock3=false; - } - else if (normalizedFileName.includes("raspberry") || normalizedFileName.includes("rpi") || normalizedFileName.includes("image-pi") || normalizedFileName.includes("image_pi")) { - imageWriter.setSetting("sbc", "rpi"); - sbc = "rpi" - rpi=true; - rock5=false; - rock3=false; - } - else{ - imageWriter.setSetting("sbc", "unknown"); - sbc = "unknown" - rpi=false; - rock5=false; - rock3=false; - } + FleetProfilesHelper.getSettingsMap(imageWriter) + var board = FleetProfilesHelper.platformForHint(normalizedPlatform) || FleetProfilesHelper.platformForHint(normalizedFileName) || + FleetProfilesHelper.platformForHardware(imageWriter.getValue("platform")) + platform = board ? board.id : "" + sbc = board ? board.sbc : "unknown" + rpi = sbc === "rpi" + rock5 = sbc === "rock-5a" || sbc === "rock-5b" || sbc === "radxa-cm5" + rock3 = sbc === "zero3w" || sbc === "radxa-cm3" + imageWriter.setSetting("platform", platform) imageWriter.setSetting("sbc", sbc) imageWriter.setSetting("bootType", bootType) @@ -1241,8 +1202,10 @@ Rectangle { function availableBootOptions() { if (!settingsMap.bootType || !settingsMap.bootType.options) return [] + var board = FleetProfilesHelper.platformForHardware(platform) return settingsMap.bootType.options.filter(function(option) { return option.id && option.id.length > 0 && + (!board || board.roles.indexOf(option.id.toLowerCase()) >= 0) && ((option.id === "Air" && supportsAir) || (option.id === "Ground" && supportsGround)) }) @@ -1269,6 +1232,8 @@ Rectangle { } } + imageWriter.setSetting("platform", platform) + imageWriter.setSetting("initialParameterDefaults", JSON.stringify(initialDefaults.exportDefaults())) imageWriter.setSetting("bootType", bootType) imageWriter.setSetting("camera", camera) imageWriter.setSetting("camera2", camera2) @@ -1323,7 +1288,7 @@ Rectangle { return try { - settingsMap = JSON.parse(imageWriter.readResourceText(":/doc/openhd_settings_map.json")) + settingsMap = FleetProfilesHelper.getSettingsMap(imageWriter) settingsMapLoaded = true } catch (e) { console.log("Failed to load OpenHD settings map: " + e) diff --git a/src/configure.qml b/src/configure.qml index 0454bb56a..c393f1df9 100644 --- a/src/configure.qml +++ b/src/configure.qml @@ -35,7 +35,10 @@ Rectangle { // Settings map and values shared with the image configuration page. property var settingsMap: ({}) property bool settingsMapLoaded: false + property string settingsMapSourceRoot: "" + property bool hasImageSettingsManifest: false property string bootType: "" + property string platform: imageWriter.getValue("platform") || "" property string sbc: "" property string camera: "" property string camera2: "" @@ -66,6 +69,11 @@ Rectangle { property bool returnHomeAfterPopupClose: false property string language: "" property string token: "" + function platformSupportsRole(role) { + var boards = settingsMap.platforms || [] + for (var n=0; n= 0 + return true + } Component.onCompleted: { qopenhdConfPath = normalizeLocalFilePath(imageWriter.getValue("qopenhdConfPath")) @@ -376,9 +384,36 @@ ImButton { } ColumnLayout { id: settingsBody + InitialSettingsPanel { + id: initialDefaults + settingsOwner: imageWriter + Layout.fillWidth: true + fields: hasImageSettingsManifest ? (settingsMap.initialFields || []) : [] + role: bootType.toLowerCase() + } width: settingsScroll.width - settingsScroll.leftPadding - settingsScroll.rightPadding spacing: 16 + Label { text: qsTr("Hardware platform"); Layout.fillWidth: true } + ModernComboBox { + Layout.fillWidth: true + model: settingsMap.platforms || [] + textRole: "label" + currentIndex: { + for (var n=0; n= 0 ? currentText : qsTr("Choose hardware platform") + onActivated: { + var board = model[currentIndex] + window.platform = board.id + sbc = board.sbc + imageWriter.setSetting("platform", board.id) + imageWriter.setSetting("sbc", board.sbc) + if (board.roles.indexOf(bootType.toLowerCase()) < 0) bootType = board.roles[0] === "air" ? "Air" : "Ground" + rebuildCameraSelectors() + } + } SettingsSection { title: qsTr("Boot Mode") Layout.fillWidth: true @@ -386,7 +421,7 @@ ImButton { ColumnLayout { spacing: 0 Repeater { - model: settingsMap.bootType && settingsMap.bootType.options ? settingsMap.bootType.options.filter(function(option) { return option.id && option.id.length > 0 }) : [] + model: settingsMap.bootType && settingsMap.bootType.options ? settingsMap.bootType.options.filter(function(option) { return option.id && option.id.length > 0 && platformSupportsRole(option.id) }) : [] delegate: ImCheckBox { property var option: modelData text: option ? SettingsLabels.translated(option.id) : "" @@ -576,7 +611,7 @@ ImButton { } var targetIndex = 0 - if (cameraResolution && cameraResolution.length > 0) { + if (cameraResolution && cameraResolution.length > 0) { for (var idx = 0; idx < cameraResolutionOptionsModel.count; idx++) { if (cameraResolutionOptionsModel.get(idx).value === cameraResolution) { targetIndex = idx @@ -1299,11 +1334,19 @@ ImButton { } function loadSettingsMap() { - if (settingsMapLoaded) + if (settingsMapLoaded && settingsMapSourceRoot === openhdRoot) return try { - settingsMap = JSON.parse(imageWriter.readResourceText(":/doc/openhd_settings_map.json")) + var manifestText = openhdRoot ? imageWriter.readTextFile(drivePath("settings-manifest.json")) : "" + hasImageSettingsManifest = manifestText.length > 0 + settingsMap = JSON.parse(hasImageSettingsManifest ? manifestText : imageWriter.readResourceText(":/doc/openhd_settings_map.json")) + if (hasImageSettingsManifest && (!settingsMap._settingsContract || settingsMap._settingsContract.version !== 1)) { + settingsMap = {} + hasImageSettingsManifest = false + throw new Error("Unsupported image settings manifest") + } + settingsMapSourceRoot = openhdRoot settingsMapLoaded = true } catch (e) { console.log("[Configure] Failed to load OpenHD settings map: " + e) @@ -1470,9 +1513,11 @@ ImButton { var settingsJson = imageWriter.readTextFile(drivePath("settings.json")) var settingsObj = {} + if (platform) settingsObj.platform = platform if (settingsJson && settingsJson.length > 0) { try { settingsObj = JSON.parse(settingsJson) + initialDefaults.defaults = settingsObj.parameter_defaults || {version: 1, components: {}} } catch (e) { console.log("[Configure] Error parsing settings.json:", e) } @@ -1491,6 +1536,7 @@ ImButton { } else if (settingsMap.sbc && settingsMap.sbc.options && settingsMap.sbc.options.length > 0) { sbc = settingsMap.sbc.options[0].id } + platform = settingsObj.platform || "" if (settingsObj.debug) { mode = "debug" @@ -1597,6 +1643,7 @@ ImButton { console.log("[Configure] Writing settings to", openhdRoot) var settingsObj = {} + settingsObj.parameter_defaults = initialDefaults.exportDefaults() if (bootType === "Air") { settingsObj.role = "air" } else if (bootType === "Ground") { @@ -1709,11 +1756,13 @@ ImButton { premiumCertificatePresent = imageWriter.fileExists(drivePath(premiumCertificateRelativePath())) } + imageWriter.setSetting("platform", platform) imageWriter.setSetting("bootType", bootType) imageWriter.setSetting("sbc", sbc) imageWriter.setSetting("camera", camera) imageWriter.setSetting("camera2", camera2) imageWriter.setSetting("cameraResolution", cameraResolution) + imageWriter.setSetting("initialParameterDefaults", JSON.stringify(initialDefaults.exportDefaults())) imageWriter.setSetting("camera2Resolution", camera2Resolution) imageWriter.setSetting("cameraPort", cameraPort) imageWriter.setSetting("camera2Port", camera2Port) diff --git a/src/imagewriter.cpp b/src/imagewriter.cpp index c35189a6c..dcf94ae91 100644 --- a/src/imagewriter.cpp +++ b/src/imagewriter.cpp @@ -1635,9 +1635,15 @@ void ImageWriter::setSetting(const QString &key, const QVariant &value) _settings.setValue(key, value); _settings.sync(); + emit settingChanged(key, value); } +QVariantMap ImageWriter::initialConfiguration() const +{ + return OpenHDImageCustomizer::initialConfiguration(_settings).toVariantMap(); +} + QString ImageWriter::crypt(const QByteArray &password) { QByteArray salt = "$5$"; diff --git a/src/imagewriter.h b/src/imagewriter.h index 8631aa5fa..d5c441562 100644 --- a/src/imagewriter.h +++ b/src/imagewriter.h @@ -166,6 +166,7 @@ class ImageWriter : public QObject Q_INVOKABLE bool getBoolSetting(const QString &key); Q_INVOKABLE QString getValue(const QString &key); Q_INVOKABLE void setSetting(const QString &key, const QVariant &value); + Q_INVOKABLE QVariantMap initialConfiguration() const; Q_INVOKABLE QVariantMap getSavedCustomizationSettings(); Q_INVOKABLE void clearSavedCustomizationSettings(); Q_INVOKABLE bool hasSavedCustomizationSettings(); @@ -190,6 +191,7 @@ class ImageWriter : public QObject Q_INVOKABLE bool hasMouse(); signals: + void settingChanged(const QString &key, const QVariant &value); /* We are emiting signals with QVariant as parameters because QML likes it that way */ void downloadProgress(QVariant dlnow, QVariant dltotal); diff --git a/src/openhdimagecustomizer.cpp b/src/openhdimagecustomizer.cpp index 78135b73e..a4f095ac2 100644 --- a/src/openhdimagecustomizer.cpp +++ b/src/openhdimagecustomizer.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,85 +21,25 @@ QString OpenHDImageCustomizer::cameraTypeForName(const QString &cameraName, const QString &sbc) { - if (cameraName.isEmpty()) - return QString(); - - qDebug() << "Camera found" << cameraName; - - QString cameraType; - if (sbc == "rpi") - { - if (cameraName == "OV5647") cameraType = "30"; - else if (cameraName == "IMX219") cameraType = "31"; - else if (cameraName == "IMX708") cameraType = "32"; - else if (cameraName == "IMX477") cameraType = "33"; - else if (cameraName == "HDMI") cameraType = "20"; - else if (cameraName == "SkyMasterHDR708") cameraType = "40"; - else if (cameraName == "SkyVisionPro519") cameraType = "41"; - else if (cameraName == "IMX477m") cameraType = "42"; - else if (cameraName == "IMX462") cameraType = "43"; - else if (cameraName == "IMX327") cameraType = "44"; - else if (cameraName == "IMX290") cameraType = "45"; - else if (cameraName == "IMX462MINI") cameraType = "46"; - else if (cameraName == "IMX662") cameraType = "47"; - else if (cameraName == "2MPCAMERAS") cameraType = "60"; - else if (cameraName == "CSIMX307") cameraType = "61"; - else if (cameraName == "CSSC137") cameraType = "62"; - else if (cameraName == "MVCAM") cameraType = "63"; - else if (cameraName == "GX/GXC IMX662 (ISP)") cameraType = "64"; - } - else if (sbc == "zero3w") - { - if (cameraName == "HDMI") cameraType = "90"; - else if (cameraName == "IMX462") cameraType = "94"; - else if (cameraName == "IMX519") cameraType = "95"; - else if (cameraName == "IMX219") cameraType = "92"; - else if (cameraName == "OV5647") cameraType = "91"; - else if (cameraName == "IMX708") cameraType = "93"; - else if (cameraName == "VEYE") cameraType = "97"; - else if (cameraName == "OHD-JAGUAR") cameraType = "96"; - } - else if (sbc == "rock-5b" || sbc == "rock-5a" || sbc == "radxa-cm5") - { - if (cameraName == "HDMI") cameraType = "80"; - else if (cameraName == "IMX219") cameraType = "82"; - else if (cameraName == "OV5647") cameraType = "81"; - else if (cameraName == "IMX708") cameraType = "83"; - else if (cameraName == "IMX462") cameraType = "84"; - else if (cameraName == "IMX415") cameraType = "85"; - else if (cameraName == "IMX477") cameraType = "86"; - else if (cameraName == "IMX519") cameraType = "87"; - else if (cameraName == "OHD-JAGUAR") cameraType = "88"; + QFile file(":/doc/openhd_settings_map.json"); + if (!file.open(QIODevice::ReadOnly)) return {}; + const auto camera=QJsonDocument::fromJson(file.readAll()).object().value("camera").toObject(); + const auto search=[&](const QJsonArray& vendors) -> QString { + for (const auto& vendor:vendors) for (const auto& option:vendor.toObject().value("options").toArray()) { + const auto object=option.toObject(); + if (object.value("id").toString()==cameraName) return object.value("valueWritten").toVariant().toString(); + } + return {}; + }; + for (const auto& group:camera.value("sbcGroups").toArray()) { + const auto object=group.toObject(); + if (object.value("sbc").toArray().contains(sbc)) { + const auto result=search(object.value("vendors").toArray()); + if (!result.isEmpty()) return result; + } } + return search(camera.value("commonVendors").toArray()); - if (cameraName == "FILESRC") cameraType = "4"; - else if (cameraName == "IP-CAMERA") cameraType = "3"; - else if (cameraName == "EXTERNAL") cameraType = "2"; - else if (cameraName == "USB") cameraType = "10"; - else if (cameraName == "TESTPATTERN") cameraType = "0"; - else if (cameraName == "INFIRAY") cameraType = "11"; - else if (cameraName == "INFIRAY_T2") cameraType = "12"; - else if (cameraName == "INFIRAY_X2") cameraType = "13"; - else if (cameraName == "INFIRAY_P2_PRO") cameraType = "14"; - else if (cameraName == "FLIR_VUE" || cameraName == "FLIR VUE") cameraType = "15"; - else if (cameraName == "FLIR_BOSON" || cameraName == "FLIR BOSON") cameraType = "16"; - else if (cameraName == "HDZERO") cameraType = "70"; - else if (cameraName == "RUNCAM_V1") cameraType = "71"; - else if (cameraName == "RUNCAM_V2") cameraType = "72"; - else if (cameraName == "RUNCAM_V3") cameraType = "73"; - else if (cameraName == "RUNCAM_NANO_90") cameraType = "74"; - else if (cameraName == "OHD-JAGUAR-X21") cameraType = "76"; - else if (cameraName == "OPENIPC") cameraType = "110"; - else if (cameraName == "XAVIER-IMX577") cameraType = "101"; - else if (cameraName == "ORQA-HORNET") cameraType = "122"; - else if (cameraName == "ORQA-JAGUAR") cameraType = "123"; - else if (cameraName == "ORQA-REKINDLE") cameraType = "124"; - else if (cameraName == "ROCKCHIP-RV") cameraType = "125"; - else if (sbc == "x20" && cameraName == "OHD-JAGUAR") cameraType = "75"; - else if ((sbc == "qcs405" || sbc == "qrb5165") && cameraName == "IMX577") cameraType = "120"; - else if ((sbc == "qcs405" || sbc == "qrb5165") && cameraName == "OV9282") cameraType = "121"; - - return cameraType; } QJsonObject OpenHDImageCustomizer::buildSettings(const QSettings &settings) @@ -175,6 +116,8 @@ QJsonObject OpenHDImageCustomizer::buildSettings(const QSettings &settings) if (!sbc.isEmpty()) openhdSettings.insert("sbc", sbc); + if (!settings.value("platform").toString().isEmpty()) + openhdSettings.insert("platform", settings.value("platform").toString()); if (mode == "debug") openhdSettings.insert("debug", true); openhdSettings.insert("disable_ethernet_link", disableEthernetLink); @@ -205,9 +148,28 @@ QJsonObject OpenHDImageCustomizer::buildSettings(const QSettings &settings) openhdSettings.insert("fleetcontrol_username", fleetControlUsername); openhdSettings.insert("fleetcontrol_password", fleetControlPassword); } + const auto defaults=QJsonDocument::fromJson(settings.value("initialParameterDefaults").toString().toUtf8()).object(); + if (defaults.value("version").toInt()==1 && defaults.value("components").isObject()) + openhdSettings.insert("parameter_defaults",defaults); return openhdSettings; } +QJsonObject OpenHDImageCustomizer::initialConfiguration(const QSettings &settings) +{ + auto file = buildSettings(settings); + for (const auto &key : {"fleetcontrol_username", "fleetcontrol_password", "token"}) file.remove(key); + QJsonObject selections; + for (const auto &key : {"bootType", "platform", "sbc", "camera", "camera2", "cameraResolution", "camera2Resolution", + "cameraPort", "camera2Port", "ipCameraAddress", "ipCameraPipeline", "camera2IpCameraAddress", "camera2IpCameraPipeline", + "ipCameraBitrate", "displayForceMode", "displayWidth", "displayHeight", "displayRefreshHz", "mode", "hotSpot", + "useSettings", "mapboxApiKey", "disableEthernetLink", "initialParameterDefaults", "language"}) + if (settings.contains(key)) selections.insert(key, QJsonValue::fromVariant(settings.value(key))); + selections.insert("mode", file.value("role")); + selections.insert("bootType", file.value("role").toString() == "ground" ? "Ground" : "Air"); + selections.insert("debug", file.value("debug").toBool()); + return {{"version", 1}, {"settings", file}, {"imageWriterSettings", selections}}; +} + OpenHDImageCustomizer::Result OpenHDImageCustomizer::prepareBootSplash(const QString &bootPartition) { const QDir boot(bootPartition); diff --git a/src/openhdimagecustomizer.h b/src/openhdimagecustomizer.h index b7067e099..41245bdd3 100644 --- a/src/openhdimagecustomizer.h +++ b/src/openhdimagecustomizer.h @@ -46,6 +46,7 @@ class OpenHDImageCustomizer final }; static QJsonObject buildSettings(const QSettings &settings); + static QJsonObject initialConfiguration(const QSettings &settings); static Result prepareBootSplash(const QString &bootPartition); static Result apply(const QString &bootPartition, const QSettings &settings); diff --git a/src/qml.qrc b/src/qml.qrc index acee417c3..f0c84f41f 100644 --- a/src/qml.qrc +++ b/src/qml.qrc @@ -58,6 +58,7 @@ qmlcomponents/ImageCardGrid.qml qmlcomponents/DeviceSelectionPage.qml qmlcomponents/SettingsSection.qml + qmlcomponents/InitialSettingsPanel.qml qmlcomponents/OperationReviewPage.qml qmlcomponents/LanguageSettingsPage.qml qmlcomponents/CacheSettingsSection.qml diff --git a/src/qmlcomponents/FleetControlPage.qml b/src/qmlcomponents/FleetControlPage.qml index 3622760d9..609ff3620 100644 --- a/src/qmlcomponents/FleetControlPage.qml +++ b/src/qmlcomponents/FleetControlPage.qml @@ -1,6 +1,7 @@ import QtQuick 2.9 import QtQuick.Controls 2.2 import QtQuick.Layouts 1.3 +import QtQuick.Dialogs 1.3 import "FleetProfilesHelper.js" as FleetProfilesHelper Item { @@ -123,6 +124,9 @@ Item { "icon": item.craftIcon, "role": item.craftRole, "hardware": item.craftHardware, + "platform": item.craftPlatform || "", + "writerSettings": item.craftWriterSettings || "{}", + "initialParameterDefaults": item.craftInitialDefaults || "{\"version\":1,\"components\":{}}", "description": item.craftDescription, "cameraVendor": item.craftCameraVendor || "", "camera": item.craftCamera || "", @@ -222,12 +226,16 @@ Item { } function loadCrafts() { + request("GET", "/api/imagewriter/platforms", undefined, function(xhr, response) { + if (xhr.status >= 200 && xhr.status < 300 && response && response.version === 1 && FleetProfilesHelper.installPlatforms(response.platforms)) { + var copy = JSON.parse(JSON.stringify(root.settingsMap)) + copy.platforms = response.platforms + root.settingsMap = copy + } + }) var selectedCraftId = imageWriter.getValue("fleetcontrol_craft_id") || "" loadingCrafts = true message = "" - craftModel.clear() - imageWriter.setSetting(getCacheKey(), "[]") - activeCraftName = "" // FleetControl is the source of crafts. The local list is refreshed only after a successful fetch. request("GET", "/api/imagewriter/profiles", undefined, function(xhr, response) { @@ -253,6 +261,9 @@ Item { craftModel.append({ "craftId": profile.id || ("craft_" + i), + "craftPlatform": s.platform || "", + "craftWriterSettings": JSON.stringify(s), + "craftInitialDefaults": s.initialParameterDefaults || "{\"version\":1,\"components\":{}}", "craftName": profile.name || "", "craftCategory": category, "craftIcon": icon, @@ -296,7 +307,9 @@ Item { var tempId = (editIdx >= 0 && editIdx < craftModel.count) ? craftModel.get(editIdx).craftId : ("craft_" + Date.now()) - var sbcKey = sbcKeyFromHardware(hardware) + var board = FleetProfilesHelper.platformForHardware(hardware) + if (!board) { message = qsTr("Choose a concrete hardware platform."); return } + var sbcKey = board.sbc var craftObj = { "craftId": tempId, @@ -333,6 +346,7 @@ Item { "mode": role, "bootType": role === "ground" ? "Ground" : "Air", "sbc": sbcKey, + "platform": board.id, "useSettings": true, "craftCategory": category, "craftIcon": icon, @@ -358,6 +372,12 @@ Item { } } + if (editIdx >= 0 && editIdx < craftModel.count) { + var previousSettings = JSON.parse(craftModel.get(editIdx).craftWriterSettings || "{}"); + Object.keys(previousSettings).forEach(function(key) { + if (payload.openhdSettings[key] === undefined) payload.openhdSettings[key] = previousSettings[key]; + }); + } var existingCloudProfile = editIdx >= 0 && editIdx < craftModel.count && String(tempId).indexOf("craft_") !== 0 var method = existingCloudProfile ? "PUT" : "POST" @@ -377,6 +397,32 @@ Item { }) } + property bool savingConfiguration: false + function saveInitialConfiguration() { + var craftId = imageWriter.getValue("fleetcontrol_craft_id") + if (!signedIn || !craftId || savingConfiguration) return + var snapshot = imageWriter.initialConfiguration() + savingConfiguration = true + request("PUT", "/api/imagewriter/profiles/" + encodeURIComponent(craftId) + "/initial-configuration", snapshot, function(xhr, response) { + savingConfiguration = false + if (xhr.status >= 200 && xhr.status < 300) { + loadCrafts() + message = qsTr("Initial configuration saved on this craft.") + } else message = response && response.message ? response.message : qsTr("Could not save the initial configuration.") + }) + } + FileDialog { + id: exportConfigurationDialog + title: qsTr("Save initial OpenHD configuration") + selectExisting: false + nameFilters: ["JSON files (*.json)"] + onAccepted: { + var localPath = decodeURIComponent(String(fileUrl).replace(/^file:\/\//, "")) + if (Qt.platform.os === "windows" && localPath.charAt(0) === "/") localPath = localPath.substring(1) + message = imageWriter.writeTextFile(localPath, JSON.stringify(imageWriter.initialConfiguration().settings, null, 2) + "\n") + ? qsTr("Initial configuration exported.") : qsTr("Could not export the initial configuration.") + } + } function deleteCraft(index) { if (index >= 0 && index < craftModel.count) { var item = craftModel.get(index) @@ -741,7 +787,8 @@ Item { editorSelectedCategory = craft.craftCategory || "craft" editorSelectedIcon = craft.craftIcon || (editorSelectedCategory === "craft" ? "survey" : "station-military-gcs") editorSelectedFamily = familyForIcon(editorSelectedIcon) - editorSelectedHardware = craft.craftHardware || "Raspberry Pi 4" + var board = FleetProfilesHelper.platformForHardware(craft.craftPlatform || craft.craftHardware) + editorSelectedHardware = board ? board.label : craft.craftHardware || "" craftNameField.text = craft.craftName || "" craftDescField.text = craft.craftDescription || "" @@ -1216,6 +1263,17 @@ Item { elide: Text.ElideRight } + ModernActionButton { + text: root.savingConfiguration ? qsTr("Saving…") : qsTr("Save configuration") + enabled: root.signedIn && !root.savingConfiguration + implicitHeight: 26 + onClicked: root.saveInitialConfiguration() + } + ModernActionButton { + text: qsTr("Export JSON") + implicitHeight: 26 + onClicked: exportConfigurationDialog.open() + } ModernActionButton { text: qsTr("Clear Selection") implicitHeight: 26 @@ -2001,19 +2059,7 @@ Item { anchors.right: parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter - model: [ - "Raspberry Pi 4", - "Raspberry Pi 5", - "Raspberry Pi CM4", - "Raspberry Pi Zero 2W", - "Radxa Zero 3W", - "Radxa Rock 3", - "Radxa Rock 5B", - "Radxa CM5", - "OpenHD Core X20", - "OpenHD Core X21", - "PC / x86_64 Ground Station" - ] + model: (root.settingsMap.platforms || []).map(function(p) { return p.label }) background: Rectangle { color: "transparent" } onCurrentTextChanged: { root.editorSelectedHardware = currentText diff --git a/src/qmlcomponents/FleetProfilesHelper.js b/src/qmlcomponents/FleetProfilesHelper.js index 6a7ec6f13..183717701 100644 --- a/src/qmlcomponents/FleetProfilesHelper.js +++ b/src/qmlcomponents/FleetProfilesHelper.js @@ -46,17 +46,49 @@ function iconSource(key) { return map[key] || "../icons/ui/hub.svg"; } +function platformForHardware(hw) { + var key = String(hw || "").toLowerCase(); + var platforms = _cachedSettingsMap ? _cachedSettingsMap.platforms || [] : []; + for (var i=0; i 512) return false; + var seen = {}; + for (var i=0; i= 0) return "x21"; - if (hw.indexOf("x20") >= 0) return "x20"; - if (hw.indexOf("rock 5") >= 0 || hw.indexOf("rock-5") >= 0 || hw.indexOf("cm5") >= 0 || hw.indexOf("rk3588") >= 0) return "rock-5b"; - if (hw.indexOf("zero 3") >= 0 || hw.indexOf("zero3") >= 0 || hw.indexOf("rock 3") >= 0 || hw.indexOf("cm3") >= 0 || hw.indexOf("rk3566") >= 0) return "zero3w"; - if (hw.indexOf("x86") >= 0 || hw.indexOf("pc") >= 0 || hw.indexOf("laptop") >= 0) return "x86"; - if (hw.indexOf("qualcomm") >= 0 || hw.indexOf("qcs") >= 0 || hw.indexOf("qrb") >= 0) return "qcs405"; - if (hw.indexOf("openipc") >= 0) return "openipc"; - if (hw.indexOf("raspberry") >= 0 || hw.indexOf("rpi") >= 0) return "rpi"; - return "rpi"; + var platform = platformForHardware(hw); + return platform ? platform.sbc : ""; } function getVendorsForSbc(settingsMap, sbcKey) { @@ -220,6 +252,9 @@ function loadProfiles(imageWriter) { if (p && p.name) { list.push({ craftId: p.id || ("craft_" + i), + craftPlatform: p.platform || "", + craftWriterSettings: p.writerSettings || "{}", + craftInitialDefaults: p.initialParameterDefaults || "{\"version\":1,\"components\":{}}", craftName: p.name, craftCategory: p.category || "craft", craftIcon: p.icon || (p.category === "station" ? "station-military-gcs" : "survey"), @@ -258,11 +293,15 @@ function loadProfiles(imageWriter) { function applyProfile(imageWriter, p) { if (!imageWriter || !p) return; var role = p.craftRole || (p.craftCategory === "station" ? "ground" : "air"); - var sbcKey = sbcKeyFromHardware(p.craftHardware); + var board = platformForHardware(p.craftPlatform || p.craftHardware); + var sbcKey = board ? board.sbc : ""; imageWriter.setSetting("mode", role); imageWriter.setSetting("bootType", role === "ground" ? "Ground" : "Air"); imageWriter.setSetting("sbc", sbcKey); + imageWriter.setSetting("platform", board ? board.id : ""); + // Clear a previous craft plan when this profile has no defaults. + imageWriter.setSetting("initialParameterDefaults", p.craftInitialDefaults || "{\"version\":1,\"components\":{}}"); imageWriter.setSetting("useSettings", true); imageWriter.setSetting("fleetcontrol_craft_id", p.craftId || ""); imageWriter.setSetting("fleetcontrol_craft_name", p.craftName || ""); @@ -286,6 +325,13 @@ function applyProfile(imageWriter, p) { imageWriter.setSetting("camera2IpCameraPipeline", role === "air" ? (p.craftCamera2IpPipeline || "") : ""); imageWriter.setSetting("ipCameraBitrate", p.craftIpCameraBitrate || 2); + try { + var extra = JSON.parse(p.craftWriterSettings || "{}"); + ["disableEthernetLink", "language"].forEach(function(key) { + if (extra[key] !== undefined) imageWriter.setSetting(key, extra[key]); + }); + imageWriter.setSetting("mode", extra.debug ? "debug" : role); + } catch(e) {} imageWriter.setSetting("hotSpot", p.craftHotSpot || ""); if (role === "ground") { diff --git a/src/qmlcomponents/InitialSettingsPanel.qml b/src/qmlcomponents/InitialSettingsPanel.qml new file mode 100644 index 000000000..a307460b2 --- /dev/null +++ b/src/qmlcomponents/InitialSettingsPanel.qml @@ -0,0 +1,93 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.0 +import Qt.labs.settings 1.0 + +GroupBox { + id: panel + title: qsTr("Initial OpenHD parameters") + property var fields: [] + property var settingsOwner: null + property string role: "" + property var defaults: ({version: 1, components: {}}) + readonly property var visibleFields: fields.filter(function(f) { + return f.provision && f.provision.roles.indexOf(role) >= 0 + }) + visible: visibleFields.length > 0 + Settings { id: store; property string initialParameterDefaults: "{}" } + Component.onCompleted: { + loadDefaults(settingsOwner ? settingsOwner.getValue("initialParameterDefaults") : store.initialParameterDefaults) + } + function loadDefaults(raw) { + try { var d = JSON.parse(raw); if (d.version === 1 && d.components) { defaults = d; return } } catch(e) {} + defaults = {version: 1, components: {}} + } + Connections { + target: panel.settingsOwner + function onSettingChanged(key, value) { if (key === "initialParameterDefaults") panel.loadDefaults(String(value)) } + } + function hasValue(field) { + var values = defaults.components[String(field.provision.componentId)] + return !!(values && values[field.id] !== undefined) + } + function value(field) { + var values = defaults.components[String(field.provision.componentId)] + return hasValue(field) ? values[field.id] : field.choices && field.choices.length ? field.choices[0].value : field.type === "int32" ? 0 : "" + } + function setValue(field, value, enabled) { + var copy = JSON.parse(JSON.stringify(defaults)) + var component = String(field.provision.componentId) + if (!copy.components[component]) copy.components[component] = {} + if (enabled) copy.components[component][field.id] = value + else delete copy.components[component][field.id] + defaults = copy + store.initialParameterDefaults = JSON.stringify(copy) + if (settingsOwner) settingsOwner.setSetting("initialParameterDefaults", JSON.stringify(copy)) + } + function exportDefaults() { + // Preserve explicit file parameters that a newer firmware knows about. + var result = JSON.parse(JSON.stringify(defaults)) + if (role === "ground") { delete result.components["100"]; delete result.components["101"] } + fields.forEach(function(field) { + if (!field.provision || field.provision.roles.indexOf(role) >= 0) return + var values = result.components[String(field.provision.componentId)] + if (values) delete values[field.id] + }) + return result + } + ColumnLayout { + anchors.fill: parent + Label { text: qsTr("Applied only when OpenHD creates new settings. Existing user settings are preserved."); wrapMode: Text.WordWrap; Layout.fillWidth: true } + Repeater { + model: panel.visibleFields + delegate: ColumnLayout { + Layout.fillWidth: true + property var field: modelData + CheckBox { + text: field.label + checked: panel.hasValue(field) + onClicked: panel.setValue(field, panel.value(field), checked) + } + ComboBox { + visible: field.choices && field.choices.length > 0 + enabled: panel.hasValue(field) + Layout.fillWidth: true + model: field.choices || [] + textRole: "label" + currentIndex: { + for (var n=0; nJSON.stringify(map),getValue:key=>settings[key] || '',setSetting:(key,value)=>settings[key]=value}; +context.getSettingsMap(writer); +for(const board of map.platforms) { + assert.strictEqual(context.platformForHardware(board.id).sbc,board.sbc); + context.applyProfile(writer,{craftId:'craft-1',craftRole:board.roles[0],craftPlatform:board.id,craftHardware:board.label, + craftCamera:'USB',craftInitialDefaults:'{"version":1,"components":{"100":{"AIR_RECORDING_E":2}}}',craftWriterSettings:'{"disableEthernetLink":false}'}); + assert.strictEqual(settings.platform,board.id); + assert.strictEqual(settings.sbc,board.sbc); + assert.strictEqual(JSON.parse(settings.initialParameterDefaults).components['100'].AIR_RECORDING_E,2); + assert.strictEqual(settings.disableEthernetLink,false); +} +assert.strictEqual(context.platformForHardware('Raspberry Pi (Pi 4 / Pi 5 / CM4 / Zero 2W)'),null); +assert.strictEqual(context.platformForHint('openhd-rpi5-bullseye.img').id,'rpi5'); +assert.strictEqual(context.platformForHint('openhd-rpi-bullseye.img'),null); +context.applyProfile(writer,{craftRole:'ground',craftPlatform:'rpi4',craftCamera:'USB',craftCamera2:'USB'}); +assert.deepStrictEqual(JSON.parse(settings.initialParameterDefaults),{version:1,components:{}}); +assert.strictEqual(settings.camera,''); assert.strictEqual(settings.camera2,''); +console.log(`PROFILE ROUND TRIP AND ${map.platforms.length} SHARED PLATFORMS PASSED`); diff --git a/src/test/initialsettings_qml_test.cpp b/src/test/initialsettings_qml_test.cpp new file mode 100644 index 000000000..33a03fc2b --- /dev/null +++ b/src/test/initialsettings_qml_test.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +class Owner : public QObject { + Q_OBJECT +public: + QString raw = R"({"version":1,"components":{"100":{"AIR_RECORDING_E":2},"191":{"CUSTOM_NEW":7}}})"; + Q_INVOKABLE QString getValue(const QString&) const { return raw; } + Q_INVOKABLE void setSetting(const QString &key,const QVariant &value) { raw=value.toString();emit settingChanged(key,value); } +signals: + void settingChanged(const QString &key,const QVariant &value); +}; +int main(int argc,char**argv) { + QGuiApplication app(argc,argv); app.setOrganizationName("OpenHDSettingsQmlTests");app.setApplicationName("Defaults"); + QFile catalog(":/doc/openhd_settings_map.json");if(!catalog.open(QIODevice::ReadOnly)) return 1; + const auto map=QJsonDocument::fromJson(catalog.readAll()).object(); + Owner owner; QQmlEngine engine; + QQmlComponent component(&engine,QUrl::fromLocalFile(QStringLiteral(INITIAL_SETTINGS_QML_PATH))); + QObject* panel=component.beginCreate(engine.rootContext()); + if(!panel) {qCritical()<setProperty("settingsOwner",QVariant::fromValue(static_cast(&owner))); + panel->setProperty("fields",map.value("initialFields").toArray().toVariantList());panel->setProperty("role","air"); + component.completeCreate();engine.globalObject().setProperty("panel",engine.newQObject(panel)); + panel->setProperty("fields",map.value("initialFields").toArray().toVariantList());panel->setProperty("role","air"); + const auto exported=[&] { const auto value=engine.evaluate("JSON.stringify(panel.exportDefaults())"); + if(value.isError()) std::cerr<setProperty("role","ground");values=exported().value("components").toObject().value("100").toObject(); + if(!values.isEmpty() || exported().value("components").toObject().value("191").toObject().value("CUSTOM_NEW").toInt()!=7) return 5; + delete panel;std::cout<<"INITIAL SETTINGS QML PROFILE REFRESH AND ROLE FILTER PASSED"<../../doc/openhd_settings_map.json