diff --git a/.gitea/workflows/desktop-release.yml b/.gitea/workflows/desktop-release.yml index cada220..4f72a4b 100644 --- a/.gitea/workflows/desktop-release.yml +++ b/.gitea/workflows/desktop-release.yml @@ -21,8 +21,76 @@ permissions: releases: read jobs: + bootstrap-updater-key: + name: Bootstrap updater signing key + runs-on: ubuntu-latest + permissions: + code: write + actions: write + outputs: + ready: ${{ steps.bootstrap.outputs.ready }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Bootstrap signing key if missing + id: bootstrap + shell: bash + env: + EXISTING_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + run: | + set -euo pipefail + + if [ -n "$EXISTING_SIGNING_KEY" ] && ! grep -q '__FIELDLENS_UPDATER_PUBKEY__' src-tauri/tauri.conf.json; then + echo "ready=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + npm install + npm run tauri signer generate -- --ci -w /tmp/fieldlens-updater.key >/tmp/tauri-signer.log 2>&1 + + test -s /tmp/fieldlens-updater.key + test -s /tmp/fieldlens-updater.key.pub + + api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}" + jq -n --rawfile data /tmp/fieldlens-updater.key \ + '{data:$data, description:"FieldLens Print Tauri updater private signing key"}' \ + >/tmp/updater-secret.json + + curl -fsS \ + -X PUT \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/updater-secret.json \ + "$api/actions/secrets/TAURI_SIGNING_PRIVATE_KEY" >/dev/null + + node <<'NODE' + const fs = require('fs') + const path = 'src-tauri/tauri.conf.json' + const config = JSON.parse(fs.readFileSync(path, 'utf8')) + config.plugins.updater.pubkey = fs.readFileSync('/tmp/fieldlens-updater.key.pub', 'utf8').trim() + fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n') + NODE + + git config user.name "FieldLens Desktop CI" + git config user.email "ci@noreply.git.arrowstack.dev" + git add src-tauri/tauri.conf.json + git commit -m "chore: initialize updater public key" + git push origin HEAD:main + + rm -f /tmp/fieldlens-updater.key /tmp/fieldlens-updater.key.pub /tmp/updater-secret.json + echo "ready=false" >> "$GITHUB_OUTPUT" + windows-nsis: name: Windows x64 NSIS + needs: bootstrap-updater-key + if: needs.bootstrap-updater-key.outputs.ready == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,29 +112,35 @@ jobs: - name: Install Node dependencies run: npm install - - name: Build Windows installer + - name: Build signed Windows installer run: npm run bundle:windows env: XWIN_CACHE_DIR: ${{ runner.temp }}/xwin-cache + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" - - name: Stage Windows installer + - name: Stage Windows installer and signature shell: bash run: | set -euo pipefail source_file="$(find src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis -maxdepth 1 -type f -name '*-setup.exe' -print -quit)" test -n "$source_file" + test -s "$source_file.sig" mkdir -p dist cp "$source_file" dist/fieldlens-print-windows-x64-setup.exe + cp "$source_file.sig" dist/fieldlens-print-windows-x64-setup.exe.sig - uses: actions/upload-artifact@v3 with: name: fieldlens-print-windows-x64 - path: dist/fieldlens-print-windows-x64-setup.exe + path: dist/* if-no-files-found: error retention-days: 14 - linux-deb: - name: Linux x64 DEB + linux: + name: Linux x64 DEB + AppImage + needs: bootstrap-updater-key + if: needs.bootstrap-updater-key.outputs.ready == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -95,49 +169,47 @@ jobs: - name: Install Node dependencies run: npm install - - name: Build Debian package - run: npm run bundle:deb + - name: Build signed Linux packages + run: npm run bundle:linux + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" - - name: Stage Debian package + - name: Stage Linux packages and updater signature shell: bash run: | set -euo pipefail - source_file="$(find src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)" - test -n "$source_file" + deb_file="$(find src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' -print -quit)" + appimage_file="$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' -print -quit)" + test -n "$deb_file" + test -n "$appimage_file" + test -s "$appimage_file.sig" mkdir -p dist - cp "$source_file" dist/fieldlens-print-linux-x64.deb + cp "$deb_file" dist/fieldlens-print-linux-x64.deb + cp "$appimage_file" dist/fieldlens-print-linux-x64.AppImage + cp "$appimage_file.sig" dist/fieldlens-print-linux-x64.AppImage.sig - uses: actions/upload-artifact@v3 with: - name: fieldlens-print-linux-x64-deb - path: dist/fieldlens-print-linux-x64.deb + name: fieldlens-print-linux-x64 + path: dist/* if-no-files-found: error retention-days: 14 publish-release: name: Publish public desktop release - needs: [windows-nsis, linux-deb] + needs: [bootstrap-updater-key, windows-nsis, linux] + if: needs.bootstrap-updater-key.outputs.ready == 'true' runs-on: ubuntu-latest permissions: - code: read + code: write releases: write steps: - uses: actions/checkout@v4 - - name: Resolve release tag - id: release - shell: bash - run: | - if [[ "${GITEA_REF}" == refs/tags/* ]]; then - tag="${GITEA_REF_NAME}" - else - version="$(node -p "require('./package.json').version")" - tag="v${version}" - fi - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - env: - GITEA_REF: ${{ gitea.ref }} - GITEA_REF_NAME: ${{ gitea.ref_name }} + - uses: actions/setup-node@v4 + with: + node-version: 24 - uses: actions/download-artifact@v3 with: @@ -146,9 +218,64 @@ jobs: - uses: actions/download-artifact@v3 with: - name: fieldlens-print-linux-x64-deb + name: fieldlens-print-linux-x64 path: release-assets + - name: Build updater manifest + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + windows_sig="$(cat release-assets/fieldlens-print-windows-x64-setup.exe.sig)" + linux_sig="$(cat release-assets/fieldlens-print-linux-x64.AppImage.sig)" + base="https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads" + + jq -n \ + --arg version "$version" \ + --arg windows_sig "$windows_sig" \ + --arg linux_sig "$linux_sig" \ + --arg windows_url "$base/fieldlens-print-windows-x64-setup.exe" \ + --arg linux_url "$base/fieldlens-print-linux-x64.AppImage" \ + '{ + version: $version, + notes: "FieldLens Print desktop update", + platforms: { + "windows-x86_64": {signature:$windows_sig, url:$windows_url}, + "linux-x86_64": {signature:$linux_sig, url:$linux_url} + } + }' > release-assets/latest.json + + - name: Update stable public downloads + shell: bash + run: | + set -euo pipefail + mkdir -p downloads + + cp release-assets/fieldlens-print-windows-x64-setup.exe downloads/ + cp release-assets/fieldlens-print-windows-x64-setup.exe.sig downloads/ + cp release-assets/fieldlens-print-linux-x64.deb downloads/ + cp release-assets/fieldlens-print-linux-x64.AppImage downloads/ + cp release-assets/fieldlens-print-linux-x64.AppImage.sig downloads/ + cp release-assets/latest.json downloads/latest.json + + deb_sha="$(sha256sum downloads/fieldlens-print-linux-x64.deb | awk '{print $1}')" + sed -E "s/EXPECTED_SHA256=\"[0-9a-f]{64}\"/EXPECTED_SHA256=\"$deb_sha\"/" downloads/install.sh > /tmp/install.sh + mv /tmp/install.sh downloads/install.sh + + sha256sum \ + downloads/fieldlens-print-linux-x64.deb \ + downloads/fieldlens-print-linux-x64.AppImage \ + downloads/fieldlens-print-windows-x64-setup.exe \ + | sed 's#downloads/##' > downloads/SHA256SUMS + + git config user.name "FieldLens Desktop CI" + git config user.email "ci@noreply.git.arrowstack.dev" + git add downloads + if ! git diff --cached --quiet; then + git commit -m "release: publish FieldLens Print $(node -p "require('./package.json').version") downloads" + git push origin HEAD:main + fi + - name: Publish Gitea release assets shell: bash env: @@ -156,56 +283,51 @@ jobs: GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_SHA: ${{ gitea.sha }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail + version="$(node -p "require('./package.json').version")" + release_tag="v$version" api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}" auth_header="Authorization: token ${GITEA_TOKEN}" - status="$(curl -sS -o /tmp/release.json -w '%{http_code}' \ - -H "$auth_header" \ - "$api/releases/tags/$RELEASE_TAG")" + status="$(curl -sS -o /tmp/release.json -w '%{http_code}' -H "$auth_header" "$api/releases/tags/$release_tag")" if [ "$status" = "404" ]; then jq -n \ - --arg tag "$RELEASE_TAG" \ + --arg tag "$release_tag" \ --arg sha "$GITEA_SHA" \ '{ - tag_name: $tag, - target_commitish: $sha, - name: ("FieldLens Print " + $tag), - body: "Public FieldLens Print desktop installers.", - draft: false, - prerelease: false + tag_name:$tag, + target_commitish:$sha, + name:("FieldLens Print " + $tag), + body:"Signed FieldLens Print desktop installers and updater artifacts.", + draft:false, + prerelease:false }' > /tmp/create-release.json - curl -fsS \ - -X POST \ - -H "$auth_header" \ - -H "Content-Type: application/json" \ - --data-binary @/tmp/create-release.json \ - "$api/releases" > /tmp/release.json + curl -fsS -X POST -H "$auth_header" -H "Content-Type: application/json" \ + --data-binary @/tmp/create-release.json "$api/releases" > /tmp/release.json elif [ "$status" != "200" ]; then cat /tmp/release.json exit 1 fi release_id="$(jq -r '.id' /tmp/release.json)" - test "$release_id" != "null" - curl -fsS -H "$auth_header" "$api/releases/$release_id/assets" > /tmp/assets.json - for file in release-assets/fieldlens-print-windows-x64-setup.exe release-assets/fieldlens-print-linux-x64.deb; do + for file in \ + release-assets/fieldlens-print-windows-x64-setup.exe \ + release-assets/fieldlens-print-windows-x64-setup.exe.sig \ + release-assets/fieldlens-print-linux-x64.deb \ + release-assets/fieldlens-print-linux-x64.AppImage \ + release-assets/fieldlens-print-linux-x64.AppImage.sig \ + release-assets/latest.json + do name="$(basename "$file")" existing_id="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .id' /tmp/assets.json | head -n 1)" if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then curl -fsS -X DELETE -H "$auth_header" "$api/releases/$release_id/assets/$existing_id" fi - - curl -fsS \ - -X POST \ - -H "$auth_header" \ - -F "attachment=@$file" \ - "$api/releases/$release_id/assets?name=$name" + curl -fsS -X POST -H "$auth_header" -F "attachment=@$file" "$api/releases/$release_id/assets?name=$name" done diff --git a/package.json b/package.json index 92e7c49..e8e28cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { @@ -9,11 +9,14 @@ "tauri": "tauri", "bundle:windows": "npm run icons && tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis", "bundle:deb": "npm run icons && tauri build --bundles deb", - "icons": "tauri icon app-icon.svg" + "icons": "tauri icon app-icon.svg", + "bundle:linux": "npm run icons && tauri build --bundles deb,appimage" }, "dependencies": { "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-opener": "^2.0.0" + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.0.0" }, "devDependencies": { "@tauri-apps/cli": "^2.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 57428b7..416ec3a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.0" +version = "0.1.1" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" @@ -23,3 +23,5 @@ serde_json = "1" tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" uuid = { version = "1", features = ["v4", "serde"] } +tauri-plugin-process = "2" +tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 52241d5..3ebfe14 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,9 +2,13 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "FieldLens Print desktop permissions", - "windows": ["main"], + "windows": [ + "main" + ], "permissions": [ "core:default", - "opener:default" + "opener:default", + "updater:default", + "process:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2e9f94..3ad938e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,6 +82,8 @@ fn sync_pending(state: State<'_, AppState>) -> Result { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let data_dir = app.path().app_data_dir()?; let shared = SharedState::load(data_dir) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 00188b4..09feb99 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FieldLens Print", - "version": "0.1.0", + "version": "0.1.1", "identifier": "dev.arrowstack.fieldlens.print", "build": { "beforeDevCommand": "npm run dev", @@ -25,11 +25,26 @@ }, "bundle": { "active": true, - "targets": ["nsis", "msi"], + "targets": [ + "nsis", + "msi" + ], "windows": { "nsis": { "installMode": "currentUser" } + }, + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "__FIELDLENS_UPDATER_PUBKEY__", + "endpoints": [ + "https://git.arrowstack.dev/Admin/fieldlens-desktop/raw/branch/main/downloads/latest.json" + ], + "windows": { + "installMode": "passive" + } } } } diff --git a/src/main.ts b/src/main.ts index 7f2ad5a..9572a1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,9 @@ +import { BundleType, getBundleType, getVersion } from '@tauri-apps/api/app' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { openUrl } from '@tauri-apps/plugin-opener' +import { relaunch } from '@tauri-apps/plugin-process' +import { check, type Update } from '@tauri-apps/plugin-updater' import './styles.css' type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both' @@ -51,6 +54,10 @@ let config: AppConfig let jobs: Job[] = [] let status: Status let loginTimer: number | undefined +let currentVersion = '0.1.1' +let bundleType: BundleType | null = null +let availableUpdate: Update | null = null +let updateBusy = false app.innerHTML = `
@@ -119,6 +126,19 @@ app.innerHTML = `
+ +
+
+ App updates + Version … + Checking for updates shortly after startup. +
+
+ + +
+ +
@@ -238,6 +258,93 @@ el('login').addEventListener('click', async () => { }, Math.max(3, session.interval) * 1000) }) +async function checkForUpdates(manual = false) { + if (updateBusy) return + const state = el('update-state') + const installButton = el('install-update') + const checkButton = el('check-update') + + try { + checkButton.disabled = true + state.textContent = manual ? 'Checking for updates…' : 'Checking quietly for updates…' + const update = await check() + availableUpdate = update + + if (!update) { + installButton.hidden = true + state.textContent = `FieldLens Print ${currentVersion} is up to date.` + return + } + + state.textContent = `Version ${update.version} is available.` + installButton.hidden = false + installButton.textContent = bundleType === BundleType.Deb ? 'Open Linux installer' : 'Download & restart' + } catch (error) { + if (manual) state.textContent = `Update check failed: ${String(error)}` + else state.textContent = 'Automatic update check unavailable. You can retry manually.' + } finally { + checkButton.disabled = false + } +} + +async function installAvailableUpdate() { + if (!availableUpdate || updateBusy) return + + const state = el('update-state') + const button = el('install-update') + const progress = el('update-progress') + const bar = progress.querySelector('span') as HTMLSpanElement + + if (bundleType === BundleType.Deb) { + state.textContent = 'Debian packages update through the Linux installer.' + await openUrl('https://fieldlens.arrowstack.dev/downloads') + return + } + + if (status?.running) { + state.textContent = 'Stop capture before installing an update.' + return + } + + updateBusy = true + button.disabled = true + progress.hidden = false + bar.style.width = '8%' + let downloaded = 0 + let total = 0 + + try { + state.textContent = `Downloading FieldLens Print ${availableUpdate.version}…` + await availableUpdate.downloadAndInstall((event) => { + if (event.event === 'Started') { + total = event.data.contentLength ?? 0 + downloaded = 0 + bar.style.width = '10%' + } else if (event.event === 'Progress') { + downloaded += event.data.chunkLength + if (total > 0) { + const percent = Math.min(92, Math.max(10, Math.round((downloaded / total) * 100))) + bar.style.width = `${percent}%` + } + } else if (event.event === 'Finished') { + bar.style.width = '100%' + state.textContent = 'Update installed. Restarting…' + } + }) + + await relaunch() + } catch (error) { + state.textContent = `Update failed: ${String(error)}` + progress.hidden = true + } finally { + updateBusy = false + button.disabled = false + } +} + +el('check-update').addEventListener('click', () => { void checkForUpdates(true) }) +el('install-update').addEventListener('click', () => { void installAvailableUpdate() }) + await listen('capture-job', (event) => { jobs = [event.payload, ...jobs.filter((job) => job.id !== event.payload.id)].slice(0, 200) void refreshStatus() @@ -246,4 +353,10 @@ await listen('capture-job', (event) => { config = await invoke('get_config') writeForm(config) await refreshStatus() + +currentVersion = await getVersion() +bundleType = await getBundleType().catch(() => null) +el('version-label').textContent = `Version ${currentVersion}${bundleType ? ` · ${bundleType}` : ''}` + +window.setTimeout(() => { void checkForUpdates(false) }, 3000) window.setInterval(() => { void refreshStatus() }, 5000) diff --git a/src/styles.css b/src/styles.css index 981c5fe..1b22035 100644 --- a/src/styles.css +++ b/src/styles.css @@ -55,3 +55,12 @@ input:focus, select:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px #38bdf8 .fields { grid-template-columns: 1fr; } label.full { grid-column: auto; } } + +.update-box { margin-top: 14px; padding: 13px; border: 1px solid #263449; border-radius: 12px; background: #0b1220aa; } +.update-box > div:first-child { display: grid; gap: 3px; } +.update-box strong { font-size: 12px; } +.update-box small { line-height: 1.45; } +.update-actions { margin-top: 12px; } +.update-progress { height: 5px; margin-top: 12px; overflow: hidden; border-radius: 999px; background: #1e293b; } +.update-progress span { display: block; width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .16s ease; } +.btn:disabled { cursor: wait; opacity: .55; }