diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a52355..125a9f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,25 +4,36 @@ on: push: tags: - 'v*' + # Dry run from the Actions tab: builds, tests and checks both packages of the selected branch, downloads them in the + # release job and generates the AutoUpdater XML; creates no release and pushes nothing. + workflow_dispatch: +# Read-only by default: the build jobs run NuGet build logic and the tests. Only prepare and release push or publish. permissions: - contents: write + contents: read jobs: - release: - runs-on: windows-latest - + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + sha: ${{ steps.commit.outputs.SHA }} + version: ${{ steps.version.outputs.VERSION }} + steps: - - name: Checkout main branch + # A release is built from main: this workflow commits the version and the AutoUpdater XML to it. + - name: Checkout uses: actions/checkout@v5 with: - ref: main + ref: ${{ github.event_name == 'push' && 'main' || github.ref }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # The release is built from main (the workflow commits the version and AutoUpdater XML to it). - # Refuse to release if main is not the tagged commit, so the package always matches the tag. + # Refuse to release if main is not the tagged commit, so the packages always match the tag. - name: Check that the tag points to main + if: github.event_name == 'push' + shell: pwsh run: | $mainSha = git rev-parse HEAD $tagSha = "${{ github.sha }}" @@ -31,49 +42,47 @@ jobs: exit 1 } echo "Tag ${{ github.ref_name }} matches main ($mainSha)" - shell: powershell - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Extract version from tag + + - name: Get the version id: version + shell: pwsh run: | - $tagName = "${{ github.ref }}" -replace 'refs/tags/v', '' - echo "VERSION=$tagName" >> $env:GITHUB_OUTPUT - echo "Tag version: $tagName" - shell: powershell - + if ("${{ github.event_name }}" -eq "push") { + $version = "${{ github.ref_name }}" -replace '^v', '' + } + else { + $version = [regex]::Match((Get-Content Canon.API/Canon.API.csproj -Raw), '(.*)').Groups[1].Value + } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + echo "Version: $version" + - name: Check and update project version + if: github.event_name == 'push' id: version_check + shell: pwsh run: | $tagVersion = "${{ steps.version.outputs.VERSION }}" - - # Read current version from project file + $projectFile = "Canon.API/Canon.API.csproj" $projectContent = Get-Content $projectFile $currentVersion = "" - + foreach ($line in $projectContent) { if ($line -match '(.*)') { $currentVersion = $matches[1] break } } - + echo "Current project version: $currentVersion" echo "Tag version: $tagVersion" - + if ($currentVersion -ne $tagVersion) { echo "VERSION_MISMATCH=true" >> $env:GITHUB_OUTPUT echo "Versions do not match - updating project files" - - # Update Canon.API project file + $projectContent -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" | Set-Content $projectFile - - # Update Canon.Core project file if it has version info + $coreProjectFile = "Canon.Core/Canon.Core.csproj" if (Test-Path $coreProjectFile) { $coreContent = Get-Content $coreProjectFile @@ -85,54 +94,164 @@ jobs: echo "VERSION_MISMATCH=false" >> $env:GITHUB_OUTPUT echo "Versions match - no update needed" } - shell: powershell - + - name: Commit version updates - if: steps.version_check.outputs.VERSION_MISMATCH == 'true' + if: github.event_name == 'push' && steps.version_check.outputs.VERSION_MISMATCH == 'true' + shell: pwsh run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add "*.csproj" git commit -m "Update project versions to ${{ steps.version.outputs.VERSION }}" git push origin main - shell: powershell - + + - name: Commit to build + id: commit + shell: pwsh + run: echo "SHA=$(git rev-parse HEAD)" >> $env:GITHUB_OUTPUT + + windows: + needs: prepare + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore dependencies run: dotnet restore CanonSDK.sln - + - name: Build solution run: dotnet build CanonSDK.sln --configuration Release --no-restore - name: Run unit tests run: dotnet test CanonSDK.sln --configuration Release --no-build - - - name: Publish Canon.API as standalone executable + + - name: Publish the Windows package run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish - - - name: Create release package - run: | - Compress-Archive -Path "./publish/*" -DestinationPath "./CanonWebAPI.zip" + + - name: Check that the Windows package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe + shell: pwsh + + - name: Create the Windows archive + run: Compress-Archive -Path "./publish/*" -DestinationPath "./CanonWebAPI.zip" shell: powershell + - name: Upload the Windows archive + uses: actions/upload-artifact@v4 + with: + name: windows-package + path: CanonWebAPI.zip + if-no-files-found: error + + linux: + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install libusb (needed by the Linux EDSDK) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0 + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build + + - name: Publish the Linux package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output ./package/CanonWebAPI + + - name: Check that the Linux package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./package/CanonWebAPI/Canon.API + shell: pwsh + + # Owned by root when extracted with sudo: with the runner's uid, the local user with that uid could edit install.sh + # before root runs it. + - name: Create the Linux archive + run: | + chmod 755 package/CanonWebAPI/Canon.API package/CanonWebAPI/install.sh + tar --owner=0 --group=0 --numeric-owner -czf CanonWebAPI-linux-x64.tar.gz -C package CanonWebAPI + + - name: Upload the Linux archive + uses: actions/upload-artifact@v4 + with: + name: linux-package + path: CanonWebAPI-linux-x64.tar.gz + if-no-files-found: error + + # Runs only when both packages were built and checked. A dry run stops before publishing: no release, no push. + release: + needs: [prepare, windows, linux] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'push' && 'main' || github.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download the packages + uses: actions/download-artifact@v4 + with: + path: packages + merge-multiple: true + + - name: List the packages + shell: pwsh + run: Get-ChildItem packages + - name: Create GitHub Release + if: github.event_name == 'push' uses: softprops/action-gh-release@v2 with: - files: ./CanonWebAPI.zip + tag_name: ${{ github.ref_name }} + files: | + packages/CanonWebAPI.zip + packages/CanonWebAPI-linux-x64.tar.gz + fail_on_unmatched_files: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + - name: Update AutoUpdater XML + shell: pwsh run: | - $version = "${{ steps.version.outputs.VERSION }}" + $version = "${{ needs.prepare.outputs.version }}" $releaseUrl = "https://github.com/${{ github.repository }}/releases/download/v$version/CanonWebAPI.zip" - - # Create docs folder if it doesn't exist + if (!(Test-Path "docs")) { New-Item -ItemType Directory -Path "docs" } - - # Create AutoUpdater XML content + $xmlContent = @" @@ -142,17 +261,19 @@ jobs: true "@ - - $xmlContent | Out-File -FilePath "docs/autoupdate.xml" -Encoding UTF8 - shell: powershell - + + # The feed has always started with a BOM (written by Windows PowerShell); pwsh's UTF8 would drop it. + $xmlContent | Out-File -FilePath "docs/autoupdate.xml" -Encoding utf8BOM + Get-Content docs/autoupdate.xml + - name: Commit AutoUpdater XML + if: github.event_name == 'push' + shell: pwsh run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add docs/autoupdate.xml if (!(git diff --staged --quiet)) { - git commit -m "Update AutoUpdater XML for version ${{ steps.version.outputs.VERSION }}" + git commit -m "Update AutoUpdater XML for version ${{ needs.prepare.outputs.version }}" git push origin main } - shell: powershell \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89e64ab..c816898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ concurrency: jobs: build-and-test: runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout @@ -35,9 +36,55 @@ jobs: - name: Run unit tests run: dotnet test CanonSDK.sln --configuration Release --no-build --logger trx --results-directory TestResults + - name: Publish the Windows package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + + - name: Check that the Windows package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe + shell: pwsh + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-windows + path: TestResults + + build-and-test-linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install libusb (needed by the Linux EDSDK) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0 + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build --logger trx --results-directory TestResults + + - name: Publish the Linux package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + + - name: Check that the Linux package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API + shell: pwsh + - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-linux path: TestResults diff --git a/CHANGELOG.md b/CHANGELOG.md index 406670f..3e711df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow. ## [Unreleased] +## [1.5.0.0] - 2026-10-04 + ### Added +- Linux package in every release: `CanonWebAPI-linux-x64.tar.gz`, a self-contained executable for Linux x64 (no .NET + or ICU to install) with the Linux EDSDK, and `install.sh`, which installs it as a systemd service with the USB + access it needs (`/opt/canonwebapi`, `canonwebapi` user, udev rule); `install.sh --uninstall` removes it. - `tools/Test-Camera.ps1` (with `Test-Camera.cmd`): runs the photo booth camera checks against the API (live view, captures, flash changes during the live view, power cycle) and saves the pictures, the results and the API log. - Linux x64 support: the `net10.0` target of `Canon.API` runs on Linux with the Linux version of the EDSDK - (`EDSDK/linux-x64/libEDSDK.so`, needs `libusb-1.0`). No automatic update and no release package on Linux yet. + (`EDSDK/linux-x64/libEDSDK.so`, needs `libusb-1.0`). No automatic update on Linux. - Debug logs (`Logging:LogLevel:Canon.Core` = `Debug`): every EDSDK event, and each step of a capture (UI lock, flash settings, shutter button, transfer) with its result and duration. @@ -19,12 +24,21 @@ Versions match the Git tags (`v1.0.0.11`) that trigger the release workflow. - `Canon.API` has two target frameworks: `dotnet run` and `dotnet publish` need `-f net10.0-windows` (Windows) or `-f net10.0` (Linux). `Canon.Core` and the unit tests target `net10.0`. - A failed UI unlock after setting the flash is logged as a warning; it was ignored. +- A fatal startup error (e.g. port already in use, invalid `appsettings.Local.json`) now exits with code 1. It exited + with code 0, so launchers and systemd did not see the failure. - The problem title returned when the Canon EDSDK cannot be loaded now names both platforms: "Canon EDSDK could not be loaded (EDSDK.dll or libEDSDK.so missing, wrong architecture, or libusb-1.0 missing on Linux)" (was "...(EDSDK.dll missing or wrong architecture)"). Client-visible text. - Regenerating `docs/openapi.json` at build time now uses `dotnet build Canon.API -f net10.0 -t:Build -t:GenerateOpenApiDocuments` (Windows or Linux); the former `-p:OpenApiGenerateDocumentsOnBuild=true` form generates nothing now that `Canon.API` has two target frameworks. +- CI builds and tests on Windows and Linux, then publishes the Windows and Linux packages and starts them to check that + they load the EDSDK (`tools/Test-Package.ps1`). The published Windows executable was never run before a release. +- The release workflow builds, tests and checks the Windows and Linux packages in parallel, and creates the release + (both packages) and updates the AutoUpdater XML only when both succeeded. It can be run by hand from the Actions tab + as a dry run: it also downloads both packages and generates the AutoUpdater XML in the release job, but creates no + release and pushes nothing. The build jobs get a read-only token: only the jobs that push or publish can write to + the repository. ### Fixed - The camera froze when a picture was taken while the live view ran, as in the photo booth: the shutter stayed busy @@ -250,7 +264,8 @@ First release. - Automatic updates with AutoUpdater.NET, from GitHub releases. - CI/CD workflow building the release package. -[Unreleased]: https://github.com/dansleboby/CanonWebAPI/compare/v1.4.0.0...HEAD +[Unreleased]: https://github.com/dansleboby/CanonWebAPI/compare/v1.5.0.0...HEAD +[1.5.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.4.0.0...v1.5.0.0 [1.4.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.3.0.0...v1.4.0.0 [1.3.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.2.0.0...v1.3.0.0 [1.2.0.0]: https://github.com/dansleboby/CanonWebAPI/compare/v1.1.0.0...v1.2.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 180b02a..3f795cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,8 @@ The solution consists of 4 projects: ### Dependencies - Requires Canon EDSDK 13.20.21 **64-bit** DLLs (`EDSDK.dll`, `EdsImage.dll`, from `EDSDK_64/Dll` of the SDK) in the EDSDK folder -- Linux: `EDSDK/linux-x64/libEDSDK.so` (from `EDSDK/Library/x86_64` of the 13.20.21 Linux SDK), needs `libusb-1.0` +- Linux: `EDSDK/linux-x64/libEDSDK.so` (from `EDSDK/Library/x86_64` of the 13.20.21 Linux SDK), needs `libusb-1.0`; the `net10.0` target uses invariant globalization (no ICU needed: only the invariant culture is used) +- Linux package (`packaging/linux/`): the udev rule uses final assignments (`MODE:=`, `GROUP:=`) because `60-libgphoto2*.rules` sorts after it and sets `GROUP="plugdev"` on add/bind; `install.sh` applies it with an add event (libgphoto2 skips change events) - Uses Serilog for logging - Swagger/OpenAPI for API documentation - Platform target: x64 (Windows; Linux through the `net10.0` target of Canon.API) @@ -89,7 +90,7 @@ Errors are problem details: 400 invalid value, 409 capture refused or setting lo - The camera can be connected via USB before or after the application starts (automatic connection and reconnection) - Canon EOS Utility must NOT be running (conflicts with EDSDK access) - Requires compatible Canon camera with EDSDK support -- Canon.Core and the tests target `net10.0`; Canon.API targets `net10.0-windows` (release, AutoUpdater.NET) and `net10.0` (Linux). Publish the release with `-f net10.0-windows` +- Canon.Core and the tests target `net10.0`; Canon.API targets `net10.0-windows` (Windows release, AutoUpdater.NET) and `net10.0` (Linux). The release publishes the Windows package with `-f net10.0-windows -r win-x64` and the Linux package with `-f net10.0 -r linux-x64` (both self-contained single files) - Uses structured logging with Serilog (logs to console and `logs/canon-api.log` next to the executable; levels from `Logging:LogLevel`) - Content root is the executable folder: settings files are read next to the executable whatever the working directory - Operator settings go in `appsettings.Local.json`, never shipped: automatic updates overwrite `appsettings.json` @@ -110,11 +111,11 @@ The application includes AutoUpdater.NET with these configurations: ## Release Process The project uses GitHub Actions for automated releases (record every change in the `Unreleased` section of `CHANGELOG.md`; never edit `docs/autoupdate.xml` by hand): -- Trigger: Push tags matching `v*` pattern -- Workflow compares tag version with project file versions -- Updates project files automatically if versions don't match -- Creates GitHub releases with packaged binaries -- Updates AutoUpdater XML automatically +- Trigger: push a tag matching `v*` on the latest commit of `main` (or run the workflow by hand: dry run, nothing published) +- `prepare` job: checks the tag, compares the tag version with the project versions and commits them if they differ +- `windows` and `linux` jobs, in parallel: build, test, publish `CanonWebAPI.zip` (win-x64) and `CanonWebAPI-linux-x64.tar.gz` (linux-x64, with `packaging/linux/`), and start each package with `tools/Test-Package.ps1` +- `release` job, only when both succeeded: creates the GitHub release with both packages and updates the AutoUpdater XML (Windows package, written with a UTF-8 BOM as it always was); a dry run only downloads the packages and generates the XML +- Token scope: `contents: read` for the workflow and no persisted credentials in the build jobs (they run NuGet build logic and the tests); only `prepare` and `release` get `contents: write` ## EDSDK Integration Notes @@ -133,4 +134,5 @@ The project uses GitHub Actions for automated releases (record every change in t - **Canon.Core.Tests**: xUnit tests of the logic that does not need a camera (value tables, settings rules, timeouts, file types, CanonThread, LiveViewBroadcaster, EDSDK structure layouts) - **Canon.API.Tests**: xUnit tests of the error mapping (CameraExceptionHandler), the request models and the log levels +- CI (pull requests and `main`): build and tests on Windows and Linux runners, then both packages are published and started by `tools/Test-Package.ps1`, which checks that they load the EDSDK (`/status`: "No Canon camera detected") - End-to-end testing requires a physical Canon camera connected via USB (not possible in Windows containers) \ No newline at end of file diff --git a/Canon.API/Canon.API.csproj b/Canon.API/Canon.API.csproj index 6ecfb53..ccd9e47 100644 --- a/Canon.API/Canon.API.csproj +++ b/Canon.API/Canon.API.csproj @@ -8,9 +8,9 @@ x64 - 1.4.0.0 - 1.4.0.0 - 1.4.0.0 + 1.5.0.0 + 1.5.0.0 + 1.5.0.0 @@ -28,6 +28,14 @@ true + + + true + true + + @@ -80,6 +88,11 @@ + + + + + diff --git a/Canon.API/Program.cs b/Canon.API/Program.cs index 24ab7db..5b4ebbe 100644 --- a/Canon.API/Program.cs +++ b/Canon.API/Program.cs @@ -75,6 +75,8 @@ catch (Exception ex) when (ex is not HostAbortedException) { Log.Fatal(ex, "Application startup failed"); + // Launchers and systemd (Restart=on-failure) only see the failure through a non-zero exit code. + Environment.ExitCode = 1; } finally { diff --git a/README.md b/README.md index 7cde9c1..a6ecca7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A web API for remotely controlling Canon DSLR and mirrorless cameras. This project utilizes the Canon EDSDK to communicate with the camera and includes automatic update capabilities. -**Current version: 1.4.0.0** (.NET 10, Canon EDSDK 13.20.21). See the [changelog](CHANGELOG.md) for the changes of each version. +**Current version: 1.5.0.0** (.NET 10, Canon EDSDK 13.20.21). See the [changelog](CHANGELOG.md) for the changes of each version. > **Upgrading from 1.0.x**: errors are now returned as problem details with new status codes (e.g. 503 when the camera is not connected, 504 instead of 408 on capture timeout), and values unknown to the value tables are returned in hexadecimal (`"0x99"`). See the [changelog](CHANGELOG.md#1100---2026-09-30). @@ -12,6 +12,8 @@ A web API for remotely controlling Canon DSLR and mirrorless cameras. This proje > **Upgrading from 1.3.x**: settings changed in `appsettings.json` are reset by this update: put them in `appsettings.Local.json`, which updates never overwrite (see [Configuration](#configuration)). `POST /settings` refuses unknown fields (400), and f/4.5 in 1/3 stop steps now reads `"4.5 (1/3)"` (sending `"4.5"` still works). See the [changelog](CHANGELOG.md#1400---2026-09-30). +> **Upgrading from 1.4.x**: the "flash firing" setting is no longer written while the live view runs (it froze the EOS R100): with `Canon:ForceFlashFiring` it is set when the camera connects, before the live view starts and before a capture taken without live view, and a value set with `POST /flash` stays until the live view stops. Linux is supported (see [Installing on Linux](#installing-on-linux)). From the sources, `dotnet run` and `dotnet publish` need `-f net10.0-windows` or `-f net10.0`. See the [changelog](CHANGELOG.md#1500---2026-10-04). + ## Features * Get camera information (e.g., camera name, shooting mode, temperature restrictions). @@ -187,6 +189,36 @@ This application includes automatic update functionality powered by AutoUpdater. Linux has no automatic update yet (the `net10.0` target does not include AutoUpdater.NET). +## Installing on Linux + +Each release also provides `CanonWebAPI-linux-x64.tar.gz`: a self-contained executable for Linux x64 (no .NET to +install) with the Linux version of the EDSDK. Requirements: x86_64 Linux with glibc 2.27 or later and libstdc++ (GCC 7 +or later), `libusb-1.0` (`sudo apt install libusb-1.0-0`), and systemd for the service; no ICU library is needed. +Tested on Ubuntu 26.04. There is no automatic update on Linux. + +```bash +tar -xzf CanonWebAPI-linux-x64.tar.gz +cd CanonWebAPI +./Canon.API # by hand: http://localhost:5000 +sudo ./install.sh # or as a service started at boot +``` + +`install.sh` copies the files to `/opt/canonwebapi`, creates the `canonwebapi` system user, adds a udev rule that lets +it open Canon cameras (USB vendor `04a9`, PTP interface), then installs, enables and starts the `canonwebapi` systemd +service. It checks the system and the package before changing anything, and fails when the service is not running a +few seconds after its start (see `journalctl -u canonwebapi`). + +* Settings: `/opt/canonwebapi/appsettings.Local.json` (kept by updates), then `sudo systemctl restart canonwebapi`. +* Logs: `/opt/canonwebapi/logs/` and `journalctl -u canonwebapi`; state: `systemctl status canonwebapi`. +* Update: extract the new archive and run its `install.sh`. +* Uninstall: `sudo /opt/canonwebapi/install.sh --uninstall` removes the service, the udev rule, `/opt/canonwebapi` + (settings and logs included) and the `canonwebapi` user. +* Run by hand, the API needs read/write access to the camera USB device: desktop sessions get it, otherwise install + the service. A desktop can mount the camera (GNOME: gvfs) and hold it: unmount it (`gio mount -l`, then + `gio mount -u `) or disable the automount. +* Stop the service before running `Canon.API` by hand (`sudo systemctl stop canonwebapi`): only one program can use + the camera, and the service also uses port 5000. + ## Development & Building ### Building from Source @@ -211,12 +243,14 @@ dotnet run --project Canon.API -f net10.0-windows # on Linux: -f net10.0 This project uses automated GitHub Actions for releases: 1. **Changelog**: Move the changes of the `Unreleased` section of [CHANGELOG.md](CHANGELOG.md) under the new version and date, and update the version in `Canon.API/Canon.API.csproj` -2. **Tag-based releases**: Push a tag like `v1.4.0.0` on the latest commit of `main` to trigger automated build and release (the workflow refuses a tag on another commit) +2. **Tag-based releases**: Push a tag like `v1.5.0.0` on the latest commit of `main` to trigger automated build and release (the workflow refuses a tag on another commit) 3. **Version synchronization**: The workflow automatically updates project versions to match the tag -4. **Unit tests**: The workflow runs `Canon.Core.Tests` and `Canon.API.Tests` before packaging -5. **Automatic packaging**: Creates release packages and updates the AutoUpdater XML +4. **Packages**: The workflow builds and tests the solution, publishes the Windows and Linux packages and starts both to check that they load the EDSDK (`tools/Test-Package.ps1`) +5. **Atomic release**: Only when both packages succeeded, it creates the release (`CanonWebAPI.zip`, `CanonWebAPI-linux-x64.tar.gz`), then updates the AutoUpdater XML. A failure of the build jobs publishes nothing; if the release job fails after creating the release, the feed is not updated and the photo booths stay on the previous version: rerun the failed job 6. **GitHub releases**: Automatically creates GitHub releases with generated notes +To check the pipeline before tagging, run the "Release CanonWebAPI" workflow by hand from the Actions tab (on `main`): it builds, tests and checks both packages, downloads them in the release job and generates the AutoUpdater XML, without creating a release or pushing anything. + `docs/autoupdate.xml` is updated by the workflow only: changing it by hand makes every installation download that version. ### Running the API @@ -228,7 +262,7 @@ dotnet run --project Canon.API -f net10.0-windows # on Linux: -f net10.0 ### Running on Linux The `net10.0` target of `Canon.API` runs on Linux x64 with the Linux version of the EDSDK (`EDSDK/linux-x64/libEDSDK.so`). -There is no automatic update and no release package for Linux yet: run it from the sources. +To run a release, see [Installing on Linux](#installing-on-linux); this section runs it from the sources. Prerequisites: .NET 10 SDK and `libusb-1.0` (Ubuntu: `sudo apt install dotnet-sdk-10.0 libusb-1.0-0`). @@ -306,7 +340,7 @@ update check out of the generator, which runs the application's entry point. * **Canon T7** ✅ ### System Requirements -* **OS**: Windows 10/11 (x64); Linux x64 from the sources (see [Running on Linux](#running-on-linux)) +* **OS**: Windows 10/11 (x64): `CanonWebAPI.zip`; Linux x64 (glibc 2.27+, libstdc++ from GCC 7+, `libusb-1.0`): `CanonWebAPI-linux-x64.tar.gz` (see [Installing on Linux](#installing-on-linux)) * **Runtime**: none to install: the release is a self-contained executable (.NET 10) * **Dependencies**: Canon EDSDK 13.20.21 64-bit libraries (included; up to 1.0.0.11: EDSDK 13.19.0) diff --git a/docs/openapi.json b/docs/openapi.json index a25addb..455e9f2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Canon Web API", "description": "Remote control of a Canon camera through the Canon EDSDK: settings, capture and MJPEG live view. Errors are returned as problem details: 400 invalid value, 409 capture refused by the camera or setting not changeable in the current shooting mode, 503 camera not connected or busy, 504 capture timeout.", - "version": "1.4.0" + "version": "1.5.0" }, "paths": { "/cameraname": { diff --git a/packaging/linux/60-canonwebapi.rules b/packaging/linux/60-canonwebapi.rules new file mode 100644 index 0000000..ffdbc65 --- /dev/null +++ b/packaging/linux/60-canonwebapi.rules @@ -0,0 +1,4 @@ +# CanonWebAPI: Canon PTP cameras can be opened by the canonwebapi service user. +# Final assignments: 60-libgphoto2*.rules sorts after this file and sets MODE="0664", GROUP="plugdev" on add/bind. +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="", IMPORT{builtin}="usb_id" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="04a9", ENV{ID_USB_INTERFACES}=="*:060101:*", MODE:="0660", GROUP:="canonwebapi" diff --git a/packaging/linux/canonwebapi.service b/packaging/linux/canonwebapi.service new file mode 100644 index 0000000..8cd6ae6 --- /dev/null +++ b/packaging/linux/canonwebapi.service @@ -0,0 +1,19 @@ +[Unit] +Description=CanonWebAPI (Canon camera web API) +After=network.target + +[Service] +User=canonwebapi +Group=canonwebapi +WorkingDirectory=/opt/canonwebapi +ExecStart=/opt/canonwebapi/Canon.API +Restart=on-failure +RestartSec=5 +# No PrivateDevices (USB camera), MemoryDenyWriteExecute (JIT) or RestrictAddressFamilies (hot plug uses netlink). +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..4e4fd11 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Installs or updates CanonWebAPI as a systemd service from an extracted Linux package, or removes it. +# sudo ./install.sh install or update (appsettings.Local.json and the logs are kept) +# sudo ./install.sh --uninstall remove the service, the udev rule, /opt/canonwebapi and the canonwebapi user +set -euo pipefail + +readonly app_dir=/opt/canonwebapi +readonly service=canonwebapi +readonly user=canonwebapi +readonly unit_file=/etc/systemd/system/canonwebapi.service +readonly rule_file=/etc/udev/rules.d/60-canonwebapi.rules +package_dir="$(cd "$(dirname "$0")" && pwd)" +readonly package_dir + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +# Applies the udev rules to a plugged camera as at boot or replug (60-libgphoto2*.rules skips change events), then +# waits until it has its final ownership. +apply_camera_rules() { + udevadm trigger --action=add --subsystem-match=usb --attr-match=idVendor=04a9 + udevadm settle +} + +uninstall() { + systemctl disable --now "$service" 2>/dev/null || true + rm -f "$unit_file" "$rule_file" + systemctl daemon-reload + udevadm control --reload + # Gives a plugged camera its default ownership back. + apply_camera_rules + rm -rf "$app_dir" + if id "$user" >/dev/null 2>&1; then + userdel "$user" + fi + echo "CanonWebAPI removed." +} + +install_package() { + # Every check comes before the first change, so a failed check leaves the system as it was. + [ "$(uname -m)" = x86_64 ] || fail "this package is for x86_64, not $(uname -m)." + # No grep -q: with pipefail, grep exiting early would fail the pipeline through SIGPIPE. + ldconfig -p | grep 'libusb-1.0.so.0' >/dev/null || fail "libusb-1.0 is missing: sudo apt install libusb-1.0-0" + [ "$package_dir" != "$app_dir" ] || fail "run the install.sh of the extracted package, not the installed one." + local file + for file in Canon.API libEDSDK.so canonwebapi.service 60-canonwebapi.rules; do + [ -f "$package_dir/$file" ] || fail "$file not found next to the script: run it from the extracted package." + done + + if ! id "$user" >/dev/null 2>&1; then + useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$user" + fi + + systemctl stop "$service" 2>/dev/null || true + + install -d -o root -g root -m 755 "$app_dir" + install -d -o "$user" -g "$user" -m 755 "$app_dir/logs" + + # The appsettings.Local.json and logs/ of the installation are left as they are. + for file in "$package_dir"/*; do + name="$(basename "$file")" + case "$name" in + appsettings.Local.json | canonwebapi.service | 60-canonwebapi.rules) continue ;; + esac + [ -f "$file" ] || continue + install -o root -g root -m 644 "$file" "$app_dir/$name" + done + chmod 755 "$app_dir/Canon.API" "$app_dir/install.sh" + + install -o root -g root -m 644 "$package_dir/60-canonwebapi.rules" "$rule_file" + udevadm control --reload + apply_camera_rules + + install -o root -g root -m 644 "$package_dir/canonwebapi.service" "$unit_file" + systemctl daemon-reload + systemctl enable "$service" + systemctl restart "$service" + # A startup failure (port 5000 in use, invalid appsettings.Local.json...) ends the process within a few seconds. + sleep 3 + systemctl is-active --quiet "$service" || fail "the $service service is not running: see journalctl -u $service" + + echo "CanonWebAPI is installed in $app_dir and runs as the $service service: http://localhost:5000/status" + echo "Settings: $app_dir/appsettings.Local.json. Logs: $app_dir/logs and journalctl -u $service" + systemctl --no-pager status "$service" || true +} + +[ "$(id -u)" -eq 0 ] || fail "run it as root: sudo $0${*:+ $*}" +[ -d /run/systemd/system ] || fail "systemd is not running: the $service service needs it." + +case "${1:-}" in + "") install_package ;; + --uninstall) uninstall ;; + *) fail "unknown option $1. Usage: sudo $0 [--uninstall]" ;; +esac diff --git a/specs/2026-10-04-linux-package-design.md b/specs/2026-10-04-linux-package-design.md new file mode 100644 index 0000000..34da910 --- /dev/null +++ b/specs/2026-10-04-linux-package-design.md @@ -0,0 +1,188 @@ +# Linux support, step 2: a Linux package in release 1.5.0.0 + +Date: 2026-10-04. Status: approved design, amended after the final review (udev rule with final assignments, install.sh +checks, service hardening, invariant globalization, release token scope and dry run). + +## Context + +- Step 1 is merged (PR #7): the `net10.0` target of Canon.API runs on Linux x64 from the sources, verified with an EOS + R100. See `specs/2026-10-04-linux-edsdk-design.md`. +- Release 1.5.0.0 must ship a Linux build next to the Windows package. +- Today `.github/workflows/build.yml` runs one Windows job on a `v*` tag: tag check, version sync, build, tests, + `CanonWebAPI.zip` (win-x64), GitHub release, then `docs/autoupdate.xml`, which the photo booths read to update + themselves. The Windows package name, content and feed must not change. +- Decisions: x64 only; a `.tar.gz` usable by hand plus an optional `install.sh` (systemd service, USB permissions); + manual updates on Linux; an atomic release pipeline. +- Verified on the development machine (Ubuntu 26.04): `dotnet publish -f net10.0 -r linux-x64 --self-contained true + -p:PublishSingleFile=true` produces an executable `Canon.API` that needs no .NET native library next to it, only + `libEDSDK.so`. Without a camera, `/status` answers `"error": "No Canon camera detected: Device not found"`. SIGTERM + stops it cleanly in 2 s (the camera session is closed). The single file requires glibc 2.27 or later; `libEDSDK.so` + requires glibc 2.14 and libstdc++ (GLIBCXX 3.4.21), plus `libusb-1.0` and `libudev`. + +## Goals + +- Every release has a second asset, `CanonWebAPI-linux-x64.tar.gz`, built and checked in the same workflow as the + Windows package. +- On Linux the API runs by hand from the extracted folder, or as a systemd service installed by `install.sh`. +- A release is published only when both packages built, passed the tests, and started and loaded the EDSDK. +- The pull request CI starts both packages too (the published Windows executable was never run in CI). +- Release 1.5.0.0 is prepared (version, upgrade note, changelog); the tag is pushed only with the user's go-ahead. + +## Non-goals + +ARM (arm64, arm32), automatic update on Linux, `.deb` or other distribution packages, a Linux camera test script +(`tools/Test-Camera.ps1` needs PowerShell), Docker images for Linux. + +## Design + +### 1. The Linux package + +- Asset: `CanonWebAPI-linux-x64.tar.gz`, containing one folder `CanonWebAPI/`: + - `Canon.API` (self-contained single-file executable), `libEDSDK.so`, `appsettings.json`, + `appsettings.Development.json`, the `.pdb` files and `Canon.API.staticwebassets.endpoints.json` (as in the + Windows package); + - `install.sh`, `canonwebapi.service`, `60-canonwebapi.rules` (section 2). +- Built with `dotnet publish Canon.API/Canon.API.csproj -c Release -f net10.0 -r linux-x64 --self-contained true + -p:PublishSingleFile=true`. `EnableCompressionInSingleFile` applies to Linux as to Windows, and the `net10.0` target + uses `InvariantGlobalization`: the API only formats with the invariant culture, so no ICU library is needed. No + `IncludeNativeLibrariesForSelfExtract`: nothing would be extracted (verified above), and a service user has no home + directory to extract to. +- The package content is defined in `Canon.API.csproj`, like the Windows DLLs: for the `net10.0` target, the files of + `packaging/linux/` are copied to the publish folder only (not to the build output). `install.sh` keeps its + executable bit through the tar archive, which is created on Linux. +- Run by hand: `./Canon.API` listens on `http://localhost:5000`, like the Windows executable; `--urls` or + `ASPNETCORE_URLS` change it. Needs `libusb-1.0`, read/write access to the camera USB device (desktop session, or the + udev rule of `install.sh`), and no other program holding the camera. +- The Windows package is unchanged. + +### 2. `install.sh` + +Run as root from the extracted folder: `sudo ./install.sh` installs or updates, `sudo ./install.sh --uninstall` +removes. Files in `packaging/linux/`: `install.sh`, `canonwebapi.service`, `60-canonwebapi.rules`. + +Install or update: + +1. Checks, before anything changes: root, systemd running as init (`/run/systemd/system`), `x86_64`, + `libusb-1.0.so.0` known to `ldconfig`, and the package files next to the script (`Canon.API`, `libEDSDK.so`, + `canonwebapi.service`, `60-canonwebapi.rules`; the installed copy in `/opt/canonwebapi` only uninstalls). When + libusb is missing, prints `sudo apt install libusb-1.0-0` and stops (no package installed by the script). +2. Creates the system user `canonwebapi` when missing: no login shell, no home directory. +3. Stops the service when it runs (update). +4. Copies the program files to `/opt/canonwebapi`, owned by root. `logs/` is owned by `canonwebapi`. An existing + `appsettings.Local.json` is never overwritten or removed; none is created. +5. Installs `/etc/udev/rules.d/60-canonwebapi.rules`, reloads udev, replays an `add` event for the Canon USB devices + and waits for udev (`udevadm settle`). The rule gives Canon PTP cameras (interface `06/01/01`) `MODE:="0660"`, + `GROUP:="canonwebapi"`: final assignments, because `60-libgphoto2*.rules` sorts after it and sets `MODE="0664", + GROUP="plugdev"` on `add` and `bind` (it skips `change` events, so a `change` trigger would hide the problem until + the next replug or reboot). Canon printers and scanners are untouched. Desktop users keep their own access (the + `uaccess` ACL is separate from the group). +6. Installs `/etc/systemd/system/canonwebapi.service`, reloads systemd, enables and (re)starts the service, waits 3 s + and fails, pointing to `journalctl -u canonwebapi`, when it is not active; then prints the URL and its status. + +Service unit: + +```ini +[Unit] +Description=CanonWebAPI (Canon camera web API) +After=network.target + +[Service] +User=canonwebapi +Group=canonwebapi +WorkingDirectory=/opt/canonwebapi +ExecStart=/opt/canonwebapi/Canon.API +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes + +[Install] +WantedBy=multi-user.target +``` + +No `PrivateDevices` (USB camera), `MemoryDenyWriteExecute` (JIT) or `RestrictAddressFamilies` (hot plug uses netlink). +`ProtectSystem=full` leaves `/opt` writable for the logs. A fatal startup error (port in use, invalid +`appsettings.Local.json`) exits with code 1, so `Restart=on-failure` applies. + +systemd stops it with SIGTERM: ASP.NET Core shuts down cleanly and closes the camera session. Logs: the API files in +`/opt/canonwebapi/logs/` and `journalctl -u canonwebapi` (console output). + +Uninstall: stops and disables the service, removes the unit and the udev rule (then replays the `add` event, so a +plugged camera gets its default ownership back), `/opt/canonwebapi` (settings and logs included) and the +`canonwebapi` user. + +Update: extract the new archive, run its `install.sh`. + +### 3. Release workflow and CI + +A PowerShell script, `tools/Test-Package.ps1`, checks a published package: it starts the executable with +`--urls http://localhost:5000 --AutoUpdate:Enabled=false` from a temporary copy of its folder (its logs stay out of +the package), polls `GET /status` for up to 60 s, and passes when the answer is HTTP 200 with `connected: true` or an +`error` containing `No Canon camera detected` (the runners have no camera; a missing or unloadable `EDSDK.dll` / +`libEDSDK.so` gives another error). It stops the executable and prints its output on failure. +PowerShell 7 is installed on the GitHub Windows and Ubuntu runners. + +`.github/workflows/build.yml` (tag `v*`), jobs: + +1. `prepare` (ubuntu-latest): the current tag check and version sync steps (PowerShell, `shell: pwsh`); outputs the + commit to build and the version. +2. `windows` (windows-latest) and `linux` (ubuntu-latest), in parallel, both on the prepared commit: restore, build, + test, publish their runtime, `Test-Package.ps1`, then archive (`CanonWebAPI.zip` with `Compress-Archive` as today, + `CanonWebAPI-linux-x64.tar.gz` with `tar`, files owned by root in the archive) and upload it as a workflow + artifact. Both time out after 30 minutes. +3. `release` (ubuntu-latest), when both succeeded: downloads the two archives, creates the GitHub release with both + (generated notes, as today), then writes and commits `docs/autoupdate.xml` (Windows zip URL and UTF-8 BOM, as + today). + +Token scope: `contents: read` for the workflow; only `prepare` and `release` get `contents: write`. The build jobs run +NuGet build logic and the tests, so their checkout does not persist credentials. + +When the Windows or Linux job fails, nothing is released and the feed is not updated. When the `release` job fails +after creating the release, the feed is not updated and the booths stay on the previous version: rerun the failed job. +The workflow can also be run by hand from the Actions tab (`workflow_dispatch`): a dry run of `prepare` (no tag check, +no version commit), `windows`, `linux` and `release` (downloads both packages and generates the XML) that creates no +release and pushes nothing, to check the pipeline on `main` before tagging. + +`.github/workflows/ci.yml` (pull requests and `main`): the Windows job also publishes win-x64 and runs +`Test-Package.ps1`; a new Linux job (ubuntu-latest) restores, builds, tests, publishes linux-x64 and runs +`Test-Package.ps1`. + +### 4. Documentation + +- README: + - an "Installing on Linux" section: download and extract, prerequisites (`libusb-1.0`, glibc 2.27+, systemd for the + service), run by hand, `install.sh` (what it installs), update, uninstall, settings in + `/opt/canonwebapi/appsettings.Local.json`, logs, `systemctl status|restart canonwebapi`, `journalctl -u + canonwebapi`, and the desktop automount note (gvfs can hold the camera); + - "Running on Linux" stays the from-source section; + - System Requirements and the release steps mention both packages. +- CLAUDE.md: the two packages, `packaging/linux/`, `tools/Test-Package.ps1`, the release pipeline jobs. +- CHANGELOG (Unreleased): Linux package and `install.sh`; release pipeline and CI check both packages. + +### 5. Release 1.5.0.0 + +Last step, after the implementation: + +- Version 1.5.0.0 in `Canon.API/Canon.API.csproj`, the README ("Current version") and `docs/openapi.json`. +- README "Upgrading from 1.4.x" note: the flash setting is no longer written while the live view runs (a value set with + `POST /flash` stays until the live view stops); `dotnet run` and `dotnet publish` need `-f`; Linux package. +- CHANGELOG: `[Unreleased]` becomes `[1.5.0.0] - `, with a new empty `[Unreleased]` and updated links. +- The tag `v1.5.0.0` is pushed only with the user's go-ahead: it updates every photo booth. + +## Testing + +- Pull request CI: both packages start and load the EDSDK (`Test-Package.ps1`), on Windows and Linux runners. +- Development machine: build the Linux package with the release commands and extract it; run `./Canon.API` by hand; + run `install.sh` (sudo, by the user), check the service, the udev rule and the logs; camera test with the R100 + against the service (captures with the live view, flash change, power cycle); `install.sh --uninstall`. + +## Risks + +- The GitHub Ubuntu runner may not let the EDSDK initialize (no USB). `/status` would then report another error and + `Test-Package.ps1` would fail on the pull request, before any release: the expected message is adjusted there, still + rejecting library load failures. +- On a desktop, gvfs can mount the camera and hold it: documented (unmount it or disable the automount). +- `install.sh` changes the system (user, `/opt`, udev rule, service): it is tested on the development machine with + the user, then uninstalled. diff --git a/specs/2026-10-04-linux-package-plan.md b/specs/2026-10-04-linux-package-plan.md new file mode 100644 index 0000000..8d0e16b --- /dev/null +++ b/specs/2026-10-04-linux-package-plan.md @@ -0,0 +1,893 @@ +# Linux Package (Release 1.5.0.0) - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `CanonWebAPI-linux-x64.tar.gz` (with an optional systemd installer) next to the Windows package in every release, both built and checked by an atomic release pipeline, then prepare release 1.5.0.0. + +**Architecture:** The package content is defined in `Canon.API.csproj` for the `net10.0` target (publish-only files from `packaging/linux/`). `tools/Test-Package.ps1` starts a published package and checks that it loads the EDSDK; the CI and release workflows run it on Windows and Linux runners. The release workflow becomes prepare -> windows + linux (parallel) -> release. + +**Tech Stack:** .NET 10 (SDK 10.0.112 here), GitHub Actions (windows-latest, ubuntu-latest), PowerShell 7 (`pwsh`, on both runners), bash, systemd, udev. + +**Spec:** `specs/2026-10-04-linux-package-design.md` + +## Global Constraints + +- Branch `feature/linux-package`. Every commit message ends with `Co-Authored-By: Claude Opus 5.5 `. +- The Windows package does not change: asset `CanonWebAPI.zip`, same content, same `docs/autoupdate.xml` content and URL. +- Linux asset: `CanonWebAPI-linux-x64.tar.gz`, one top folder `CanonWebAPI/`. x64 only. +- Linux publish command: `dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true`. +- Install locations: `/opt/canonwebapi`, user and group `canonwebapi`, `/etc/systemd/system/canonwebapi.service`, `/etc/udev/rules.d/60-canonwebapi.rules`. API URL `http://localhost:5000`. +- Package check: `/status` answers with `connected: true` or an `error` containing `No Canon camera detected`. +- Code comments in English, senior altitude (only what the code cannot say). ASCII punctuation in code, comments, scripts and docs. +- Every change is recorded in the `Unreleased` section of `CHANGELOG.md`. +- PowerShell 7 for local checks: `/tmp/claude-1000/-home-gilbert-Documents-Project-CanonWebAPI/055083d5-ab07-4905-b425-34b4a1057fc9/scratchpad/pwsh/pwsh` (not installed system wide). +- Verified before this plan: with the Task 1 project settings, `dotnet build CanonSDK.sln` has 0 warnings, the `packaging/linux` files are not copied to the build output, and the linux-x64 publish folder contains `Canon.API` (executable, 53 MB compressed), `libEDSDK.so`, the three packaging files (`install.sh` keeps its executable bit), `appsettings*.json`, `*.pdb`, `Canon.API.staticwebassets.endpoints.json`. + +## File Structure + +| File | Change | +|------|--------| +| `Canon.API/Canon.API.csproj` | Linux publish settings and packaging items for `net10.0` | +| `packaging/linux/install.sh` | New: install, update, uninstall (executable) | +| `packaging/linux/canonwebapi.service` | New: systemd unit | +| `packaging/linux/60-canonwebapi.rules` | New: udev rule | +| `tools/Test-Package.ps1` | New: starts a published package and checks `/status` | +| `.github/workflows/ci.yml` | Windows job checks its package; new Linux job | +| `.github/workflows/build.yml` | prepare / windows / linux / release jobs; manual dry run | +| `README.md`, `CLAUDE.md`, `CHANGELOG.md` | Linux installation, CI, release pipeline; release 1.5.0.0 | +| `docs/openapi.json` | Version 1.5.0 (Task 6) | + +--- + +### Task 1: Linux package content and installer + +**Files:** +- Create: `packaging/linux/install.sh`, `packaging/linux/canonwebapi.service`, `packaging/linux/60-canonwebapi.rules` +- Modify: `Canon.API/Canon.API.csproj`, `README.md`, `CHANGELOG.md` + +**Interfaces:** +- Produces: a linux-x64 publish folder containing `Canon.API`, `libEDSDK.so`, `install.sh` (executable), `canonwebapi.service`, `60-canonwebapi.rules` at its root. `install.sh` usage: `sudo ./install.sh` (install or update), `sudo ./install.sh --uninstall`. + +- [ ] **Step 1: Check that the packaging files are not published yet** + +```bash +O=$(mktemp -d) +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output "$O" +ls "$O" +``` + +Expected: no `install.sh`, `canonwebapi.service` or `60-canonwebapi.rules`; `Canon.API` is about 100 MB (not compressed). + +- [ ] **Step 2: The systemd unit** + +Create `packaging/linux/canonwebapi.service`: + +```ini +[Unit] +Description=CanonWebAPI (Canon camera web API) +After=network.target + +[Service] +User=canonwebapi +Group=canonwebapi +WorkingDirectory=/opt/canonwebapi +ExecStart=/opt/canonwebapi/Canon.API +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 3: The udev rule** + +Create `packaging/linux/60-canonwebapi.rules`: + +``` +# CanonWebAPI: Canon cameras (USB vendor 04a9) can be opened by the canonwebapi service user. +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="04a9", MODE="0660", GROUP="canonwebapi" +``` + +- [ ] **Step 4: The installer** + +Create `packaging/linux/install.sh`, then `chmod 755 packaging/linux/install.sh` (git records the executable bit): + +```bash +#!/usr/bin/env bash +# Installs or updates CanonWebAPI as a systemd service from an extracted Linux package, or removes it. +# sudo ./install.sh install or update (appsettings.Local.json and the logs are kept) +# sudo ./install.sh --uninstall remove the service, the udev rule, /opt/canonwebapi and the canonwebapi user +set -euo pipefail + +readonly app_dir=/opt/canonwebapi +readonly service=canonwebapi +readonly user=canonwebapi +readonly unit_file=/etc/systemd/system/canonwebapi.service +readonly rule_file=/etc/udev/rules.d/60-canonwebapi.rules +package_dir="$(cd "$(dirname "$0")" && pwd)" +readonly package_dir + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +uninstall() { + systemctl disable --now "$service" 2>/dev/null || true + rm -f "$unit_file" "$rule_file" + systemctl daemon-reload + udevadm control --reload + rm -rf "$app_dir" + if id "$user" >/dev/null 2>&1; then + userdel "$user" + fi + echo "CanonWebAPI removed." +} + +install_package() { + [ "$(uname -m)" = x86_64 ] || fail "this package is for x86_64, not $(uname -m)." + # No grep -q: with pipefail, grep exiting early would fail the pipeline through SIGPIPE. + ldconfig -p | grep 'libusb-1.0.so.0' >/dev/null || fail "libusb-1.0 is missing: sudo apt install libusb-1.0-0" + [ -f "$package_dir/Canon.API" ] || fail "Canon.API not found next to the script: run it from the extracted package." + [ "$package_dir" != "$app_dir" ] || fail "run the install.sh of the extracted package, not the installed one." + + if ! id "$user" >/dev/null 2>&1; then + useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$user" + fi + + systemctl stop "$service" 2>/dev/null || true + + install -d -o root -g root -m 755 "$app_dir" + install -d -o "$user" -g "$user" -m 755 "$app_dir/logs" + + # Program files only: appsettings.Local.json and the logs of the installation are kept. + for file in "$package_dir"/*; do + name="$(basename "$file")" + case "$name" in + appsettings.Local.json | canonwebapi.service | 60-canonwebapi.rules) continue ;; + esac + [ -f "$file" ] || continue + install -o root -g root -m 644 "$file" "$app_dir/$name" + done + chmod 755 "$app_dir/Canon.API" "$app_dir/install.sh" + + install -o root -g root -m 644 "$package_dir/60-canonwebapi.rules" "$rule_file" + udevadm control --reload + # Applies the rule to a camera that is already plugged in. + udevadm trigger --subsystem-match=usb --attr-match=idVendor=04a9 + + install -o root -g root -m 644 "$package_dir/canonwebapi.service" "$unit_file" + systemctl daemon-reload + systemctl enable "$service" + systemctl restart "$service" + + echo "CanonWebAPI is installed in $app_dir and runs as the $service service: http://localhost:5000/status" + echo "Settings: $app_dir/appsettings.Local.json. Logs: $app_dir/logs and journalctl -u $service" + systemctl --no-pager status "$service" || true +} + +[ "$(id -u)" -eq 0 ] || fail "run it as root: sudo $0 $*" +command -v systemctl >/dev/null || fail "systemd is required." + +case "${1:-}" in + "") install_package ;; + --uninstall) uninstall ;; + *) fail "unknown option $1. Usage: sudo $0 [--uninstall]" ;; +esac +``` + +- [ ] **Step 5: Publish settings and packaging items** + +In `Canon.API/Canon.API.csproj`, after the `PropertyGroup` conditioned on `net10.0-windows` (Self-Contained Single-File Deployment), add: + +```xml + + + true + +``` + +(XML comments cannot contain `--`: do not put command line options in them.) + +After the `ItemGroup` that copies `..\EDSDK\linux-x64\libEDSDK.so` for `net10.0`, add: + +```xml + + + + +``` + +- [ ] **Step 6: Check the package content** + +```bash +dotnet build CanonSDK.sln +ls Canon.API/bin/Debug/net10.0/ | grep -c -E 'install.sh|canonwebapi' # 0: publish only +O=$(mktemp -d) +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output "$O" +ls -l "$O" +bash -n packaging/linux/install.sh && echo "syntax OK" +dotnet test CanonSDK.sln +``` + +Expected: build 0 warnings, 0 errors; the grep count is 0; the publish folder lists `Canon.API` (executable, about 53 MB), `libEDSDK.so`, `install.sh` (executable), `canonwebapi.service`, `60-canonwebapi.rules`, `appsettings.json`, `appsettings.Development.json`, the `.pdb` files and `Canon.API.staticwebassets.endpoints.json`; `syntax OK`; tests `Failed: 0`. If `shellcheck` can be installed without root (e.g. `pip install --user shellcheck-py`), run `shellcheck packaging/linux/install.sh` and fix its warnings; otherwise say so in the report. + +- [ ] **Step 7: Documentation** + +`README.md`: after the "## Automatic Updates" section (and its last line "Linux has no automatic update yet..."), add: + +````markdown +## Installing on Linux + +Each release also provides `CanonWebAPI-linux-x64.tar.gz`: a self-contained executable for Linux x64 (no .NET to +install) with the Linux version of the EDSDK. Requirements: glibc 2.27 or later (e.g. Ubuntu 18.04, Debian 10 or +later), `libusb-1.0` (`sudo apt install libusb-1.0-0`), and systemd for the service. There is no automatic update on +Linux. + +```bash +tar -xzf CanonWebAPI-linux-x64.tar.gz +cd CanonWebAPI +./Canon.API # by hand: http://localhost:5000 +sudo ./install.sh # or as a service started at boot +``` + +`install.sh` copies the files to `/opt/canonwebapi`, creates the `canonwebapi` system user, adds a udev rule that lets +it open Canon cameras (USB vendor `04a9`), then installs, enables and starts the `canonwebapi` systemd service. + +* Settings: `/opt/canonwebapi/appsettings.Local.json` (kept by updates), then `sudo systemctl restart canonwebapi`. +* Logs: `/opt/canonwebapi/logs/` and `journalctl -u canonwebapi`; state: `systemctl status canonwebapi`. +* Update: extract the new archive and run its `install.sh`. +* Uninstall: `sudo /opt/canonwebapi/install.sh --uninstall` removes the service, the udev rule, `/opt/canonwebapi` + (settings and logs included) and the `canonwebapi` user. +* Run by hand, the API needs read/write access to the camera USB device: desktop sessions get it, otherwise install + the service. A desktop can mount the camera (GNOME: gvfs) and hold it: unmount it (`gio mount -l`, then + `gio mount -u `) or disable the automount. +```` + +In "### Running on Linux", replace "There is no automatic update and no release package for Linux yet: run it from the sources." with "To run a release, see [Installing on Linux](#installing-on-linux); this section runs it from the sources." + +In "### System Requirements", replace the OS line with: + +```markdown +* **OS**: Windows 10/11 (x64): `CanonWebAPI.zip`; Linux x64 (glibc 2.27+, `libusb-1.0`): `CanonWebAPI-linux-x64.tar.gz` (see [Installing on Linux](#installing-on-linux)) +``` + +`CHANGELOG.md`, `## [Unreleased]` / `### Added`, first bullet: + +```markdown +- Linux package in every release: `CanonWebAPI-linux-x64.tar.gz`, a self-contained executable for Linux x64 with the + Linux EDSDK, and `install.sh`, which installs it as a systemd service with the USB access it needs + (`/opt/canonwebapi`, `canonwebapi` user, udev rule); `install.sh --uninstall` removes it. +``` + +- [ ] **Step 8: Commit** + +```bash +git add packaging/linux Canon.API/Canon.API.csproj README.md CHANGELOG.md +git commit -m "Add the Linux package content and its systemd installer + +Co-Authored-By: Claude Opus 5.5 " +git ls-files -s packaging/linux/install.sh # mode 100755 +``` + +--- + +### Task 2: Package check script and CI on Windows and Linux + +**Files:** +- Create: `tools/Test-Package.ps1` +- Modify: `.github/workflows/ci.yml`, `CLAUDE.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: the Linux publish folder of Task 1. +- Produces: `./tools/Test-Package.ps1 -Executable [-Url http://localhost:5000] [-TimeoutSeconds 60]`; exit code 0 when the package loads the EDSDK, 1 otherwise. Used by Task 3. + +- [ ] **Step 1: The script** + +Create `tools/Test-Package.ps1`: + +```powershell +<# +.SYNOPSIS + Checks that a published CanonWebAPI package starts and loads the Canon EDSDK. + +.DESCRIPTION + Copies the folder of the executable to a temporary folder, so that its logs do not end up in the package, starts + it, polls GET /status and passes when the API answers with a connected camera or "No Canon camera detected" (no + camera on the machine). Any other answer, such as an EDSDK library that cannot be loaded, fails. Used by the CI and + release workflows on the Windows and Linux runners (PowerShell 7). + +.EXAMPLE + ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Executable, + [string]$Url = 'http://localhost:5000', + [int]$TimeoutSeconds = 60 +) + +$ErrorActionPreference = 'Stop' + +$source = (Resolve-Path $Executable).Path +$work = Join-Path ([System.IO.Path]::GetTempPath()) ('canonwebapi-package-' + [guid]::NewGuid()) +Copy-Item (Split-Path $source) $work -Recurse +$exe = Join-Path $work (Split-Path $source -Leaf) +if ($IsLinux) { & chmod 755 $exe } +$output = Join-Path $work 'console.log' +$errorOutput = Join-Path $work 'console-error.log' + +$process = Start-Process $exe -WorkingDirectory $work -PassThru -RedirectStandardOutput $output ` + -RedirectStandardError $errorOutput -ArgumentList @("--urls=$Url", '--AutoUpdate:Enabled=false') +$status = $null + +try { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while (-not $status -and -not $process.HasExited -and (Get-Date) -lt $deadline) { + try { + $status = Invoke-RestMethod "$Url/status" -TimeoutSec 10 + } + catch { + Start-Sleep -Seconds 1 + } + } +} +finally { + if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force } + $process.WaitForExit() +} + +if ($status -and ($status.connected -or "$($status.error)" -match 'No Canon camera detected')) { + $detail = if ($status.connected) { "camera $($status.cameraName) connected" } else { $status.error } + Write-Host "Package OK: $detail" + Remove-Item $work -Recurse -Force + exit 0 +} + +$answer = if ($status) { $status | ConvertTo-Json -Compress } else { 'no answer' } +Write-Host "Package check failed. /status: $answer" +Write-Host '--- application output ---' +Get-Content $output, $errorOutput -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } +exit 1 +``` + +- [ ] **Step 2: Check it locally (pass, then fail)** + +```bash +PWSH=/tmp/claude-1000/-home-gilbert-Documents-Project-CanonWebAPI/055083d5-ab07-4905-b425-34b4a1057fc9/scratchpad/pwsh/pwsh +O=$(mktemp -d) +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output "$O" +$PWSH -NoProfile -File tools/Test-Package.ps1 -Executable "$O/Canon.API" -Url http://localhost:5098; echo "exit $?" +ls "$O" # no logs folder: the check ran in a copy +B=$(mktemp -d); cp -a "$O"/. "$B"/; rm "$B/libEDSDK.so" +$PWSH -NoProfile -File tools/Test-Package.ps1 -Executable "$B/Canon.API" -Url http://localhost:5098; echo "exit $?" +``` + +Expected: the first run prints `Package OK: No Canon camera detected: Device not found` (or `camera Canon EOS R100 connected` when a camera is plugged in and free) and exit 0; the publish folder has no `logs`; the second run prints `Package check failed` with an `/status` error about loading `EDSDK` (`Unable to load shared library 'EDSDK'...`) and exit 1. Do not leave any `Canon.API` process running (`ss -ltn | grep 5098` prints nothing). + +- [ ] **Step 3: CI on Windows and Linux** + +Replace the whole `.github/workflows/ci.yml` with: + +```yaml +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build --logger trx --results-directory TestResults + + - name: Publish the Windows package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + + - name: Check that the Windows package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe + shell: pwsh + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-windows + path: TestResults + + build-and-test-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install libusb (needed by the Linux EDSDK) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0 + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build --logger trx --results-directory TestResults + + - name: Publish the Linux package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + + - name: Check that the Linux package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API + shell: pwsh + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-linux + path: TestResults +``` + +Check the syntax: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('ci.yml OK')"` + +- [ ] **Step 4: Documentation** + +`CLAUDE.md`, section "## Testing", add a bullet after the Canon.API.Tests one: + +```markdown +- CI (pull requests and `main`): build and tests on Windows and Linux runners, then both packages are published and started by `tools/Test-Package.ps1`, which checks that they load the EDSDK (`/status`: "No Canon camera detected") +``` + +`CHANGELOG.md`, `## [Unreleased]` / `### Changed`, add: + +```markdown +- CI builds and tests on Windows and Linux, then publishes the Windows and Linux packages and starts them to check that + they load the EDSDK (`tools/Test-Package.ps1`). The published Windows executable was never run before a release. +``` + +- [ ] **Step 5: Commit** + +```bash +git add tools/Test-Package.ps1 .github/workflows/ci.yml CLAUDE.md CHANGELOG.md +git commit -m "Check both packages in CI on Windows and Linux runners + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Atomic release pipeline + +**Files:** +- Modify: `.github/workflows/build.yml`, `README.md`, `CLAUDE.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: `tools/Test-Package.ps1` (Task 2), the Linux package content (Task 1). +- Produces: on a `v*` tag, a GitHub release with `CanonWebAPI.zip` and `CanonWebAPI-linux-x64.tar.gz`, then `docs/autoupdate.xml` committed to `main`; from the Actions tab (`workflow_dispatch`), a dry run that builds, tests and checks both packages and publishes nothing. + +- [ ] **Step 1: The workflow** + +Replace the whole `.github/workflows/build.yml` with: + +```yaml +name: Release CanonWebAPI + +on: + push: + tags: + - 'v*' + # Dry run from the Actions tab: builds, tests and checks both packages of the selected branch; publishes nothing. + workflow_dispatch: + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.commit.outputs.SHA }} + version: ${{ steps.version.outputs.VERSION }} + + steps: + # A release is built from main: this workflow commits the version and the AutoUpdater XML to it. + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'push' && 'main' || github.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + # Refuse to release if main is not the tagged commit, so the packages always match the tag. + - name: Check that the tag points to main + if: github.event_name == 'push' + shell: pwsh + run: | + $mainSha = git rev-parse HEAD + $tagSha = "${{ github.sha }}" + if ($mainSha -ne $tagSha) { + Write-Error "Tag ${{ github.ref_name }} points to $tagSha but main is at $mainSha. Tag the latest commit of main." + exit 1 + } + echo "Tag ${{ github.ref_name }} matches main ($mainSha)" + + - name: Get the version + id: version + shell: pwsh + run: | + if ("${{ github.event_name }}" -eq "push") { + $version = "${{ github.ref_name }}" -replace '^v', '' + } + else { + $version = [regex]::Match((Get-Content Canon.API/Canon.API.csproj -Raw), '(.*)').Groups[1].Value + } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + echo "Version: $version" + + - name: Check and update project version + if: github.event_name == 'push' + id: version_check + shell: pwsh + run: | + $tagVersion = "${{ steps.version.outputs.VERSION }}" + + # Read current version from project file + $projectFile = "Canon.API/Canon.API.csproj" + $projectContent = Get-Content $projectFile + $currentVersion = "" + + foreach ($line in $projectContent) { + if ($line -match '(.*)') { + $currentVersion = $matches[1] + break + } + } + + echo "Current project version: $currentVersion" + echo "Tag version: $tagVersion" + + if ($currentVersion -ne $tagVersion) { + echo "VERSION_MISMATCH=true" >> $env:GITHUB_OUTPUT + echo "Versions do not match - updating project files" + + # Update Canon.API project file + $projectContent -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" | Set-Content $projectFile + + # Update Canon.Core project file if it has version info + $coreProjectFile = "Canon.Core/Canon.Core.csproj" + if (Test-Path $coreProjectFile) { + $coreContent = Get-Content $coreProjectFile + if ($coreContent -match '') { + $coreContent -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" -replace '.*', "$tagVersion" | Set-Content $coreProjectFile + } + } + } else { + echo "VERSION_MISMATCH=false" >> $env:GITHUB_OUTPUT + echo "Versions match - no update needed" + } + + - name: Commit version updates + if: github.event_name == 'push' && steps.version_check.outputs.VERSION_MISMATCH == 'true' + shell: pwsh + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add "*.csproj" + git commit -m "Update project versions to ${{ steps.version.outputs.VERSION }}" + git push origin main + + - name: Commit to build + id: commit + shell: pwsh + run: echo "SHA=$(git rev-parse HEAD)" >> $env:GITHUB_OUTPUT + + windows: + needs: prepare + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build + + - name: Publish the Windows package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0-windows --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output ./publish + + - name: Check that the Windows package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe + shell: pwsh + + - name: Create the Windows archive + run: Compress-Archive -Path "./publish/*" -DestinationPath "./CanonWebAPI.zip" + shell: powershell + + - name: Upload the Windows archive + uses: actions/upload-artifact@v4 + with: + name: windows-package + path: CanonWebAPI.zip + if-no-files-found: error + + linux: + needs: prepare + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install libusb (needed by the Linux EDSDK) + run: sudo apt-get update && sudo apt-get install -y libusb-1.0-0 + + - name: Restore dependencies + run: dotnet restore CanonSDK.sln + + - name: Build solution + run: dotnet build CanonSDK.sln --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test CanonSDK.sln --configuration Release --no-build + + - name: Publish the Linux package + run: dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output ./package/CanonWebAPI + + - name: Check that the Linux package starts and loads the EDSDK + run: ./tools/Test-Package.ps1 -Executable ./package/CanonWebAPI/Canon.API + shell: pwsh + + - name: Create the Linux archive + run: | + chmod 755 package/CanonWebAPI/Canon.API package/CanonWebAPI/install.sh + tar -czf CanonWebAPI-linux-x64.tar.gz -C package CanonWebAPI + + - name: Upload the Linux archive + uses: actions/upload-artifact@v4 + with: + name: linux-package + path: CanonWebAPI-linux-x64.tar.gz + if-no-files-found: error + + # Only when both packages were built and checked: a failure publishes nothing and leaves the photo booths alone. + release: + needs: [prepare, windows, linux] + if: github.event_name == 'push' + runs-on: ubuntu-latest + + steps: + - name: Checkout main + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download the packages + uses: actions/download-artifact@v4 + with: + path: packages + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: | + packages/CanonWebAPI.zip + packages/CanonWebAPI-linux-x64.tar.gz + fail_on_unmatched_files: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update AutoUpdater XML + shell: pwsh + run: | + $version = "${{ needs.prepare.outputs.version }}" + $releaseUrl = "https://github.com/${{ github.repository }}/releases/download/v$version/CanonWebAPI.zip" + + # Create docs folder if it doesn't exist + if (!(Test-Path "docs")) { + New-Item -ItemType Directory -Path "docs" + } + + # Create AutoUpdater XML content + $xmlContent = @" + + + $version + $releaseUrl + https://github.com/${{ github.repository }}/releases/tag/v$version + true + + "@ + + $xmlContent | Out-File -FilePath "docs/autoupdate.xml" -Encoding UTF8 + + - name: Commit AutoUpdater XML + shell: pwsh + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add docs/autoupdate.xml + if (!(git diff --staged --quiet)) { + git commit -m "Update AutoUpdater XML for version ${{ needs.prepare.outputs.version }}" + git push origin main + } +``` + +Check the syntax and the wiring: + +```bash +python3 - <<'EOF' +import yaml +w = yaml.safe_load(open('.github/workflows/build.yml')) +jobs = w['jobs'] +assert list(jobs) == ['prepare', 'windows', 'linux', 'release'], list(jobs) +assert jobs['release']['needs'] == ['prepare', 'windows', 'linux'] +assert jobs['windows']['needs'] == 'prepare' and jobs['linux']['needs'] == 'prepare' +print('build.yml OK, triggers:', list(w[True])) +EOF +``` + +Expected: `build.yml OK, triggers: ['push', 'workflow_dispatch']` (PyYAML reads the `on` key as `True`). + +- [ ] **Step 2: Documentation** + +`README.md`, "### Creating Releases": replace items 4 and 5 with: + +```markdown +4. **Packages**: The workflow builds and tests the solution, publishes the Windows and Linux packages and starts both to check that they load the EDSDK (`tools/Test-Package.ps1`) +5. **Atomic release**: Only when both packages succeeded, it creates the release (`CanonWebAPI.zip`, `CanonWebAPI-linux-x64.tar.gz`) and updates the AutoUpdater XML; a failure publishes nothing +``` + +and add after the list (before the `docs/autoupdate.xml` sentence): + +```markdown +To check the pipeline before tagging, run the "Release CanonWebAPI" workflow by hand from the Actions tab (on `main`): it builds, tests and checks both packages and publishes nothing. +``` + +`CLAUDE.md`, "## Release Process": replace the five bullets ("Trigger: ..." to "Updates AutoUpdater XML automatically") with: + +```markdown +- Trigger: push a tag matching `v*` on the latest commit of `main` (or run the workflow by hand: dry run, nothing published) +- `prepare` job: checks the tag, compares the tag version with the project versions and commits them if they differ +- `windows` and `linux` jobs, in parallel: build, test, publish `CanonWebAPI.zip` (win-x64) and `CanonWebAPI-linux-x64.tar.gz` (linux-x64, with `packaging/linux/`), and start each package with `tools/Test-Package.ps1` +- `release` job, only when both succeeded: creates the GitHub release with both packages and updates the AutoUpdater XML (Windows package) +``` + +`CHANGELOG.md`, `## [Unreleased]` / `### Changed`, add: + +```markdown +- The release workflow builds, tests and checks the Windows and Linux packages in parallel, and creates the release + (both packages) and updates the AutoUpdater XML only when both succeeded. It can be run by hand from the Actions tab + as a dry run that publishes nothing. +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/build.yml README.md CLAUDE.md CHANGELOG.md +git commit -m "Build, check and release the Windows and Linux packages together + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Pull request and CI (controller) + +**Files:** none. + +- [ ] **Step 1:** Push `feature/linux-package` and open a pull request to `main` (body ends with the Claude Code line). Wait for the CI: `build-and-test` (Windows) and `build-and-test-linux` must pass, including both package checks. +- [ ] **Step 2:** If the Linux package check fails on the runner with another `/status` error than a library load failure (spec, Risks), report it to the user before changing the expected message. + +--- + +### Task 5: Installation test with the camera (manual, with the user) + +**Files:** none (findings go to the user). + +- [ ] **Step 1: Build the archive exactly as the release job** + +```bash +S=/tmp/claude-1000/-home-gilbert-Documents-Project-CanonWebAPI/055083d5-ab07-4905-b425-34b4a1057fc9/scratchpad/linux-package +rm -rf "$S" && mkdir -p "$S/package" +dotnet publish Canon.API/Canon.API.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained true -p:PublishSingleFile=true --output "$S/package/CanonWebAPI" +chmod 755 "$S/package/CanonWebAPI/Canon.API" "$S/package/CanonWebAPI/install.sh" +tar -czf "$S/CanonWebAPI-linux-x64.tar.gz" -C "$S/package" CanonWebAPI +mkdir "$S/extract" && tar -xzf "$S/CanonWebAPI-linux-x64.tar.gz" -C "$S/extract" && ls -l "$S/extract/CanonWebAPI" +``` + +- [ ] **Step 2: Install as a service.** Ask the user to plug the camera into this computer (not the VM) and to run `! sudo /tmp/claude-1000/-home-gilbert-Documents-Project-CanonWebAPI/055083d5-ab07-4905-b425-34b4a1057fc9/scratchpad/linux-package/extract/CanonWebAPI/install.sh`. Then check: + +```bash +systemctl status canonwebapi --no-pager +ls -l /opt/canonwebapi /opt/canonwebapi/logs +ls -l /dev/bus/usb/$(lsusb -d 04a9: | awk '{print $2 "/" substr($4, 1, 3)}') # group canonwebapi +curl -s http://localhost:5000/status +``` + +Expected: service active; files owned by root, `logs/` by `canonwebapi`; the camera device in group `canonwebapi`; `/status` connected. +- [ ] **Step 3: Camera test against the service:** + +```bash +/tmp/claude-1000/-home-gilbert-Documents-Project-CanonWebAPI/055083d5-ab07-4905-b425-34b4a1057fc9/scratchpad/pwsh/pwsh -NoProfile -File tools/Test-Camera.ps1 -BaseUrl http://localhost:5000 -NoBrowser +``` + +(captures with the live view, flash changes, power cycle). Then read `/opt/canonwebapi/logs/canon-api*.log` and `journalctl -u canonwebapi --no-pager | tail -50`. +- [ ] **Step 4: Update and uninstall.** Ask the user to run the `install.sh` of the extracted package again (update: service restarted, files replaced), then `! sudo /opt/canonwebapi/install.sh --uninstall`. Check that `systemctl status canonwebapi`, `/etc/udev/rules.d/60-canonwebapi.rules`, `/opt/canonwebapi` and `id canonwebapi` are all gone. + +--- + +### Task 6: Release 1.5.0.0 (controller, with the user) + +**Files:** +- Modify: `Canon.API/Canon.API.csproj`, `docs/openapi.json`, `README.md`, `CHANGELOG.md` + +- [ ] **Step 1: Version.** `Canon.API/Canon.API.csproj`: `AssemblyVersion`, `FileVersion` and `Version` to `1.5.0.0`. `docs/openapi.json`: `"version": "1.4.0"` to `"version": "1.5.0"`. README: "**Current version: 1.4.0.0**" to "**Current version: 1.5.0.0**", and the example tag in "Creating Releases" to `v1.5.0.0`. +- [ ] **Step 2: Upgrade note.** README, after the "Upgrading from 1.3.x" note: + +```markdown +> **Upgrading from 1.4.x**: the "flash firing" setting is no longer written while the live view runs (it froze the EOS R100): with `Canon:ForceFlashFiring` it is set when the camera connects, before the live view starts and before a capture taken without live view, and a value set with `POST /flash` stays until the live view stops. Linux is supported (see [Installing on Linux](#installing-on-linux)). From the sources, `dotnet run` and `dotnet publish` need `-f net10.0-windows` or `-f net10.0`. See the [changelog](CHANGELOG.md#1500---). +``` + +(`` is the release date in `YYYY-MM-DD` form, the same as in the changelog heading.) + +- [ ] **Step 3: Changelog.** `## [Unreleased]` becomes `## [1.5.0.0] - ` with a new empty `## [Unreleased]` above it; the links at the end: `[Unreleased]: .../compare/v1.5.0.0...HEAD` and `[1.5.0.0]: .../compare/v1.4.0.0...v1.5.0.0`. +- [ ] **Step 4:** Build and tests (`dotnet build CanonSDK.sln`, `dotnet test CanonSDK.sln`), commit "Bump version to 1.5.0.0 and date the 1.5.0.0 release", push, wait for the CI. +- [ ] **Step 5:** After the user merges the pull request: run the "Release CanonWebAPI" workflow by hand on `main` (dry run) and check that `windows` and `linux` pass. Then, only with the user's go-ahead, tag the latest commit of `main` with `v1.5.0.0` and push the tag; check the release assets and `docs/autoupdate.xml`. diff --git a/tools/Test-Package.ps1 b/tools/Test-Package.ps1 new file mode 100644 index 0000000..fd14fd6 --- /dev/null +++ b/tools/Test-Package.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS + Checks that a published CanonWebAPI package starts and loads the Canon EDSDK. + +.DESCRIPTION + Copies the folder of the executable to a temporary folder, so that its logs do not end up in the package, starts + it, polls GET /status and passes when the API answers with a connected camera or "No Canon camera detected" (no + camera on the machine). Any other answer, such as an EDSDK library that cannot be loaded, fails. Used by the CI and + release workflows on the Windows and Linux runners (PowerShell 7). + +.EXAMPLE + ./tools/Test-Package.ps1 -Executable ./publish/Canon.API.exe +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Executable, + [string]$Url = 'http://localhost:5000', + [int]$TimeoutSeconds = 60 +) + +$ErrorActionPreference = 'Stop' + +$source = (Resolve-Path $Executable).Path +$work = Join-Path ([System.IO.Path]::GetTempPath()) ('canonwebapi-package-' + [guid]::NewGuid()) +Copy-Item (Split-Path $source) $work -Recurse +$exe = Join-Path $work (Split-Path $source -Leaf) +if ($IsLinux) { & chmod 755 $exe } +$output = Join-Path $work 'console.log' +$errorOutput = Join-Path $work 'console-error.log' + +$process = Start-Process $exe -WorkingDirectory $work -PassThru -RedirectStandardOutput $output ` + -RedirectStandardError $errorOutput -ArgumentList @("--urls=$Url", '--AutoUpdate:Enabled=false') +$status = $null + +try { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while (-not $status -and -not $process.HasExited -and (Get-Date) -lt $deadline) { + try { + $status = Invoke-RestMethod "$Url/status" -TimeoutSec 10 + } + catch { + Start-Sleep -Seconds 1 + } + } +} +finally { + if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force } + $process.WaitForExit() +} + +if ($status -and ($status.connected -or "$($status.error)" -match 'No Canon camera detected')) { + $detail = if ($status.connected) { "camera $($status.cameraName) connected" } else { $status.error } + Write-Host "Package OK: $detail" + Remove-Item $work -Recurse -Force + exit 0 +} + +$answer = if ($status) { $status | ConvertTo-Json -Compress } else { 'no answer' } +Write-Host "Package check failed. /status: $answer" +Write-Host '--- application output ---' +Get-Content $output, $errorOutput -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } +exit 1