From 256d1a86d22f817f59cded2f4d1932bd190813a0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:11:37 +0100 Subject: [PATCH] UI changes to update and support auto updating (#6075) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/multiOSReleases.yml | 67 +- .gitleaksignore | 5 + frontend/.gitignore | 6 + frontend/.prettierignore | 2 +- .../public/locales/en-GB/translation.toml | 38 + frontend/editor/src-tauri/Cargo.lock | 170 +++ frontend/editor/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 7 +- .../editor/src-tauri/provisioner/src/main.rs | 64 +- .../src-tauri/src/commands/connection.rs | 314 ++++- frontend/editor/src-tauri/src/commands/mod.rs | 7 + .../editor/src-tauri/src/commands/updater.rs | 271 ++++ frontend/editor/src-tauri/src/lib.rs | 15 + frontend/editor/src-tauri/tauri.conf.json | 10 + .../src-tauri/windows/wix/provisioning.wxs | 23 +- .../src/core/components/AppProviders.tsx | 4 + .../core/components/shared/UpdateModal.tsx | 1191 +++++++++++------ .../components/shared/UpdateStartupPopup.tsx | 136 ++ .../config/configSections/GeneralSection.tsx | 203 ++- .../src/desktop/components/AppProviders.tsx | 38 + .../config/configSections/GeneralSection.tsx | 106 +- .../src/desktop/hooks/useDesktopInstall.ts | 131 ++ .../hooks/useDesktopUpdatePopup.test.ts | 179 +++ .../desktop/hooks/useDesktopUpdatePopup.ts | 237 ++++ .../desktop/services/desktopUpdateService.ts | 291 ++++ frontend/package.json | 8 +- frontend/scripts/dev-update-test/README.md | 35 + .../dev-update-test/build-dev-update.sh | 108 ++ .../scripts/dev-update-test/serve-update.sh | 66 + .../dev-update-test/setup-dev-updater.sh | 47 + .../dev-update-test/test-update-e2e.sh | 310 +++++ 31 files changed, 3602 insertions(+), 488 deletions(-) create mode 100644 .gitleaksignore create mode 100644 frontend/editor/src-tauri/src/commands/updater.rs create mode 100644 frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx create mode 100644 frontend/editor/src/desktop/hooks/useDesktopInstall.ts create mode 100644 frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.test.ts create mode 100644 frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.ts create mode 100644 frontend/editor/src/desktop/services/desktopUpdateService.ts create mode 100644 frontend/scripts/dev-update-test/README.md create mode 100644 frontend/scripts/dev-update-test/build-dev-update.sh create mode 100644 frontend/scripts/dev-update-test/serve-update.sh create mode 100644 frontend/scripts/dev-update-test/setup-dev-updater.sh create mode 100644 frontend/scripts/dev-update-test/test-update-e2e.sh diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 91f1cb0b9..9eaad9bb2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -495,6 +495,7 @@ jobs: projectPath: ./frontend/editor tauriScript: npx tauri args: ${{ matrix.args }} + updaterJsonKeepUniversal: true - name: Clear release GPG key from runner keyring (Linux) if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') @@ -596,15 +597,28 @@ jobs: # Only ship the MSI installer on Windows. The loose exe and WiX toolset exes # are not the user-facing installer - the MSI contains the signed inner exe. find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \; + find . -name "*.msi.zip" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.zip" \; + find . -name "*.msi.zip.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.zip.sig" \; + find . -name "*.nsis.zip" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.nsis.zip" \; + find . -name "*.nsis.zip.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.nsis.zip.sig" \; elif [ "${{ matrix.platform }}" = "macos-15" ]; then find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \; find . -name "*.app" -exec cp -r {} "$DIST/Stirling-PDF-${{ matrix.name }}.app" \; + find . -name "*.app.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz" \; + find . -name "*.app.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.app.tar.gz.sig" \; else find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \; find . -name "*.AppImage" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage" \; + find . -name "*.AppImage.tar.gz" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.tar.gz" \; + find . -name "*.AppImage.tar.gz.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.AppImage.tar.gz.sig" \; fi + # Copy updater latest.json if generated by tauri-action (rename to be platform-specific). + # Scope strictly to the tauri target tree - a broader find from repo root + # could pick up stray latest.json files and corrupt the release manifest. + find . -name "latest.json" -not -path "*/deps/*" 2>/dev/null | head -1 | xargs -I{} cp {} "$DIST/latest-${{ matrix.name }}.json" 2>/dev/null || true + - name: Upload build artifacts if: always() && steps.digicert-setup.conclusion != 'failure' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -613,8 +627,7 @@ jobs: path: ./dist/* retention-days: 1 - create-release: - if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' + collect-and-release: needs: [pick, determine-matrix, build, build-jars] runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} permissions: @@ -652,7 +665,52 @@ jobs: - name: Display structure of downloaded files run: ls -R ./artifacts + - name: Generate merged updater latest.json + run: | + python3 - << 'PYEOF' + import json, glob, os, sys + + files = sorted(glob.glob('./artifacts/tauri/**/latest-*.json', recursive=True)) + if not files: + print("No latest-*.json files found — skipping latest.json generation") + sys.exit(0) + + merged = None + for f in files: + print(f"Merging: {f}") + with open(f) as fh: + data = json.load(fh) + if merged is None: + merged = dict(data) + merged['platforms'] = {} + else: + # Guard against stale artifacts from a rerun bleeding into a new + # release's latest.json. All per-platform files must agree on version. + if merged.get('version') != data.get('version'): + sys.exit( + f"Version mismatch: {merged.get('version')} vs " + f"{data.get('version')} in {f}" + ) + for platform, payload in data.get('platforms', {}).items(): + if platform in merged['platforms']: + sys.exit(f"Duplicate platform entry for {platform} in {f}") + merged['platforms'][platform] = payload + + if merged and merged.get('platforms'): + with open('./artifacts/latest.json', 'w') as fh: + json.dump(merged, fh, indent=2) + print(f"Generated latest.json with platforms: {list(merged['platforms'].keys())}") + PYEOF + + - name: Upload merged artifacts for review + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-artifacts + path: ./artifacts/ + retention-days: 7 + - name: Upload binaries to Release + if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} @@ -660,9 +718,14 @@ jobs: files: | ./artifacts/**/*.jar ./artifacts/**/*.msi + ./artifacts/**/*.msi.zip + ./artifacts/**/*.nsis.zip ./artifacts/**/*.dmg + ./artifacts/**/*.app.tar.gz ./artifacts/**/*.deb ./artifacts/**/*.rpm ./artifacts/**/*.AppImage + ./artifacts/**/*.AppImage.tar.gz + ./artifacts/latest.json draft: false prerelease: false diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..a5f4b02f9 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# PostHog project-level key — phc_ prefix keys are public/client-side by design +# (PostHog client-side tracking embeds them in the browser bundle). Committed +# intentionally in #6150 so engine/.env has a working default, with real +# credentials overridden via engine/.env.local. +engine/.env:generic-api-key:41 diff --git a/frontend/.gitignore b/frontend/.gitignore index f881e6fb0..6d958ac43 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -42,3 +42,9 @@ test-results # auto-generated files /editor/src/assets/material-symbols-icons.json /editor/src/assets/material-symbols-icons.d.ts + +# dev update testing - keys, built bundles, screenshots, and generated config override +/scripts/dev-update-test/.keys/ +/scripts/dev-update-test/.update-dist/ +/scripts/dev-update-test/screenshots/ +/editor/src-tauri/tauri.conf.dev-update.json diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 0dec02ff5..036314c67 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,7 +2,7 @@ dist/ dist-portal/ editor/dist/ # Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). -# Match nested target/ dirs too — provisioner/ and thumbnail-handler/ each +# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each # have their own Cargo workspace under src-tauri/. editor/src-tauri/**/target/ editor/src-tauri/gen/ diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 9697479bf..94b09b446 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3473,6 +3473,22 @@ title = "Settings Opened" message = "Stirling PDF is now your default PDF editor" title = "Default App Set" +[desktopUpdate] +downloading = "Downloading update..." +installing = "Installing update..." +installingWarning = "The app will close automatically to complete the installation." +installNow = "Install Now" +readyToRestart = "Update Ready" +restartMessage = "The update has been installed. Restart the app to finish." +restartNow = "Restart Now" +updateFailed = "Update Failed" +updateFailedMessage = "Failed to download or install the update." + +[desktopUpdate.blocked] +docsLink = "View installation documentation" +message = "Stirling-PDF does not have permission to update itself on this machine." +title = "Administrator permissions required" + [editTableOfContents] submit = "Apply table of contents" @@ -7121,8 +7137,18 @@ currentBackendVersion = "Current Backend Version" currentFrontendVersion = "Current Frontend Version" description = "Check for updates and view version information" latestVersion = "Latest Version" +managedByAdmin = "Managed by administrator" +modeAuto = "Install updates automatically" +modeDisabled = "Don't check for updates" +modePrompt = "Ask me before installing updates" serverNeedsUpdate = "Server needs to be updated by administrator" title = "Software Updates" +updateBehavior = "Update behavior" +updateBehaviorDescription = "Choose whether to prompt before installing updates, install them automatically, or skip update checks entirely." +updateBehaviorError = "Could not change update behavior" +updateBehaviorErrorLocked = "This setting is locked by your administrator." +updateBehaviorLockedDescription = "Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this." +updateBehaviorSaved = "Update behavior saved." versionMismatch = "Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version." viewDetails = "View Details" @@ -8222,29 +8248,41 @@ placeholder = "Select a PDF file in the main view to get started" title = "Unlocked Forms Results" [update] +allReleases = "All Releases" availableUpdates = "Available Updates" +breaking = "Breaking" breakingChanges = "Breaking Changes" breakingChangesDefault = "This version contains breaking changes." breakingChangesDetected = "Breaking Changes Detected" breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating." close = "Close" current = "Current Version" +defaultRecommendation = "This update contains important fixes and improvements." downloadLatest = "Download Latest" +later = "Later" latest = "Latest Version" latestStable = "Latest Stable" loadingDetailedInfo = "Loading detailed information..." migrationGuide = "Migration Guide" migrationGuides = "Migration Guides" +migrationGuidesDesc = "Review important changes before updating." +modalSubtitle = "A new version of Stirling PDF is ready to install." modalTitle = "Update Available" +notes = "Notes" priorityLabel = "Priority" recommendedAction = "Recommended Action" releaseNotes = "Release Notes" +showLess = "Show fewer versions" +showMore = "Show all {{count}} versions" +stable = "STABLE" unableToLoadDetails = "Unable to load detailed information." updateAvailable = "Update Available" urgentUpdateAvailable = "Urgent Update" version = "Version" +versionHistory = "Version History" viewAllReleases = "View All Releases" viewGuide = "View Guide" +whatsNewIn = "What's new in" [update.priority] low = "Low" diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index e16dba6a6..6b04a564f 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -75,6 +75,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -899,6 +908,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1253,6 +1273,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2467,6 +2497,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2762,6 +2798,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-pdf-kit" version = "0.3.2" @@ -2874,6 +2922,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3686,15 +3748,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3863,6 +3930,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4429,6 +4523,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tauri-plugin-updater", "tauri-plugin-window-state", "tiny_http", "tokio", @@ -4640,6 +4735,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4979,6 +5085,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -5928,6 +6067,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -6625,6 +6773,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6789,6 +6947,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/frontend/editor/src-tauri/Cargo.toml b/frontend/editor/src-tauri/Cargo.toml index 64fc0bcf3..ddfd8911d 100644 --- a/frontend/editor/src-tauri/Cargo.toml +++ b/frontend/editor/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tauri-plugin-store = "2.4.2" tauri-plugin-opener = "2.5.3" tauri-plugin-deep-link = "2.4.6" tauri-plugin-notification = "2.3.3" +tauri-plugin-updater = "2" tauri-plugin-window-state = "2.2.1" keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.50", features = ["time", "sync"] } diff --git a/frontend/editor/src-tauri/capabilities/default.json b/frontend/editor/src-tauri/capabilities/default.json index 98df1fef9..4147b69ce 100644 --- a/frontend/editor/src-tauri/capabilities/default.json +++ b/frontend/editor/src-tauri/capabilities/default.json @@ -58,6 +58,11 @@ "notification:allow-is-permission-granted", "window-state:allow-filename", "window-state:allow-restore-state", - "window-state:allow-save-window-state" + "window-state:allow-save-window-state", + "updater:default", + "updater:allow-check", + "updater:allow-download", + "updater:allow-download-and-install", + "updater:allow-install" ] } diff --git a/frontend/editor/src-tauri/provisioner/src/main.rs b/frontend/editor/src-tauri/provisioner/src/main.rs index 1ab8ecc73..1a316945c 100644 --- a/frontend/editor/src-tauri/provisioner/src/main.rs +++ b/frontend/editor/src-tauri/provisioner/src/main.rs @@ -6,14 +6,37 @@ use std::path::PathBuf; #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProvisioningConfig<'a> { - server_url: &'a str, - lock_connection_mode: bool, + #[serde(skip_serializing_if = "Option::is_none")] + server_url: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + lock_connection_mode: Option, + /// Optional headless-install update policy. + /// One of `"prompt"` (default), `"auto"`, or `"disabled"`. + #[serde(skip_serializing_if = "Option::is_none")] + update_mode: Option<&'a str>, } fn parse_bool(value: &str) -> bool { + matches!( + value.trim().to_lowercase().as_str(), + "1" | "true" | "yes" | "y" + ) +} + +/// Normalise the `--update-mode` argument into the lowercase tokens the app +/// understands. Empty / whitespace values are treated as "not supplied" so +/// MSI installs that don't pass STIRLING_UPDATE_MODE behave identically to +/// earlier builds. +fn parse_update_mode(value: &str) -> Result, String> { match value.trim().to_lowercase().as_str() { - "1" | "true" | "yes" | "y" => true, - _ => false, + "" => Ok(None), + "prompt" => Ok(Some("prompt")), + "auto" => Ok(Some("auto")), + "disabled" | "off" | "none" => Ok(Some("disabled")), + other => Err(format!( + "Invalid --update-mode value '{}': expected prompt, auto, or disabled", + other + )), } } @@ -21,6 +44,7 @@ fn main() -> Result<(), String> { let mut output: Option = None; let mut url: Option = None; let mut lock_value: Option = None; + let mut update_mode_arg: Option = None; let mut args = env::args().skip(1); while let Some(arg) = args.next() { @@ -43,6 +67,12 @@ fn main() -> Result<(), String> { .ok_or_else(|| "--lock requires a value".to_string())?; lock_value = Some(value); } + "--update-mode" => { + let value = args + .next() + .ok_or_else(|| "--update-mode requires a value".to_string())?; + update_mode_arg = Some(value); + } _ => { return Err(format!("Unknown argument: {}", arg)); } @@ -50,11 +80,28 @@ fn main() -> Result<(), String> { } let output = output.ok_or_else(|| "Missing --output".to_string())?; + let url = url .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing --url".to_string())?; - let lock = lock_value.as_deref().map(parse_bool).unwrap_or(false); + .filter(|value| !value.is_empty()); + + let update_mode = update_mode_arg + .as_deref() + .map(parse_update_mode) + .transpose()? + .flatten(); + + // Nothing to write — avoid clobbering an existing provisioning file when + // the MSI is invoked without any of STIRLING_SERVER_URL / STIRLING_UPDATE_MODE. + if url.is_none() && update_mode.is_none() { + return Ok(()); + } + + let lock = if url.is_some() { + Some(lock_value.as_deref().map(parse_bool).unwrap_or(false)) + } else { + None + }; if let Some(parent) = output.parent() { fs::create_dir_all(parent) @@ -62,8 +109,9 @@ fn main() -> Result<(), String> { } let config = ProvisioningConfig { - server_url: url.as_str(), + server_url: url.as_deref(), lock_connection_mode: lock, + update_mode, }; let json = serde_json::to_string_pretty(&config) diff --git a/frontend/editor/src-tauri/src/commands/connection.rs b/frontend/editor/src-tauri/src/commands/connection.rs index 157f25ce5..068d82b73 100644 --- a/frontend/editor/src-tauri/src/commands/connection.rs +++ b/frontend/editor/src-tauri/src/commands/connection.rs @@ -15,8 +15,48 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed"; const CONNECTION_MODE_KEY: &str = "connection_mode"; const SERVER_CONFIG_KEY: &str = "server_config"; const LOCK_CONNECTION_KEY: &str = "lock_connection_mode"; +pub(crate) const UPDATE_MODE_KEY: &str = "update_mode"; +/// When `true` the update mode was written by a provisioning file and cannot +/// be changed from the UI. Only another provisioning file (from MDM) can +/// override it. We track this separately from `lock_connection_mode` because +/// an admin may want to lock updates without locking the connection URL, +/// or vice versa. +pub(crate) const UPDATE_MODE_LOCKED_KEY: &str = "update_mode_locked"; const PROVISIONING_FILE_NAME: &str = "stirling-provisioning.json"; +/// How the desktop auto-updater should behave on startup. +/// +/// * `Prompt` – default. Show the update popup when a new version is available +/// and let the user decide whether to install. +/// * `Auto` – silently download and install updates on startup, then restart. +/// Intended for managed deployments (Intune/MDM) where the user +/// cannot (or should not) be prompted. +/// * `Disabled` – never check for updates, never show the update UI. Administrators +/// are expected to push updates through their normal packaging flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UpdateMode { + Prompt, + Auto, + Disabled, +} + +impl Default for UpdateMode { + fn default() -> Self { + UpdateMode::Prompt + } +} + +/// Current update mode plus whether the UI is allowed to change it. Returned +/// by [`get_update_mode`] so the settings page can show a "managed by +/// administrator" hint instead of silently ignoring clicks. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateModeInfo { + pub mode: UpdateMode, + pub locked: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub struct ConnectionConfig { pub mode: ConnectionMode, @@ -142,6 +182,9 @@ pub async fn set_connection_mode( struct ProvisioningConfig { server_url: Option, lock_connection_mode: Option, + /// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`). + /// When omitted the existing stored mode is left unchanged. + update_mode: Option, } fn provisioning_file_paths() -> Vec { @@ -155,6 +198,23 @@ fn provisioning_file_paths() -> Vec { paths } +/// A provisioning file should only lock the update-mode UI when it was placed +/// somewhere that requires administrator rights to write to — i.e. the +/// system-wide provisioning dir written by MSI/Intune. A file in the per-user +/// `app_data_dir()` is just a user dropping JSON in their own profile; locking +/// the UI based on that would let any local user permanently disable the +/// Settings selector for themselves with no way back, because the file is +/// deleted after apply but the lock flag persists in the store. +pub(crate) fn provisioning_path_is_admin_owned( + provisioning_path: &std::path::Path, + system_dir: Option<&std::path::Path>, +) -> bool { + match system_dir { + Some(dir) => provisioning_path.starts_with(dir), + None => false, + } +} + pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), String> { let provisioning_paths = provisioning_file_paths(); let provisioning_path = provisioning_paths @@ -181,8 +241,10 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - if server_url.is_none() { - add_log("⚠️ Provisioning file missing serverUrl; skipping apply".to_string()); + if server_url.is_none() && parsed.update_mode.is_none() { + add_log( + "⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(), + ); return Ok(()); } @@ -192,36 +254,68 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin .store(STORE_FILE) .map_err(|e| format!("Failed to access store: {}", e))?; - store.set( - CONNECTION_MODE_KEY, - serde_json::to_value(&ConnectionMode::SelfHosted) - .map_err(|e| format!("Failed to serialize mode: {}", e))?, - ); + // Apply server URL / connection settings only when a URL was supplied — a + // provisioning file containing just `updateMode` should be allowed to configure + // the headless update policy without forcing self-hosted mode. + let server_config = if let Some(url) = server_url { + store.set( + CONNECTION_MODE_KEY, + serde_json::to_value(&ConnectionMode::SelfHosted) + .map_err(|e| format!("Failed to serialize mode: {}", e))?, + ); - let server_config = ServerConfig { - url: server_url.clone().unwrap(), + let cfg = ServerConfig { url }; + store.set( + SERVER_CONFIG_KEY, + serde_json::to_value(&cfg) + .map_err(|e| format!("Failed to serialize config: {}", e))?, + ); + + store.set( + LOCK_CONNECTION_KEY, + serde_json::to_value(lock_flag) + .map_err(|e| format!("Failed to serialize lock flag: {}", e))?, + ); + + store.set(FIRST_LAUNCH_KEY, serde_json::json!(true)); + Some(cfg) + } else { + None }; - store.set( - SERVER_CONFIG_KEY, - serde_json::to_value(&server_config) - .map_err(|e| format!("Failed to serialize config: {}", e))?, - ); - store.set( - LOCK_CONNECTION_KEY, - serde_json::to_value(lock_flag) - .map_err(|e| format!("Failed to serialize lock flag: {}", e))?, - ); - - store.set(FIRST_LAUNCH_KEY, serde_json::json!(true)); + if let Some(mode) = parsed.update_mode { + store.set( + UPDATE_MODE_KEY, + serde_json::to_value(&mode) + .map_err(|e| format!("Failed to serialize update mode: {}", e))?, + ); + // Only lock the UI when the provisioning file came from a path that + // requires admin rights to write — i.e. the system provisioning dir + // populated by MSI/Intune. A user dropping a file in their own + // `app_data_dir` must NOT lock themselves out of the Settings + // selector permanently (the file is deleted after apply, but the + // lock flag persists in the store). + let system_dir = system_provisioning_dir(); + let locked = provisioning_path_is_admin_owned( + &provisioning_path, + system_dir.as_deref(), + ); + store.set(UPDATE_MODE_LOCKED_KEY, serde_json::json!(locked)); + add_log(format!( + "🧩 Provisioning set update mode to {:?} (locked={})", + mode, locked + )); + } store .save() .map_err(|e| format!("Failed to save store: {}", e))?; - if let Ok(mut conn_state) = app_handle.state::().0.lock() { + if let (Some(cfg), Ok(mut conn_state)) = + (server_config.as_ref(), app_handle.state::().0.lock()) + { conn_state.mode = ConnectionMode::SelfHosted; - conn_state.server_config = Some(server_config); + conn_state.server_config = Some(cfg.clone()); conn_state.lock_connection_mode = lock_flag; } @@ -255,6 +349,78 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result { Ok(!setup_completed) } +/// Read the configured update mode from the tauri store. +/// +/// Returns [`UpdateMode::Prompt`] when the store is unavailable or no mode +/// has been set — the prompt-the-user flow is the safe default for normal, +/// non-managed installs. +pub(crate) fn read_update_mode(app_handle: &AppHandle) -> UpdateMode { + read_update_mode_info(app_handle).mode +} + +/// Read the configured update mode AND whether it's locked by provisioning. +pub(crate) fn read_update_mode_info(app_handle: &AppHandle) -> UpdateModeInfo { + match app_handle.store(STORE_FILE) { + Ok(store) => { + let mode = store + .get(UPDATE_MODE_KEY) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let locked = store + .get(UPDATE_MODE_LOCKED_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + UpdateModeInfo { mode, locked } + } + Err(_) => UpdateModeInfo { + mode: UpdateMode::default(), + locked: false, + }, + } +} + +#[tauri::command] +pub async fn get_update_mode(app_handle: AppHandle) -> Result { + Ok(read_update_mode_info(&app_handle)) +} + +/// Update the stored update mode from the UI. +/// +/// Refuses to overwrite a provisioned (locked) value so an MDM-managed +/// deployment can't be subverted by a user clicking in Settings. +#[tauri::command] +pub async fn set_update_mode( + app_handle: AppHandle, + mode: UpdateMode, +) -> Result<(), String> { + let store = app_handle + .store(STORE_FILE) + .map_err(|e| format!("Failed to access store: {}", e))?; + + let locked = store + .get(UPDATE_MODE_LOCKED_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if locked { + add_log(format!( + "⚠️ set_update_mode({:?}) rejected — mode is locked by provisioning", + mode + )); + return Err("Update mode is locked by your administrator".to_string()); + } + + store.set( + UPDATE_MODE_KEY, + serde_json::to_value(&mode) + .map_err(|e| format!("Failed to serialize update mode: {}", e))?, + ); + store + .save() + .map_err(|e| format!("Failed to save store: {}", e))?; + add_log(format!("⚙️ User set update mode to {:?}", mode)); + Ok(()) +} + #[tauri::command] pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> { log::info!("Resetting setup completion flag"); @@ -273,3 +439,105 @@ pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> log::info!("Setup completion flag reset successfully"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + // Windows-path tests are cfg-gated because `Path::starts_with` is + // component-wise: on Linux, `"C:\\foo\\bar"` is a SINGLE path component + // (since backslash is a literal character there, not a separator) so + // the prefix never matches the way it would on Windows. + #[cfg(target_os = "windows")] + #[test] + fn user_app_data_provisioning_does_not_lock_ui() { + // A user dropping a provisioning file in their own roaming AppData + // (per-user, user-writable) must NOT permanently lock the update-mode + // selector. The file is deleted after apply but the lock flag would + // persist in the store, locking the user out with no way back. + let user_path = PathBuf::from( + "C:\\Users\\alice\\AppData\\Roaming\\Stirling-PDF\\stirling-provisioning.json", + ); + let system_dir = PathBuf::from("C:\\ProgramData\\Stirling-PDF"); + + assert!(!provisioning_path_is_admin_owned( + &user_path, + Some(&system_dir), + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn system_provisioning_dir_does_lock_ui() { + // A provisioning file in ProgramData\Stirling-PDF (or /Library, /etc) + // requires admin/root rights to write — those locations are how MSI + // and Intune deliver policy — so locking the UI here is correct. + let system_path = PathBuf::from( + "C:\\ProgramData\\Stirling-PDF\\stirling-provisioning.json", + ); + let system_dir = PathBuf::from("C:\\ProgramData\\Stirling-PDF"); + + assert!(provisioning_path_is_admin_owned( + &system_path, + Some(&system_dir), + )); + } + + #[test] + fn linux_etc_provisioning_does_lock_ui() { + let system_path = PathBuf::from("/etc/stirling-pdf/stirling-provisioning.json"); + let system_dir = PathBuf::from("/etc/stirling-pdf"); + assert!(provisioning_path_is_admin_owned( + &system_path, + Some(&system_dir), + )); + } + + #[test] + fn linux_home_config_does_not_lock_ui() { + let user_path = + PathBuf::from("/home/alice/.config/Stirling-PDF/stirling-provisioning.json"); + let system_dir = PathBuf::from("/etc/stirling-pdf"); + assert!(!provisioning_path_is_admin_owned( + &user_path, + Some(&system_dir), + )); + } + + #[test] + fn macos_library_provisioning_does_lock_ui() { + let system_path = PathBuf::from( + "/Library/Application Support/Stirling-PDF/stirling-provisioning.json", + ); + let system_dir = + PathBuf::from("/Library/Application Support/Stirling-PDF"); + assert!(provisioning_path_is_admin_owned( + &system_path, + Some(&system_dir), + )); + } + + #[test] + fn macos_user_library_does_not_lock_ui() { + let user_path = PathBuf::from( + "/Users/alice/Library/Application Support/Stirling-PDF/stirling-provisioning.json", + ); + let system_dir = + PathBuf::from("/Library/Application Support/Stirling-PDF"); + assert!(!provisioning_path_is_admin_owned( + &user_path, + Some(&system_dir), + )); + } + + #[test] + fn no_system_dir_means_no_lock() { + // Defensive: when the platform has no defined system_provisioning_dir, + // refuse to lock — the user-AppData file is the only thing we'd be + // matching against, and that's the case we explicitly want to leave + // unlocked. + let user_path = PathBuf::from("/home/alice/.config/Stirling-PDF/stirling-provisioning.json"); + assert!(!provisioning_path_is_admin_owned(&user_path, None)); + } +} diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 495b38aaf..0ff48e670 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod default_app; pub mod platform; pub mod print; +pub mod updater; pub mod window; pub use backend::{cleanup_backend, get_backend_port, start_backend}; @@ -19,9 +20,11 @@ pub use window::{ }; pub use connection::{ get_connection_config, + get_update_mode, is_first_launch, reset_setup_completion, set_connection_mode, + set_update_mode, }; pub use auth::{ clear_auth_token, @@ -39,3 +42,7 @@ pub use auth::{ pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler}; pub use platform::get_desktop_os; pub use print::print_pdf_file_native; +pub use updater::{ + can_install_updates, check_for_update, download_and_install_update, get_app_version, + restart_app, +}; diff --git a/frontend/editor/src-tauri/src/commands/updater.rs b/frontend/editor/src-tauri/src/commands/updater.rs new file mode 100644 index 000000000..10139a276 --- /dev/null +++ b/frontend/editor/src-tauri/src/commands/updater.rs @@ -0,0 +1,271 @@ +use std::sync::{Arc, Mutex}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +use crate::commands::connection::{read_update_mode, UpdateMode}; +use crate::utils::add_log; + +/// Information about an available update. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + /// The new version string (e.g. "2.8.0"). + pub version: String, + /// The currently installed version string. + pub current_version: String, + /// Release notes / changelog, if provided by the update endpoint. + pub release_notes: Option, +} + +/// Progress payload emitted as `update-download-progress` events during download. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProgress { + /// Total bytes downloaded so far. + pub downloaded: u64, + /// Total bytes to download, if known. + pub total: Option, + /// Download percentage (0–100). Zero when total is unknown. + pub percent: f64, +} + +/// Check whether a newer version is available. +/// +/// Returns `None` when the app is up-to-date or when the check cannot be +/// performed (e.g. no network, endpoint misconfigured). Errors are logged +/// internally so callers do not need to surface them to the user. +#[tauri::command] +pub async fn check_for_update(app: AppHandle) -> Result, String> { + // Managed deployments may have updates disabled — never hit the endpoint in + // that case so there's no network traffic and no way for the UI to discover + // an update that would go unused. + if read_update_mode(&app) == UpdateMode::Disabled { + add_log("🔕 Update check skipped — update mode is disabled".to_string()); + return Ok(None); + } + + add_log("🔍 Checking for updates...".to_string()); + + let current_version = app.package_info().version.to_string(); + + let updater = match app.updater() { + Ok(u) => u, + Err(e) => { + add_log(format!("⚠️ Updater not available (plugin not configured?): {}", e)); + return Ok(None); + } + }; + + match updater.check().await { + Ok(Some(update)) => { + add_log(format!("✅ Update available: {} → {}", current_version, update.version)); + Ok(Some(UpdateInfo { + version: update.version.clone(), + current_version, + release_notes: update.body.clone(), + })) + } + Ok(None) => { + add_log("✅ App is up to date".to_string()); + Ok(None) + } + Err(e) => { + // Surface as Ok(None) so the frontend never shows a noisy error banner + // on a transient network failure during startup. + add_log(format!("⚠️ Update check failed: {}", e)); + Ok(None) + } + } +} + +/// Download and install the latest available update. +/// +/// Emits three events on the `AppHandle`: +/// * `update-download-progress` – [`UpdateProgress`] payload, sent for each +/// received chunk. +/// * `update-download-finished` – empty payload once the download is complete +/// and the installer has been written to disk. +/// * `update-ready-to-restart` – empty payload once the in-process install +/// step has completed and the app can be safely restarted. +/// +/// Returns `Err` if the update cannot be found, downloaded, or installed so +/// the frontend can fall back to opening the download page. +#[tauri::command] +pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> { + add_log("📥 Starting update download and install...".to_string()); + + let updater = app.updater().map_err(|e| { + let msg = format!("Updater plugin unavailable: {}", e); + add_log(format!("⚠️ {}", msg)); + msg + })?; + + let update = updater + .check() + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "No update is currently available".to_string())?; + + add_log(format!("📥 Downloading version {}...", update.version)); + + let downloaded = Arc::new(Mutex::new(0u64)); + let app_progress = app.clone(); + let app_finish = app.clone(); + + update + .download_and_install( + move |chunk_length, content_length| { + let mut dl = downloaded.lock().unwrap_or_else(|e| e.into_inner()); + *dl += chunk_length as u64; + let current = *dl; + let percent = content_length + .map(|total| (current as f64 / total as f64) * 100.0) + .unwrap_or(0.0); + + let _ = app_progress.emit( + "update-download-progress", + UpdateProgress { + downloaded: current, + total: content_length, + percent, + }, + ); + }, + move || { + add_log("✅ Download finished, applying update...".to_string()); + let _ = app_finish.emit("update-download-finished", ()); + }, + ) + .await + .map_err(|e| { + let msg = format!("Update installation failed: {}", e); + add_log(format!("❌ {}", msg)); + msg + })?; + + add_log("✅ Update installed — waiting for restart".to_string()); + let _ = app.emit("update-ready-to-restart", ()); + + Ok(()) +} + +/// Restart the application to apply an already-installed update. +/// +/// This function never returns — the process is replaced by the new version. +#[tauri::command] +pub fn restart_app(app: AppHandle) { + add_log("🔄 Restarting app to apply update...".to_string()); + app.restart(); +} + +/// Return the currently running application version string. +#[tauri::command] +pub fn get_app_version(app: AppHandle) -> String { + app.package_info().version.to_string() +} + +/// Result of [`can_install_updates`] — does this process have the permissions +/// needed to run the Tauri updater's MSI/NSIS installer without triggering +/// UAC elevation (or another blocker)? +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CanInstallResult { + /// `true` when the install directory is writable from the current + /// process — the strongest signal that msiexec will succeed without + /// needing elevation. `false` means auto-install is very likely to + /// trigger a UAC prompt (or be blocked entirely) and should be + /// skipped in silent/auto mode. + pub can_install: bool, + /// Machine-readable reason when `can_install` is `false`. One of + /// `"install_dir_not_writable"`, `"install_dir_unknown"`. `None` when + /// `can_install` is `true`. + pub reason: Option, + /// Path we probed so the frontend can include it in the "contact your + /// administrator" alert. Best-effort — may be empty on exotic layouts. + pub install_dir: Option, +} + +/// Check whether the Tauri updater is likely to be able to install a new +/// version silently. +/// +/// Tauri's Windows updater runs msiexec against the downloaded MSI, which on +/// a per-machine install writes to `C:\Program Files\Stirling-PDF`. If the +/// current user can write to that directory without elevation, msiexec will +/// succeed silently (passive install mode). If they can't, the install +/// triggers a UAC prompt that can't be auto-approved — and for a managed +/// (Intune/MDM) deployment the user typically can't satisfy it at all. +/// +/// We test this by writing (and immediately deleting) a zero-byte probe file +/// next to the running executable. This avoids trying to reverse-engineer +/// Windows token elevation state, which is surprisingly hard to do correctly +/// on Windows when UAC filtered tokens are in play. +/// +/// On macOS and Linux the probe-next-to-exe heuristic is misleading: +/// `current_exe().parent()` on macOS is `Stirling-PDF.app/Contents/MacOS/` +/// which is almost always user-writable even when `/Applications/` is not, +/// so the probe returns a false positive. On Linux the AppImage/deb install +/// story is different and not gated by this kind of check. We platform-gate +/// to Windows and report `can_install: true` elsewhere so the existing +/// interactive update flow runs unchanged. +#[tauri::command] +pub fn can_install_updates() -> CanInstallResult { + #[cfg(not(target_os = "windows"))] + { + return CanInstallResult { + can_install: true, + reason: None, + install_dir: None, + }; + } + + #[cfg(target_os = "windows")] + { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + add_log(format!("⚠️ can_install_updates: current_exe failed: {}", e)); + return CanInstallResult { + can_install: false, + reason: Some("install_dir_unknown".to_string()), + install_dir: None, + }; + } + }; + let parent = match exe.parent() { + Some(p) => p.to_path_buf(), + None => { + return CanInstallResult { + can_install: false, + reason: Some("install_dir_unknown".to_string()), + install_dir: None, + }; + } + }; + + let dir_display = parent.display().to_string(); + let probe = parent.join(".stirling-auto-update-probe.tmp"); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + add_log(format!("✅ can_install_updates: writable: {}", dir_display)); + CanInstallResult { + can_install: true, + reason: None, + install_dir: Some(dir_display), + } + } + Err(e) => { + add_log(format!( + "⚠️ can_install_updates: install dir not writable ({}): {}", + dir_display, e + )); + CanInstallResult { + can_install: false, + reason: Some("install_dir_not_writable".to_string()), + install_dir: Some(dir_display), + } + } + } + } +} diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index aa73dc8a8..e8898b053 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -32,9 +32,16 @@ use commands::{ set_connection_mode, set_as_default_pdf_handler, get_desktop_os, + get_update_mode, print_pdf_file_native, + set_update_mode, start_backend, start_oauth_login, + can_install_updates, + check_for_update, + download_and_install_update, + get_app_version, + restart_app, target_window_label, MAIN_WINDOW_LABEL, }; @@ -79,6 +86,7 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { @@ -181,6 +189,13 @@ pub fn run() { start_oauth_login, get_desktop_os, print_pdf_file_native, + can_install_updates, + check_for_update, + download_and_install_update, + get_app_version, + get_update_mode, + set_update_mode, + restart_app, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index e9e43ede8..c9e24e421 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -83,6 +83,16 @@ "desktop": { "schemes": ["stirlingpdf"] } + }, + "updater": { + "endpoints": [ + "https://github.com/Stirling-Tools/Stirling-PDF/releases/latest/download/latest.json" + ], + "dialog": false, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZEREM2QTgwNjdGNzdCOUQKUldTZGUvZG5nR3JjYmZ4TlZ3YVpHTVIzd2hBT2c4c000RDNIN2tLZENEblE4ZXFhU3J5V0lQanEK", + "windows": { + "installMode": "passive" + } } } } diff --git a/frontend/editor/src-tauri/windows/wix/provisioning.wxs b/frontend/editor/src-tauri/windows/wix/provisioning.wxs index 7c6801a97..f523baa00 100644 --- a/frontend/editor/src-tauri/windows/wix/provisioning.wxs +++ b/frontend/editor/src-tauri/windows/wix/provisioning.wxs @@ -3,6 +3,13 @@ + + @@ -64,20 +71,24 @@ + - STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0) - STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0) - STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2) - STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 3501658b9..6c0010945 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -30,6 +30,7 @@ import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import AppConfigLoader from "@app/components/shared/AppConfigLoader"; +import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup"; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; import { FolderProvider } from "@app/contexts/FolderContext"; @@ -122,6 +123,9 @@ export function AppProviders({ + {/* Auto-popup on startup when a newer Stirling-PDF release is available. + No-ops inside Tauri — the desktop popup handles that flow. */} + Promise; + restartApp: () => Promise; +} + +/** + * Passed alongside the install state when the Tauri `can_install_updates` + * probe has run. When `canInstall` is `false` the UpdateModal shows an + * inline "admin permissions required" warning and disables the Install Now + * button so users can't trip themselves into a UAC prompt they can't satisfy. + */ +export interface DesktopInstallCanInstall { + canInstall: boolean; + reason: string | null; +} + +/** Docs URL referenced from the blocked alert. */ +const WINDOWS_INSTALL_DOCS_URL = + "https://docs.stirlingpdf.com/Installation/Windows%20Installation/#automated-installation-msi-installer"; + interface UpdateModalProps { opened: boolean; onClose: () => void; + onRemindLater?: () => void; currentVersion: string; updateSummary: UpdateSummary; machineInfo: MachineInfo; + downloadSizeBytes?: number | null; + desktopInstall?: { + state: DesktopInstallState; + progress: DesktopInstallProgress | null; + errorMessage: string | null; + actions: DesktopInstallActions; + /** + * Optional: result of the `can_install_updates` probe. When present + * with `canInstall: false` the modal shows an inline admin-permissions + * warning and disables the Install Now button. Absent or + * `canInstall: true` preserves the existing interactive flow. + */ + canInstall?: DesktopInstallCanInstall | null; + }; +} + +function formatBytes(bytes: number): string { + if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)} KB`; + return `${(bytes / (1_024 * 1_024)).toFixed(1)} MB`; +} + +function formatSize(bytes: number | null | undefined): string { + if (!bytes) return "~220 MB"; + return `~${formatBytes(bytes)}`; } const UpdateModal: React.FC = ({ opened, onClose, + onRemindLater, currentVersion, updateSummary, machineInfo, + downloadSizeBytes, + desktopInstall, }) => { const { t } = useTranslation(); const [fullUpdateInfo, setFullUpdateInfo] = useState( @@ -66,13 +144,10 @@ const UpdateModal: React.FC = ({ const toggleVersion = (index: number) => { setExpandedVersions((prev) => { - const newSet = new Set(prev); - if (newSet.has(index)) { - newSet.delete(index); - } else { - newSet.add(index); - } - return newSet; + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; }); }; @@ -97,421 +172,729 @@ const UpdateModal: React.FC = ({ }; const downloadUrl = updateService.getDownloadUrl(machineInfo); + const canClose = + !desktopInstall || + desktopInstall.state === "idle" || + desktopInstall.state === "error" || + desktopInstall.state === "ready-to-restart"; + + // When the install-probe reported that we cannot write to the install + // directory (non-admin on a per-machine install, Intune/MDM deploys, etc), + // surface an inline warning and disable the Install Now button. We only + // block the interactive flow — auto mode silently skips upstream in + // useDesktopUpdatePopup so the user is never prompted. + const installBlocked = Boolean( + desktopInstall && + desktopInstall.canInstall && + desktopInstall.canInstall.canInstall === false, + ); + const isStable = + updateSummary.latest_stable_version === updateSummary.latest_version; + const priorityColor = getPriorityColor(updateSummary.max_priority); + + const handleLater = () => { + if (onRemindLater) onRemindLater(); + onClose(); + }; + + // Sort versions newest first, skip the latest (already shown in header) + const sortedVersions = fullUpdateInfo?.new_versions + ? [...fullUpdateInfo.new_versions].sort((a, b) => + updateService.compareVersions(b.version, a.version), + ) + : []; + // Show max 10 initially to keep the modal manageable + const [showAllVersions, setShowAllVersions] = useState(false); + const visibleVersions = showAllVersions + ? sortedVersions + : sortedVersions.slice(0, 10); return ( - {t("update.modalTitle", "Update Available")} - - } + onClose={canClose ? onClose : () => undefined} + withCloseButton={false} centered size="xl" + padding={0} zIndex={Z_INDEX_OVER_CONFIG_MODAL} + radius="lg" styles={{ - body: { - maxHeight: "75vh", - overflowY: "auto", - }, + body: { display: "flex", flexDirection: "column", maxHeight: "85vh" }, + content: { overflow: "hidden" }, }} > - - {/* Version Summary Section */} - - - - - {t("update.current", "Current Version")} - - - {currentVersion} - - - - - - {t("update.priorityLabel", "Priority")} - - - {getPriorityLabel(updateSummary.max_priority)} - - - - - - {t("update.latest", "Latest Version")} - - - {updateSummary.latest_version} - - - - - {updateSummary.latest_stable_version && ( + {/* ── Header ─────────────────────────────────────────────────────────── */} + + + - - - {t("update.latestStable", "Latest Stable")}: - - - {updateSummary.latest_stable_version} - - + - )} - - - {/* Recommended action */} - {updateSummary.recommended_action && ( - - - - - - {t("update.recommendedAction", "Recommended Action")} - - {updateSummary.recommended_action} - - - - )} - - {/* Breaking changes warning */} - {updateSummary.any_breaking && ( - - - - - - {t( - "update.breakingChangesDetected", - "Breaking Changes Detected", - )} - - - {t( - "update.breakingChangesMessage", - "Some versions contain breaking changes. Please review the migration guides below before updating.", - )} - - - - - )} - - {/* Migration guides */} - {updateSummary.migration_guides && - updateSummary.migration_guides.length > 0 && ( - <> - - - - {t("update.migrationGuides", "Migration Guides")} - - {updateSummary.migration_guides.map((guide, idx) => ( - - - - - {t("update.version", "Version")} {guide.version} - - - {guide.notes} - - - - - - ))} - - - )} - - {/* Version details */} - - {loading ? ( -
- - - + + + {t("update.modalTitle", "Update Available")} + + {t( - "update.loadingDetailedInfo", - "Loading detailed information...", + "update.modalSubtitle", + "A new version of Stirling-PDF is ready to install.", )} - -
- ) : fullUpdateInfo && - fullUpdateInfo.new_versions && - fullUpdateInfo.new_versions.length > 0 ? ( - - - - {t("update.availableUpdates", "Available Updates")} - - - {fullUpdateInfo.new_versions.length}{" "} - {fullUpdateInfo.new_versions.length === 1 - ? "version" - : "versions"} - - - - {fullUpdateInfo.new_versions.map((version, index) => { - const isExpanded = expandedVersions.has(index); - return ( - - toggleVersion(index)} - > - - - - {t("update.version", "Version")} - - - {version.version} - - - - {getPriorityLabel(version.priority)} - - - - - {isExpanded ? ( - - ) : ( - - )} - - - - - - - - - {version.announcement.title} - - - {version.announcement.message} - - - - {version.compatibility.breaking_changes && ( - - - - - {t( - "update.breakingChanges", - "Breaking Changes", - )} - - - - {version.compatibility.breaking_description || - t( - "update.breakingChangesDefault", - "This version contains breaking changes.", - )} - - {version.compatibility.migration_guide_url && ( - - )} - - )} - - - - - ); - })} - - - ) : null} - - {/* Action buttons */} - - - - - {downloadUrl && ( - +
+ + {canClose && ( + )} -
+ + + {/* ── Scrollable content ─────────────────────────────────────────────── */} + + + {/* Version comparison */} + + + + + {t("update.current", "Current Version")} + + + {currentVersion} + + + + + + {t("update.latest", "Latest Version")} + + + + {updateSummary.latest_version} + + {(isStable || updateSummary.latest_stable_version) && ( + // `variant="filled" color="green"` produced an acid-green + // pill that was noisy in light mode and oddly washed out + // in dark mode. `light` gives a soft teal-ish chip that + // reads well in both themes. + + {t("update.stable", "STABLE")} + + )} + + + + + + {/* Priority badge + recommendation — compact single line */} + + + {getPriorityLabel(updateSummary.max_priority)} + + + {updateSummary.recommended_action || + t( + "update.defaultRecommendation", + "This update contains important fixes and improvements.", + )} + + + + {/* Admin permissions required — shown when can_install_updates + reported that msiexec would need UAC elevation this user + can't satisfy. Placed right after the priority row so it's + the first thing users see when they open the modal and the + Install Now button (below) is disabled as a result. */} + {desktopInstall && installBlocked && ( + } + title={t( + "desktopUpdate.blocked.title", + "Administrator permissions required", + )} + > + + {t( + "desktopUpdate.blocked.message", + "Stirling-PDF does not have permission to update itself on this machine.", + )}{" "} + + {t( + "desktopUpdate.blocked.docsLink", + "View installation documentation", + )} + + + + )} + + {/* What's New card */} + + + + + + {t("update.whatsNewIn", "What's new in")}{" "} + {updateSummary.latest_version} + + + + + {t("update.releaseNotes", "Release Notes")}{" "} + + + + {t("update.allReleases", "All Releases")}{" "} + + + + + + + {/* Breaking changes */} + {updateSummary.any_breaking && ( + } + title={t( + "update.breakingChangesDetected", + "Breaking Changes Detected", + )} + > + {t( + "update.breakingChangesMessage", + "Some versions contain breaking changes. Please review the migration guides below before updating.", + )} + + )} + + {/* Migration guides */} + {updateSummary.migration_guides && + updateSummary.migration_guides.length > 0 && ( + + + {t("update.migrationGuides", "Migration Guides")} + + + {t( + "update.migrationGuidesDesc", + "Review important changes before updating.", + )} + + + {updateSummary.migration_guides.map((guide, idx) => ( + + + + + + {guide.version} + + + {guide.notes} + + + + + + ))} + + + )} + + {/* ── Version history ─────────────────────────────────────────────── */} + + {loading ? ( +
+ + + + {t( + "update.loadingDetailedInfo", + "Loading version details...", + )} + + +
+ ) : visibleVersions.length > 0 ? ( + + + + {t("update.versionHistory", "Version History")} + + + {sortedVersions.length}{" "} + {sortedVersions.length === 1 ? "version" : "versions"} + + + + {visibleVersions.map((version, index) => { + const isExpanded = expandedVersions.has(index); + return ( + + toggleVersion(index)} + > + + + {version.version} + + + {getPriorityLabel(version.priority)} + + {version.compatibility.breaking_changes && ( + + {t("update.breaking", "Breaking")} + + )} + {!isExpanded && version.announcement?.title && ( + + {version.announcement.title} + + )} + + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + {version.announcement?.message && ( + + {version.announcement.message} + + )} + {version.compatibility.breaking_changes && ( + + } + title={t( + "update.breakingChanges", + "Breaking Changes", + )} + > + + {version.compatibility.breaking_description || + t( + "update.breakingChangesDefault", + "This version contains breaking changes.", + )} + + {version.compatibility.migration_guide_url && ( + + )} + + )} + + + + + ); + })} + + {sortedVersions.length > 10 && ( +
+ +
+ )} +
+ ) : null} + + {/* Desktop install progress */} + {desktopInstall && desktopInstall.state !== "idle" && ( + + {(desktopInstall.state === "downloading" || + desktopInstall.state === "installing") && ( + + + + {desktopInstall.state === "downloading" + ? t( + "desktopUpdate.downloading", + "Downloading update...", + ) + : t("desktopUpdate.installing", "Installing update...")} + + {desktopInstall.progress && + desktopInstall.progress.total !== null && ( + + {formatBytes(desktopInstall.progress.downloaded)} /{" "} + {formatBytes(desktopInstall.progress.total)} + + )} + + + {desktopInstall.state === "installing" && ( + + + {t( + "desktopUpdate.installingWarning", + "The app will close automatically to complete the installation.", + )} + + + )} + + )} + {desktopInstall.state === "ready-to-restart" && ( + } + color="green" + variant="light" + radius="md" + title={t("desktopUpdate.readyToRestart", "Update Ready")} + > + {t( + "desktopUpdate.restartMessage", + "The update has been installed. Restart the app to finish.", + )} + + )} + {desktopInstall.state === "error" && ( + } + color="red" + variant="light" + radius="md" + title={t("desktopUpdate.updateFailed", "Update Failed")} + > + {desktopInstall.errorMessage ?? + t( + "desktopUpdate.updateFailedMessage", + "Failed to download or install the update.", + )} + + )} + + )} +
+
+ + {/* ── Sticky footer ──────────────────────────────────────────────────── */} + + + + {desktopInstall ? ( + desktopInstall.state === "ready-to-restart" ? ( + + ) : desktopInstall.state === "idle" || + desktopInstall.state === "error" ? ( + <> + {/* When install is blocked (non-admin) or the tauri updater + failed, the user still needs a way forward — show a + "Download Latest" link to the GitHub release page as a + fallback alongside the disabled Install Now button. */} + {(installBlocked || desktopInstall.state === "error") && + downloadUrl && ( + + )} + + + ) : null + ) : ( + // Tauri updater not available at all — only show the external + // download link. This is the fallback when latest.json is + // unreachable, the pubkey is wrong, signatures don't match, etc. + downloadUrl && ( + + ) + )} + +
); }; diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx new file mode 100644 index 000000000..f3e2a446b --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo"; +import { updateService, type UpdateSummary } from "@app/services/updateService"; +import UpdateModal from "@app/components/shared/UpdateModal"; + +/** + * How long to wait after mount before checking for an update. Matches the + * desktop popup delay (15s) so the first-launch experience feels the same + * in both environments and the check doesn't race with initial app config + * load / auth handshake on slow networks. + */ +const STARTUP_DELAY_MS = 15_000; + +/** + * localStorage key used by the "Remind me later" button on the UpdateModal. + * Shared with the desktop popup so snoozing in either context suppresses + * both popups for the same 24h window. + */ +const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil"; +const SNOOZE_DURATION_MS = 24 * 60 * 60 * 1000; + +/** + * Best-effort Tauri detection without importing `@tauri-apps/api` into the + * core bundle (which must remain runnable on plain web). Tauri v2 injects + * `__TAURI_INTERNALS__` before any user code runs. + */ +function isRunningInTauri(): boolean { + if (typeof window === "undefined") return false; + return ( + typeof (window as unknown as { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__ !== "undefined" + ); +} + +/** + * Web/server-side auto-popup that shows the UpdateModal on startup when a + * newer Stirling-PDF version is available. Previously this check only ran + * from the Settings → General "Check for Updates" button, so non-desktop + * users could sit on stale versions indefinitely without any prompt. + * + * On desktop (Tauri) this component is a no-op — `useDesktopUpdatePopup` + * drives the desktop flow because it also has to honour the headless + * `updateMode` provisioning flag and wire up the silent/auto installer. + * Running both would double-popup. + */ +export function UpdateStartupPopup() { + const { config } = useAppConfig(); + const { appVersion } = useFrontendVersionInfo(config?.appVersion); + + // The version to compare against the latest. Prefer the frontend version + // (which is always known) so we don't wait for the backend handshake in + // offline / self-hosted-down scenarios. + const currentVersion = appVersion ?? config?.appVersion ?? null; + + const [updateSummary, setUpdateSummary] = useState( + null, + ); + const [showModal, setShowModal] = useState(false); + const hasChecked = useRef(false); + + useEffect(() => { + // Skip on desktop — the Tauri popup owns that flow end-to-end. + if (isRunningInTauri()) return; + if (hasChecked.current) return; + if (!currentVersion) return; + // Don't even schedule the timer until we have a version to compare. + hasChecked.current = true; + + const timer = setTimeout(async () => { + // Respect the 24h snooze set by the "Remind me later" button. + const snoozedUntil = localStorage.getItem(SNOOZE_KEY); + if (snoozedUntil && Date.now() < parseInt(snoozedUntil, 10)) return; + + try { + const machineInfo = { + machineType: config?.machineType ?? "unknown", + activeSecurity: config?.activeSecurity ?? false, + licenseType: config?.license ?? "NORMAL", + }; + const summary = await updateService.getUpdateSummary( + currentVersion, + machineInfo, + ); + if ( + summary?.latest_version && + updateService.compareVersions( + summary.latest_version, + currentVersion, + ) > 0 + ) { + setUpdateSummary(summary); + setShowModal(true); + } + } catch (err) { + // Surface as a console warning — a silent failure here is preferable + // to a noisy error banner on every offline startup. + console.warn("[UpdateStartupPopup] startup update check failed:", err); + } + }, STARTUP_DELAY_MS); + + return () => clearTimeout(timer); + }, [ + currentVersion, + config?.machineType, + config?.activeSecurity, + config?.license, + ]); + + if (!updateSummary || !currentVersion) return null; + + const machineInfo = { + machineType: config?.machineType ?? "unknown", + activeSecurity: config?.activeSecurity ?? false, + licenseType: config?.license ?? "NORMAL", + }; + + return ( + setShowModal(false)} + onRemindLater={() => { + localStorage.setItem( + SNOOZE_KEY, + String(Date.now() + SNOOZE_DURATION_MS), + ); + setShowModal(false); + }} + currentVersion={currentVersion} + updateSummary={updateSummary} + machineInfo={machineInfo} + /> + ); +} + +export default UpdateStartupPopup; diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx index f1bfdbdca..d10f59ea3 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -30,21 +30,57 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; import LocalIcon from "@app/components/shared/LocalIcon"; import { updateService, UpdateSummary } from "@app/services/updateService"; import UpdateModal from "@app/components/shared/UpdateModal"; +import type { + DesktopInstallState, + DesktopInstallProgress, + DesktopInstallActions, + DesktopInstallCanInstall, +} from "@app/components/shared/UpdateModal"; import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo"; const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed"; +/** + * Desktop-only: user-facing update policy control, rendered inside the + * Software Updates section alongside the version info. Passed from the + * desktop GeneralSection override so this core component doesn't have to + * import any Tauri APIs directly. + */ +export interface DesktopUpdateModeControl { + /** Current mode. */ + mode: "prompt" | "auto" | "disabled"; + /** `true` when the mode was written by a provisioning file — disables the control. */ + locked: boolean; + /** Called when the user picks a new mode. Async: surface errors via toast. */ + onChange: (mode: "prompt" | "auto" | "disabled") => Promise | void; +} + interface GeneralSectionProps { hideTitle?: boolean; hideUpdateSection?: boolean; hideAdminBanner?: boolean; + /** Desktop-only: Tauri updater install state, passed from the desktop override. */ + desktopInstall?: { + state: DesktopInstallState; + progress: DesktopInstallProgress | null; + errorMessage: string | null; + tauriInstallReady: boolean; + /** Result of the `can_install_updates` probe, used to show an inline + * warning when msiexec would need UAC elevation this user doesn't have. */ + canInstall?: DesktopInstallCanInstall | null; + actions: DesktopInstallActions; + }; + /** Desktop-only: update-mode toggle (prompt/auto/disabled). */ + desktopUpdateMode?: DesktopUpdateModeControl; } const GeneralSection: React.FC = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false, + desktopInstall, + desktopUpdateMode, }) => { const { t } = useTranslation(); const { preferences, updatePreference } = usePreferences(); @@ -72,48 +108,56 @@ const GeneralSection: React.FC = ({ setFileLimitInput(preferences.autoUnzipFileLimit); }, [preferences.autoUnzipFileLimit]); + // The version to use for update checks — on desktop use the Tauri app version, + // falling back to the backend version + const currentVersion = appVersion ?? config?.appVersion ?? null; + // Check for updates on mount useEffect(() => { - if (config?.appVersion && config?.machineType) { + if (currentVersion) { checkForUpdate(); } - }, [config?.appVersion, config?.machineType]); + }, [currentVersion, config?.machineType]); const checkForUpdate = async () => { - if (!config?.appVersion || !config?.machineType) { - return; - } + if (!currentVersion) return; setCheckingUpdate(true); + const machineInfo = { - machineType: config.machineType, - activeSecurity: config.activeSecurity ?? false, - licenseType: config.license ?? "NORMAL", + machineType: config?.machineType ?? "unknown", + activeSecurity: config?.activeSecurity ?? false, + licenseType: config?.license ?? "NORMAL", }; const summary = await updateService.getUpdateSummary( - config.appVersion, + currentVersion, machineInfo, ); - if (summary && summary.latest_version) { - const isNewerVersion = - updateService.compareVersions( - summary.latest_version, - config.appVersion, - ) > 0; - if (isNewerVersion) { - setUpdateSummary(summary); - } else { - // Clear any existing update summary if user is on latest version - setUpdateSummary(null); - } + + if ( + summary?.latest_version && + updateService.compareVersions(summary.latest_version, currentVersion) > 0 + ) { + setUpdateSummary(summary); } else { - // No update available (latest_version is null) - clear any existing update summary setUpdateSummary(null); } + setCheckingUpdate(false); }; + // Build desktop install props for the UpdateModal (only when provided by desktop override) + const desktopInstallProps = desktopInstall?.tauriInstallReady + ? { + state: desktopInstall.state, + progress: desktopInstall.progress, + errorMessage: desktopInstall.errorMessage, + canInstall: desktopInstall.canInstall, + actions: desktopInstall.actions, + } + : undefined; + // Check if login is disabled const loginDisabled = !config?.enableLogin; @@ -215,8 +259,8 @@ const GeneralSection: React.FC = ({ )} - {/* Update Check Section */} - {!hideUpdateSection && config?.appVersion && ( + {/* Update Check Section — show when backend version is known OR in desktop mode (Tauri version is always available) */} + {!hideUpdateSection && (config?.appVersion || !!desktopInstall) && (
@@ -272,16 +316,18 @@ const GeneralSection: React.FC = ({ )}
- - {t( - "settings.general.updates.currentBackendVersion", - "Current Backend Version", - )} - :{" "} - - {config.appVersion} + {config?.appVersion && ( + + {t( + "settings.general.updates.currentBackendVersion", + "Current Backend Version", + )} + :{" "} + + {config.appVersion} + - + )} {updateSummary && ( {t( @@ -301,6 +347,7 @@ const GeneralSection: React.FC = ({ variant="default" onClick={checkForUpdate} loading={checkingUpdate} + disabled={!currentVersion} leftSection={ = ({ + {/* Desktop-only: update behaviour selector (prompt / auto / disabled). + Rendered disabled with a "Managed by administrator" hint when the + mode was pinned by a provisioning file. */} + {desktopUpdateMode && ( + + + + {t( + "settings.general.updates.updateBehavior", + "Update behavior", + )} + + {desktopUpdateMode.locked && ( + // `color="gray" variant="light"` rendered as near-invisible + // light-on-dark in dark mode. `blue light` has enough + // contrast in both themes to read clearly without being + // shouty. + + {t( + "settings.general.updates.managedByAdmin", + "Managed by administrator", + )} + + )} + + + {desktopUpdateMode.locked + ? t( + "settings.general.updates.updateBehaviorLockedDescription", + "Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this.", + ) + : t( + "settings.general.updates.updateBehaviorDescription", + "Choose whether to prompt before installing updates, install them automatically, or skip update checks entirely.", + )} + +