mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
UI changes to update and support auto updating (#6075)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
919f0ade99
commit
256d1a86d2
@@ -495,6 +495,7 @@ jobs:
|
|||||||
projectPath: ./frontend/editor
|
projectPath: ./frontend/editor
|
||||||
tauriScript: npx tauri
|
tauriScript: npx tauri
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
updaterJsonKeepUniversal: true
|
||||||
|
|
||||||
- name: Clear release GPG key from runner keyring (Linux)
|
- 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')
|
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
|
# 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.
|
# 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" -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
|
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||||
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
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" -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
|
else
|
||||||
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
|
find . -name "*.deb" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||||
find . -name "*.rpm" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.rpm" \;
|
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" -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
|
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
|
- name: Upload build artifacts
|
||||||
if: always() && steps.digicert-setup.conclusion != 'failure'
|
if: always() && steps.digicert-setup.conclusion != 'failure'
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@@ -613,8 +627,7 @@ jobs:
|
|||||||
path: ./dist/*
|
path: ./dist/*
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
create-release:
|
collect-and-release:
|
||||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
|
||||||
needs: [pick, determine-matrix, build, build-jars]
|
needs: [pick, determine-matrix, build, build-jars]
|
||||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -652,7 +665,52 @@ jobs:
|
|||||||
- name: Display structure of downloaded files
|
- name: Display structure of downloaded files
|
||||||
run: ls -R ./artifacts
|
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
|
- 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
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||||
@@ -660,9 +718,14 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
./artifacts/**/*.jar
|
./artifacts/**/*.jar
|
||||||
./artifacts/**/*.msi
|
./artifacts/**/*.msi
|
||||||
|
./artifacts/**/*.msi.zip
|
||||||
|
./artifacts/**/*.nsis.zip
|
||||||
./artifacts/**/*.dmg
|
./artifacts/**/*.dmg
|
||||||
|
./artifacts/**/*.app.tar.gz
|
||||||
./artifacts/**/*.deb
|
./artifacts/**/*.deb
|
||||||
./artifacts/**/*.rpm
|
./artifacts/**/*.rpm
|
||||||
./artifacts/**/*.AppImage
|
./artifacts/**/*.AppImage
|
||||||
|
./artifacts/**/*.AppImage.tar.gz
|
||||||
|
./artifacts/latest.json
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -42,3 +42,9 @@ test-results
|
|||||||
# auto-generated files
|
# auto-generated files
|
||||||
/editor/src/assets/material-symbols-icons.json
|
/editor/src/assets/material-symbols-icons.json
|
||||||
/editor/src/assets/material-symbols-icons.d.ts
|
/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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ dist/
|
|||||||
dist-portal/
|
dist-portal/
|
||||||
editor/dist/
|
editor/dist/
|
||||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
|
# 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/.
|
# have their own Cargo workspace under src-tauri/.
|
||||||
editor/src-tauri/**/target/
|
editor/src-tauri/**/target/
|
||||||
editor/src-tauri/gen/
|
editor/src-tauri/gen/
|
||||||
|
|||||||
@@ -3473,6 +3473,22 @@ title = "Settings Opened"
|
|||||||
message = "Stirling PDF is now your default PDF editor"
|
message = "Stirling PDF is now your default PDF editor"
|
||||||
title = "Default App Set"
|
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]
|
[editTableOfContents]
|
||||||
submit = "Apply table of contents"
|
submit = "Apply table of contents"
|
||||||
|
|
||||||
@@ -7121,8 +7137,18 @@ currentBackendVersion = "Current Backend Version"
|
|||||||
currentFrontendVersion = "Current Frontend Version"
|
currentFrontendVersion = "Current Frontend Version"
|
||||||
description = "Check for updates and view version information"
|
description = "Check for updates and view version information"
|
||||||
latestVersion = "Latest Version"
|
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"
|
serverNeedsUpdate = "Server needs to be updated by administrator"
|
||||||
title = "Software Updates"
|
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."
|
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"
|
viewDetails = "View Details"
|
||||||
|
|
||||||
@@ -8222,29 +8248,41 @@ placeholder = "Select a PDF file in the main view to get started"
|
|||||||
title = "Unlocked Forms Results"
|
title = "Unlocked Forms Results"
|
||||||
|
|
||||||
[update]
|
[update]
|
||||||
|
allReleases = "All Releases"
|
||||||
availableUpdates = "Available Updates"
|
availableUpdates = "Available Updates"
|
||||||
|
breaking = "Breaking"
|
||||||
breakingChanges = "Breaking Changes"
|
breakingChanges = "Breaking Changes"
|
||||||
breakingChangesDefault = "This version contains breaking changes."
|
breakingChangesDefault = "This version contains breaking changes."
|
||||||
breakingChangesDetected = "Breaking Changes Detected"
|
breakingChangesDetected = "Breaking Changes Detected"
|
||||||
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
|
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
|
||||||
close = "Close"
|
close = "Close"
|
||||||
current = "Current Version"
|
current = "Current Version"
|
||||||
|
defaultRecommendation = "This update contains important fixes and improvements."
|
||||||
downloadLatest = "Download Latest"
|
downloadLatest = "Download Latest"
|
||||||
|
later = "Later"
|
||||||
latest = "Latest Version"
|
latest = "Latest Version"
|
||||||
latestStable = "Latest Stable"
|
latestStable = "Latest Stable"
|
||||||
loadingDetailedInfo = "Loading detailed information..."
|
loadingDetailedInfo = "Loading detailed information..."
|
||||||
migrationGuide = "Migration Guide"
|
migrationGuide = "Migration Guide"
|
||||||
migrationGuides = "Migration Guides"
|
migrationGuides = "Migration Guides"
|
||||||
|
migrationGuidesDesc = "Review important changes before updating."
|
||||||
|
modalSubtitle = "A new version of Stirling PDF is ready to install."
|
||||||
modalTitle = "Update Available"
|
modalTitle = "Update Available"
|
||||||
|
notes = "Notes"
|
||||||
priorityLabel = "Priority"
|
priorityLabel = "Priority"
|
||||||
recommendedAction = "Recommended Action"
|
recommendedAction = "Recommended Action"
|
||||||
releaseNotes = "Release Notes"
|
releaseNotes = "Release Notes"
|
||||||
|
showLess = "Show fewer versions"
|
||||||
|
showMore = "Show all {{count}} versions"
|
||||||
|
stable = "STABLE"
|
||||||
unableToLoadDetails = "Unable to load detailed information."
|
unableToLoadDetails = "Unable to load detailed information."
|
||||||
updateAvailable = "Update Available"
|
updateAvailable = "Update Available"
|
||||||
urgentUpdateAvailable = "Urgent Update"
|
urgentUpdateAvailable = "Urgent Update"
|
||||||
version = "Version"
|
version = "Version"
|
||||||
|
versionHistory = "Version History"
|
||||||
viewAllReleases = "View All Releases"
|
viewAllReleases = "View All Releases"
|
||||||
viewGuide = "View Guide"
|
viewGuide = "View Guide"
|
||||||
|
whatsNewIn = "What's new in"
|
||||||
|
|
||||||
[update.priority]
|
[update.priority]
|
||||||
low = "Low"
|
low = "Low"
|
||||||
|
|||||||
Generated
+170
@@ -75,6 +75,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -899,6 +908,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -1253,6 +1273,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -2467,6 +2497,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2762,6 +2798,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-pdf-kit"
|
name = "objc2-pdf-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2874,6 +2922,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3686,15 +3748,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3863,6 +3930,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.13"
|
||||||
@@ -4429,6 +4523,7 @@ dependencies = [
|
|||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4640,6 +4735,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4979,6 +5085,39 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-window-state"
|
name = "tauri-plugin-window-state"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -5928,6 +6067,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -6625,6 +6773,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -6789,6 +6947,18 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ tauri-plugin-store = "2.4.2"
|
|||||||
tauri-plugin-opener = "2.5.3"
|
tauri-plugin-opener = "2.5.3"
|
||||||
tauri-plugin-deep-link = "2.4.6"
|
tauri-plugin-deep-link = "2.4.6"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-window-state = "2.2.1"
|
tauri-plugin-window-state = "2.2.1"
|
||||||
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||||
tokio = { version = "1.50", features = ["time", "sync"] }
|
tokio = { version = "1.50", features = ["time", "sync"] }
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"window-state:allow-filename",
|
"window-state:allow-filename",
|
||||||
"window-state:allow-restore-state",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,37 @@ use std::path::PathBuf;
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ProvisioningConfig<'a> {
|
struct ProvisioningConfig<'a> {
|
||||||
server_url: &'a str,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
lock_connection_mode: bool,
|
server_url: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
lock_connection_mode: Option<bool>,
|
||||||
|
/// 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 {
|
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<Option<&'static str>, String> {
|
||||||
match value.trim().to_lowercase().as_str() {
|
match value.trim().to_lowercase().as_str() {
|
||||||
"1" | "true" | "yes" | "y" => true,
|
"" => Ok(None),
|
||||||
_ => false,
|
"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<PathBuf> = None;
|
let mut output: Option<PathBuf> = None;
|
||||||
let mut url: Option<String> = None;
|
let mut url: Option<String> = None;
|
||||||
let mut lock_value: Option<String> = None;
|
let mut lock_value: Option<String> = None;
|
||||||
|
let mut update_mode_arg: Option<String> = None;
|
||||||
|
|
||||||
let mut args = env::args().skip(1);
|
let mut args = env::args().skip(1);
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
@@ -43,6 +67,12 @@ fn main() -> Result<(), String> {
|
|||||||
.ok_or_else(|| "--lock requires a value".to_string())?;
|
.ok_or_else(|| "--lock requires a value".to_string())?;
|
||||||
lock_value = Some(value);
|
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));
|
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 output = output.ok_or_else(|| "Missing --output".to_string())?;
|
||||||
|
|
||||||
let url = url
|
let url = url
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty());
|
||||||
.ok_or_else(|| "Missing --url".to_string())?;
|
|
||||||
let lock = lock_value.as_deref().map(parse_bool).unwrap_or(false);
|
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() {
|
if let Some(parent) = output.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -62,8 +109,9 @@ fn main() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = ProvisioningConfig {
|
let config = ProvisioningConfig {
|
||||||
server_url: url.as_str(),
|
server_url: url.as_deref(),
|
||||||
lock_connection_mode: lock,
|
lock_connection_mode: lock,
|
||||||
|
update_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&config)
|
let json = serde_json::to_string_pretty(&config)
|
||||||
|
|||||||
@@ -15,8 +15,48 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed";
|
|||||||
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
||||||
const SERVER_CONFIG_KEY: &str = "server_config";
|
const SERVER_CONFIG_KEY: &str = "server_config";
|
||||||
const LOCK_CONNECTION_KEY: &str = "lock_connection_mode";
|
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";
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ConnectionConfig {
|
pub struct ConnectionConfig {
|
||||||
pub mode: ConnectionMode,
|
pub mode: ConnectionMode,
|
||||||
@@ -142,6 +182,9 @@ pub async fn set_connection_mode(
|
|||||||
struct ProvisioningConfig {
|
struct ProvisioningConfig {
|
||||||
server_url: Option<String>,
|
server_url: Option<String>,
|
||||||
lock_connection_mode: Option<bool>,
|
lock_connection_mode: Option<bool>,
|
||||||
|
/// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`).
|
||||||
|
/// When omitted the existing stored mode is left unchanged.
|
||||||
|
update_mode: Option<UpdateMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provisioning_file_paths() -> Vec<PathBuf> {
|
fn provisioning_file_paths() -> Vec<PathBuf> {
|
||||||
@@ -155,6 +198,23 @@ fn provisioning_file_paths() -> Vec<PathBuf> {
|
|||||||
paths
|
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> {
|
pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), String> {
|
||||||
let provisioning_paths = provisioning_file_paths();
|
let provisioning_paths = provisioning_file_paths();
|
||||||
let provisioning_path = provisioning_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())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
if server_url.is_none() {
|
if server_url.is_none() && parsed.update_mode.is_none() {
|
||||||
add_log("⚠️ Provisioning file missing serverUrl; skipping apply".to_string());
|
add_log(
|
||||||
|
"⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,36 +254,68 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
|
|||||||
.store(STORE_FILE)
|
.store(STORE_FILE)
|
||||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||||
|
|
||||||
store.set(
|
// Apply server URL / connection settings only when a URL was supplied — a
|
||||||
CONNECTION_MODE_KEY,
|
// provisioning file containing just `updateMode` should be allowed to configure
|
||||||
serde_json::to_value(&ConnectionMode::SelfHosted)
|
// the headless update policy without forcing self-hosted mode.
|
||||||
.map_err(|e| format!("Failed to serialize mode: {}", e))?,
|
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 {
|
let cfg = ServerConfig { url };
|
||||||
url: server_url.clone().unwrap(),
|
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(
|
if let Some(mode) = parsed.update_mode {
|
||||||
LOCK_CONNECTION_KEY,
|
store.set(
|
||||||
serde_json::to_value(lock_flag)
|
UPDATE_MODE_KEY,
|
||||||
.map_err(|e| format!("Failed to serialize lock flag: {}", e))?,
|
serde_json::to_value(&mode)
|
||||||
);
|
.map_err(|e| format!("Failed to serialize update mode: {}", e))?,
|
||||||
|
);
|
||||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
|
// 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
|
store
|
||||||
.save()
|
.save()
|
||||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||||
|
|
||||||
if let Ok(mut conn_state) = app_handle.state::<AppConnectionState>().0.lock() {
|
if let (Some(cfg), Ok(mut conn_state)) =
|
||||||
|
(server_config.as_ref(), app_handle.state::<AppConnectionState>().0.lock())
|
||||||
|
{
|
||||||
conn_state.mode = ConnectionMode::SelfHosted;
|
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;
|
conn_state.lock_connection_mode = lock_flag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +349,78 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(!setup_completed)
|
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<UpdateModeInfo, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
|
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
|
||||||
log::info!("Resetting setup completion flag");
|
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");
|
log::info!("Setup completion flag reset successfully");
|
||||||
Ok(())
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod auth;
|
|||||||
pub mod default_app;
|
pub mod default_app;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod print;
|
pub mod print;
|
||||||
|
pub mod updater;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
||||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||||
@@ -19,9 +20,11 @@ pub use window::{
|
|||||||
};
|
};
|
||||||
pub use connection::{
|
pub use connection::{
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
|
get_update_mode,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
reset_setup_completion,
|
reset_setup_completion,
|
||||||
set_connection_mode,
|
set_connection_mode,
|
||||||
|
set_update_mode,
|
||||||
};
|
};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
clear_auth_token,
|
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 default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||||
pub use platform::get_desktop_os;
|
pub use platform::get_desktop_os;
|
||||||
pub use print::print_pdf_file_native;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u64>,
|
||||||
|
/// 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<Option<UpdateInfo>, 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,16 @@ use commands::{
|
|||||||
set_connection_mode,
|
set_connection_mode,
|
||||||
set_as_default_pdf_handler,
|
set_as_default_pdf_handler,
|
||||||
get_desktop_os,
|
get_desktop_os,
|
||||||
|
get_update_mode,
|
||||||
print_pdf_file_native,
|
print_pdf_file_native,
|
||||||
|
set_update_mode,
|
||||||
start_backend,
|
start_backend,
|
||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
|
can_install_updates,
|
||||||
|
check_for_update,
|
||||||
|
download_and_install_update,
|
||||||
|
get_app_version,
|
||||||
|
restart_app,
|
||||||
target_window_label,
|
target_window_label,
|
||||||
MAIN_WINDOW_LABEL,
|
MAIN_WINDOW_LABEL,
|
||||||
};
|
};
|
||||||
@@ -79,6 +86,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.manage(AppConnectionState::default())
|
.manage(AppConnectionState::default())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
@@ -181,6 +189,13 @@ pub fn run() {
|
|||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
get_desktop_os,
|
get_desktop_os,
|
||||||
print_pdf_file_native,
|
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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"desktop": {
|
"desktop": {
|
||||||
"schemes": ["stirlingpdf"]
|
"schemes": ["stirlingpdf"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"updater": {
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/Stirling-Tools/Stirling-PDF/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"dialog": false,
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZEREM2QTgwNjdGNzdCOUQKUldTZGUvZG5nR3JjYmZ4TlZ3YVpHTVIzd2hBT2c4c000RDNIN2tLZENEblE4ZXFhU3J5V0lQanEK",
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
|
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
|
||||||
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
|
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
|
||||||
|
<!--
|
||||||
|
STIRLING_UPDATE_MODE — headless auto-update policy for MDM/Intune deploys.
|
||||||
|
Accepts "prompt" (default, user-interactive), "auto" (silent download+install on
|
||||||
|
startup), or "disabled" (never check). Leaving it unset preserves whatever was
|
||||||
|
configured on the previous install (or defaults to "prompt" on a fresh install).
|
||||||
|
-->
|
||||||
|
<Property Id="STIRLING_UPDATE_MODE" Secure="yes" />
|
||||||
|
|
||||||
<DirectoryRef Id="TARGETDIR">
|
<DirectoryRef Id="TARGETDIR">
|
||||||
<Directory Id="AppDataFolder" />
|
<Directory Id="AppDataFolder" />
|
||||||
@@ -64,20 +71,24 @@
|
|||||||
<CustomAction
|
<CustomAction
|
||||||
Id="SetWriteProvisioningFilePerUser"
|
Id="SetWriteProvisioningFilePerUser"
|
||||||
Property="WriteProvisioningFilePerUser"
|
Property="WriteProvisioningFilePerUser"
|
||||||
Value="--output "[AppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]""
|
Value="--output "[AppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CustomAction
|
<CustomAction
|
||||||
Id="SetWriteProvisioningFileAllUsers"
|
Id="SetWriteProvisioningFileAllUsers"
|
||||||
Property="WriteProvisioningFileAllUsers"
|
Property="WriteProvisioningFileAllUsers"
|
||||||
Value="--output "[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]""
|
Value="--output "[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Run the provisioner when EITHER STIRLING_SERVER_URL or STIRLING_UPDATE_MODE is set.
|
||||||
|
The provisioner is a no-op if both values end up empty, so passing neither is safe.
|
||||||
|
-->
|
||||||
<InstallExecuteSequence>
|
<InstallExecuteSequence>
|
||||||
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||||
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">STIRLING_SERVER_URL <> "" AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||||
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||||
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">STIRLING_SERVER_URL <> "" AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||||
</InstallExecuteSequence>
|
</InstallExecuteSequence>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Wix>
|
</Wix>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
|||||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||||
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
||||||
|
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
|
||||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||||
@@ -122,6 +123,9 @@ export function AppProviders({
|
|||||||
<ScarfTrackingInitializer />
|
<ScarfTrackingInitializer />
|
||||||
<AppConfigLoader />
|
<AppConfigLoader />
|
||||||
<ServerDefaultsSync />
|
<ServerDefaultsSync />
|
||||||
|
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
|
||||||
|
No-ops inside Tauri — the desktop popup handles that flow. */}
|
||||||
|
<UpdateStartupPopup />
|
||||||
<FileContextProvider
|
<FileContextProvider
|
||||||
enableUrlSync={true}
|
enableUrlSync={true}
|
||||||
enablePersistence={true}
|
enablePersistence={true}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<UpdateSummary | null>(
|
||||||
|
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 (
|
||||||
|
<UpdateModal
|
||||||
|
opened={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onRemindLater={() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
SNOOZE_KEY,
|
||||||
|
String(Date.now() + SNOOZE_DURATION_MS),
|
||||||
|
);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
currentVersion={currentVersion}
|
||||||
|
updateSummary={updateSummary}
|
||||||
|
machineInfo={machineInfo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateStartupPopup;
|
||||||
+165
-38
@@ -30,21 +30,57 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
|||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { updateService, UpdateSummary } from "@app/services/updateService";
|
import { updateService, UpdateSummary } from "@app/services/updateService";
|
||||||
import UpdateModal from "@app/components/shared/UpdateModal";
|
import UpdateModal from "@app/components/shared/UpdateModal";
|
||||||
|
import type {
|
||||||
|
DesktopInstallState,
|
||||||
|
DesktopInstallProgress,
|
||||||
|
DesktopInstallActions,
|
||||||
|
DesktopInstallCanInstall,
|
||||||
|
} from "@app/components/shared/UpdateModal";
|
||||||
import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo";
|
import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo";
|
||||||
|
|
||||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||||
const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
|
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> | void;
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneralSectionProps {
|
interface GeneralSectionProps {
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
hideUpdateSection?: boolean;
|
hideUpdateSection?: boolean;
|
||||||
hideAdminBanner?: 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<GeneralSectionProps> = ({
|
const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||||
hideTitle = false,
|
hideTitle = false,
|
||||||
hideUpdateSection = false,
|
hideUpdateSection = false,
|
||||||
hideAdminBanner = false,
|
hideAdminBanner = false,
|
||||||
|
desktopInstall,
|
||||||
|
desktopUpdateMode,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
@@ -72,48 +108,56 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||||
}, [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
|
// Check for updates on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.appVersion && config?.machineType) {
|
if (currentVersion) {
|
||||||
checkForUpdate();
|
checkForUpdate();
|
||||||
}
|
}
|
||||||
}, [config?.appVersion, config?.machineType]);
|
}, [currentVersion, config?.machineType]);
|
||||||
|
|
||||||
const checkForUpdate = async () => {
|
const checkForUpdate = async () => {
|
||||||
if (!config?.appVersion || !config?.machineType) {
|
if (!currentVersion) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCheckingUpdate(true);
|
setCheckingUpdate(true);
|
||||||
|
|
||||||
const machineInfo = {
|
const machineInfo = {
|
||||||
machineType: config.machineType,
|
machineType: config?.machineType ?? "unknown",
|
||||||
activeSecurity: config.activeSecurity ?? false,
|
activeSecurity: config?.activeSecurity ?? false,
|
||||||
licenseType: config.license ?? "NORMAL",
|
licenseType: config?.license ?? "NORMAL",
|
||||||
};
|
};
|
||||||
|
|
||||||
const summary = await updateService.getUpdateSummary(
|
const summary = await updateService.getUpdateSummary(
|
||||||
config.appVersion,
|
currentVersion,
|
||||||
machineInfo,
|
machineInfo,
|
||||||
);
|
);
|
||||||
if (summary && summary.latest_version) {
|
|
||||||
const isNewerVersion =
|
if (
|
||||||
updateService.compareVersions(
|
summary?.latest_version &&
|
||||||
summary.latest_version,
|
updateService.compareVersions(summary.latest_version, currentVersion) > 0
|
||||||
config.appVersion,
|
) {
|
||||||
) > 0;
|
setUpdateSummary(summary);
|
||||||
if (isNewerVersion) {
|
|
||||||
setUpdateSummary(summary);
|
|
||||||
} else {
|
|
||||||
// Clear any existing update summary if user is on latest version
|
|
||||||
setUpdateSummary(null);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No update available (latest_version is null) - clear any existing update summary
|
|
||||||
setUpdateSummary(null);
|
setUpdateSummary(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCheckingUpdate(false);
|
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
|
// Check if login is disabled
|
||||||
const loginDisabled = !config?.enableLogin;
|
const loginDisabled = !config?.enableLogin;
|
||||||
|
|
||||||
@@ -215,8 +259,8 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Update Check Section */}
|
{/* Update Check Section — show when backend version is known OR in desktop mode (Tauri version is always available) */}
|
||||||
{!hideUpdateSection && config?.appVersion && (
|
{!hideUpdateSection && (config?.appVersion || !!desktopInstall) && (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div>
|
<div>
|
||||||
@@ -272,16 +316,18 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" c="dimmed">
|
{config?.appVersion && (
|
||||||
{t(
|
<Text size="sm" c="dimmed">
|
||||||
"settings.general.updates.currentBackendVersion",
|
{t(
|
||||||
"Current Backend Version",
|
"settings.general.updates.currentBackendVersion",
|
||||||
)}
|
"Current Backend Version",
|
||||||
:{" "}
|
)}
|
||||||
<Text component="span" fw={500}>
|
:{" "}
|
||||||
{config.appVersion}
|
<Text component="span" fw={500}>
|
||||||
|
{config.appVersion}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
{updateSummary && (
|
{updateSummary && (
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
{t(
|
{t(
|
||||||
@@ -301,6 +347,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={checkForUpdate}
|
onClick={checkForUpdate}
|
||||||
loading={checkingUpdate}
|
loading={checkingUpdate}
|
||||||
|
disabled={!currentVersion}
|
||||||
leftSection={
|
leftSection={
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
icon="refresh-rounded"
|
icon="refresh-rounded"
|
||||||
@@ -335,6 +382,79 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t(
|
||||||
|
"settings.general.updates.updateBehavior",
|
||||||
|
"Update behavior",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{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.
|
||||||
|
<Badge color="blue" variant="light" size="sm" radius="sm">
|
||||||
|
{t(
|
||||||
|
"settings.general.updates.managedByAdmin",
|
||||||
|
"Managed by administrator",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{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.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
disabled={desktopUpdateMode.locked}
|
||||||
|
value={desktopUpdateMode.mode}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
void desktopUpdateMode.onChange(
|
||||||
|
value as "prompt" | "auto" | "disabled",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: "prompt",
|
||||||
|
label: t(
|
||||||
|
"settings.general.updates.modePrompt",
|
||||||
|
"Ask me before installing updates",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: t(
|
||||||
|
"settings.general.updates.modeAuto",
|
||||||
|
"Install updates automatically",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "disabled",
|
||||||
|
label: t(
|
||||||
|
"settings.general.updates.modeDisabled",
|
||||||
|
"Don't check for updates",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
maw={360}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{updateSummary?.any_breaking && (
|
{updateSummary?.any_breaking && (
|
||||||
<Alert
|
<Alert
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -713,17 +833,24 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Update Modal */}
|
{/* Update Modal */}
|
||||||
{updateSummary && config?.appVersion && config?.machineType && (
|
{updateSummary && (config?.appVersion || !!desktopInstall) && (
|
||||||
<UpdateModal
|
<UpdateModal
|
||||||
opened={updateModalOpened}
|
opened={updateModalOpened}
|
||||||
onClose={() => setUpdateModalOpened(false)}
|
onClose={() => setUpdateModalOpened(false)}
|
||||||
currentVersion={config.appVersion}
|
onRemindLater={() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"stirling-pdf-updater:snoozedUntil",
|
||||||
|
String(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
currentVersion={appVersion ?? config?.appVersion ?? ""}
|
||||||
updateSummary={updateSummary}
|
updateSummary={updateSummary}
|
||||||
machineInfo={{
|
machineInfo={{
|
||||||
machineType: config.machineType,
|
machineType: config?.machineType ?? "unknown",
|
||||||
activeSecurity: config.activeSecurity ?? false,
|
activeSecurity: config?.activeSecurity ?? false,
|
||||||
licenseType: config.license ?? "NORMAL",
|
licenseType: config?.license ?? "NORMAL",
|
||||||
}}
|
}}
|
||||||
|
desktopInstall={desktopInstallProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
|
|||||||
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
|
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
|
||||||
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
|
||||||
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
|
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
|
||||||
|
import UpdateModal from "@core/components/shared/UpdateModal";
|
||||||
|
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
|
||||||
|
|
||||||
// Common tool endpoints to preload for faster first-use
|
// Common tool endpoints to preload for faster first-use
|
||||||
const COMMON_TOOL_ENDPOINTS = [
|
const COMMON_TOOL_ENDPOINTS = [
|
||||||
@@ -48,6 +50,7 @@ const COMMON_TOOL_ENDPOINTS = [
|
|||||||
*/
|
*/
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||||
|
const updatePopup = useDesktopUpdatePopup();
|
||||||
const [connectionMode, setConnectionMode] = useState<
|
const [connectionMode, setConnectionMode] = useState<
|
||||||
"saas" | "selfhosted" | "local" | null
|
"saas" | "selfhosted" | "local" | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -264,6 +267,38 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [authChecked]);
|
}, [authChecked]);
|
||||||
|
|
||||||
|
// Desktop auto-update popup (shown on startup if update available)
|
||||||
|
const { state: popupState, actions: popupActions } = updatePopup;
|
||||||
|
const updatePopupModal = popupState.updateSummary && (
|
||||||
|
<UpdateModal
|
||||||
|
opened={popupState.showModal}
|
||||||
|
onClose={popupActions.dismissModal}
|
||||||
|
onRemindLater={popupActions.remindLater}
|
||||||
|
currentVersion={popupState.currentVersion}
|
||||||
|
updateSummary={popupState.updateSummary}
|
||||||
|
machineInfo={{
|
||||||
|
machineType: navigator.platform?.toLowerCase().includes("mac")
|
||||||
|
? "Client-mac"
|
||||||
|
: navigator.platform?.toLowerCase().includes("linux")
|
||||||
|
? "Client-unix"
|
||||||
|
: "Client-win",
|
||||||
|
activeSecurity: false,
|
||||||
|
licenseType: "NORMAL",
|
||||||
|
}}
|
||||||
|
desktopInstall={
|
||||||
|
popupState.tauriInstallReady
|
||||||
|
? {
|
||||||
|
state: popupState.state,
|
||||||
|
progress: popupState.progress,
|
||||||
|
errorMessage: popupState.errorMessage,
|
||||||
|
canInstall: popupState.canInstall,
|
||||||
|
actions: popupActions,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<ProprietaryAppProviders
|
<ProprietaryAppProviders
|
||||||
@@ -278,6 +313,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minHeight: "100vh" }} />
|
<div style={{ minHeight: "100vh" }} />
|
||||||
|
{updatePopupModal}
|
||||||
</ProprietaryAppProviders>
|
</ProprietaryAppProviders>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -313,6 +349,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
<DesktopOnboardingModal />
|
<DesktopOnboardingModal />
|
||||||
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
|
||||||
<SignInModal />
|
<SignInModal />
|
||||||
|
{/* Desktop auto-update popup */}
|
||||||
|
{updatePopupModal}
|
||||||
</SaaSCheckoutProvider>
|
</SaaSCheckoutProvider>
|
||||||
</SaasBillingProvider>
|
</SaasBillingProvider>
|
||||||
</SaaSTeamProvider>
|
</SaaSTeamProvider>
|
||||||
|
|||||||
+102
-4
@@ -1,16 +1,114 @@
|
|||||||
import React from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack, Alert } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
|
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
|
||||||
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
|
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
|
||||||
|
import { useDesktopInstall } from "@app/hooks/useDesktopInstall";
|
||||||
|
import {
|
||||||
|
desktopUpdateService,
|
||||||
|
type UpdateMode,
|
||||||
|
type UpdateModeInfo,
|
||||||
|
} from "@app/services/desktopUpdateService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop extension of GeneralSection that adds default PDF editor settings
|
* Desktop extension of GeneralSection.
|
||||||
|
*
|
||||||
|
* Adds default PDF editor settings, wires up the Tauri auto-updater install
|
||||||
|
* flow, and exposes the user-facing update-mode control (prompt / auto /
|
||||||
|
* disabled). When the mode is locked by a provisioning file the control is
|
||||||
|
* still rendered but disabled, with a "Managed by administrator" hint, so
|
||||||
|
* managed-deployment users can see what policy is in effect.
|
||||||
*/
|
*/
|
||||||
const GeneralSection: React.FC = () => {
|
const GeneralSection: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const install = useDesktopInstall();
|
||||||
|
const [updateModeInfo, setUpdateModeInfo] = useState<UpdateModeInfo>({
|
||||||
|
mode: "prompt",
|
||||||
|
locked: false,
|
||||||
|
});
|
||||||
|
const [updateModeError, setUpdateModeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check for Tauri updater availability on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void install.checkTauriUpdate();
|
||||||
|
}, [install.checkTauriUpdate]);
|
||||||
|
|
||||||
|
// Load the current update mode + lock status on mount. We intentionally
|
||||||
|
// re-fetch on every mount so that a provisioning file dropped while the
|
||||||
|
// app is running (admin re-pushes config via MDM) is reflected the next
|
||||||
|
// time the user opens Settings — the Rust side re-reads the store on
|
||||||
|
// every call, so this is essentially a fresh read.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
desktopUpdateService.getUpdateModeInfo().then((info) => {
|
||||||
|
if (!cancelled) setUpdateModeInfo(info);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateModeChange = useCallback(
|
||||||
|
async (mode: UpdateMode) => {
|
||||||
|
setUpdateModeError(null);
|
||||||
|
try {
|
||||||
|
await desktopUpdateService.setUpdateMode(mode);
|
||||||
|
// Refresh rather than optimistically updating — the Rust command
|
||||||
|
// can refuse the change (locked) and we want the UI to reflect
|
||||||
|
// the authoritative stored value.
|
||||||
|
const fresh = await desktopUpdateService.getUpdateModeInfo();
|
||||||
|
setUpdateModeInfo(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[GeneralSection] setUpdateMode failed:", err);
|
||||||
|
const msg =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === "string"
|
||||||
|
? err
|
||||||
|
: t(
|
||||||
|
"settings.general.updates.updateBehaviorErrorLocked",
|
||||||
|
"This setting is locked by your administrator.",
|
||||||
|
);
|
||||||
|
setUpdateModeError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<DefaultAppSettings />
|
<DefaultAppSettings />
|
||||||
<CoreGeneralSection />
|
{updateModeError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
title={t(
|
||||||
|
"settings.general.updates.updateBehaviorError",
|
||||||
|
"Could not change update behavior",
|
||||||
|
)}
|
||||||
|
withCloseButton
|
||||||
|
onClose={() => setUpdateModeError(null)}
|
||||||
|
>
|
||||||
|
{updateModeError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<CoreGeneralSection
|
||||||
|
hideUpdateSection={
|
||||||
|
updateModeInfo.mode === "disabled" && updateModeInfo.locked
|
||||||
|
}
|
||||||
|
desktopInstall={{
|
||||||
|
state: install.state,
|
||||||
|
progress: install.progress,
|
||||||
|
errorMessage: install.errorMessage,
|
||||||
|
tauriInstallReady: install.tauriInstallReady,
|
||||||
|
canInstall: install.canInstall,
|
||||||
|
actions: install.actions,
|
||||||
|
}}
|
||||||
|
desktopUpdateMode={{
|
||||||
|
mode: updateModeInfo.mode,
|
||||||
|
locked: updateModeInfo.locked,
|
||||||
|
onChange: handleUpdateModeChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import type {
|
||||||
|
DesktopInstallState,
|
||||||
|
DesktopInstallProgress,
|
||||||
|
DesktopInstallActions,
|
||||||
|
} from "@core/components/shared/UpdateModal";
|
||||||
|
import {
|
||||||
|
desktopUpdateService,
|
||||||
|
type CanInstallResult,
|
||||||
|
} from "@app/services/desktopUpdateService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only hook managing the Tauri updater install state.
|
||||||
|
* Provides state + actions that get passed to the core UpdateModal
|
||||||
|
* via the desktop GeneralSection override.
|
||||||
|
*/
|
||||||
|
export function useDesktopInstall() {
|
||||||
|
const [state, setState] = useState<DesktopInstallState>("idle");
|
||||||
|
const [progress, setProgress] = useState<DesktopInstallProgress | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [tauriInstallReady, setTauriInstallReady] = useState(false);
|
||||||
|
/**
|
||||||
|
* Result of the `can_install_updates` probe. Populated when
|
||||||
|
* [`checkTauriUpdate`] finds an update, because that's when it becomes
|
||||||
|
* relevant — no point worrying about install permissions until we know
|
||||||
|
* there's actually something to install.
|
||||||
|
*
|
||||||
|
* `null` means "haven't probed yet". When the probe runs and returns
|
||||||
|
* `canInstall: false` the UpdateModal shows an inline blocked warning
|
||||||
|
* with a link to the installation docs, and disables the Install Now
|
||||||
|
* button so users can't click into a UAC prompt they can't satisfy.
|
||||||
|
*/
|
||||||
|
const [canInstall, setCanInstall] = useState<CanInstallResult | null>(null);
|
||||||
|
|
||||||
|
// Listen for the ready-to-restart event from Rust
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: UnlistenFn | undefined;
|
||||||
|
listen<void>("update-ready-to-restart", () => {
|
||||||
|
setState("ready-to-restart");
|
||||||
|
}).then((fn) => {
|
||||||
|
unlisten = fn;
|
||||||
|
});
|
||||||
|
return () => unlisten?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the Tauri updater has a downloadable build for the
|
||||||
|
* current platform, and if so probe whether we can actually install it
|
||||||
|
* without needing UAC elevation.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Check whether the Tauri updater has a downloadable build for the
|
||||||
|
* current platform, and if so probe whether we can actually install it
|
||||||
|
* without needing UAC elevation.
|
||||||
|
*
|
||||||
|
* Returns `true` when an in-app install is available — callers MUST use
|
||||||
|
* the return value instead of reading `tauriInstallReady` from the hook's
|
||||||
|
* state, because React state updates are async and won't be visible
|
||||||
|
* until the next render.
|
||||||
|
*/
|
||||||
|
const checkTauriUpdate = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ version: string } | null>(
|
||||||
|
"check_for_update",
|
||||||
|
);
|
||||||
|
const ready = !!result;
|
||||||
|
setTauriInstallReady(ready);
|
||||||
|
if (result) {
|
||||||
|
const ci = await desktopUpdateService.canInstallUpdates();
|
||||||
|
setCanInstall(ci);
|
||||||
|
} else {
|
||||||
|
setCanInstall(null);
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
} catch {
|
||||||
|
setTauriInstallReady(false);
|
||||||
|
setCanInstall(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startInstall: DesktopInstallActions["startInstall"] =
|
||||||
|
useCallback(async () => {
|
||||||
|
setState("downloading");
|
||||||
|
setProgress(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
let progressUnlisten: UnlistenFn | undefined;
|
||||||
|
let finishUnlisten: UnlistenFn | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
progressUnlisten = await listen<DesktopInstallProgress>(
|
||||||
|
"update-download-progress",
|
||||||
|
(event) => {
|
||||||
|
setProgress(event.payload);
|
||||||
|
if (event.payload.percent >= 100) setState("installing");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
finishUnlisten = await listen<void>("update-download-finished", () => {
|
||||||
|
setState("installing");
|
||||||
|
});
|
||||||
|
await invoke<void>("download_and_install_update");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[useDesktopInstall] Install failed:", err);
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
setState("error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
progressUnlisten?.();
|
||||||
|
finishUnlisten?.();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restartApp: DesktopInstallActions["restartApp"] =
|
||||||
|
useCallback(async () => {
|
||||||
|
await invoke<void>("restart_app");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
progress,
|
||||||
|
errorMessage,
|
||||||
|
tauriInstallReady,
|
||||||
|
canInstall,
|
||||||
|
checkTauriUpdate,
|
||||||
|
actions: { startInstall, restartApp },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// ── Mocks (hoisted by vi.mock) ──────────────────────────────────────────────
|
||||||
|
const invokeMock = vi.fn();
|
||||||
|
const listenMock = vi.fn();
|
||||||
|
const getVersionMock = vi.fn();
|
||||||
|
const getUpdateModeMock = vi.fn();
|
||||||
|
const canInstallUpdatesMock = vi.fn();
|
||||||
|
const getUpdateSummaryMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: (event: string, cb: unknown) => listenMock(event, cb),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/app", () => ({
|
||||||
|
getVersion: () => getVersionMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/services/updateService", () => ({
|
||||||
|
updateService: {
|
||||||
|
getUpdateSummary: (...args: unknown[]) => getUpdateSummaryMock(...args),
|
||||||
|
compareVersions: (a: string, b: string) => (a === b ? 0 : a > b ? 1 : -1),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/services/desktopUpdateService", () => ({
|
||||||
|
desktopUpdateService: {
|
||||||
|
getUpdateMode: () => getUpdateModeMock(),
|
||||||
|
canInstallUpdates: () => canInstallUpdatesMock(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
|
||||||
|
|
||||||
|
/** Flush pending microtasks so awaited promises settle. */
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_FAILURE_KEY = "stirling-pdf-updater:autoFailedAt";
|
||||||
|
|
||||||
|
/** Run the hook through its startup timer + async chain. */
|
||||||
|
async function runStartup() {
|
||||||
|
renderHook(() => useDesktopUpdatePopup());
|
||||||
|
await vi.advanceTimersByTimeAsync(16_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await flushMicrotasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDesktopUpdatePopup — auto mode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
window.localStorage.clear();
|
||||||
|
invokeMock.mockReset();
|
||||||
|
listenMock.mockReset();
|
||||||
|
getVersionMock.mockReset();
|
||||||
|
getUpdateModeMock.mockReset();
|
||||||
|
canInstallUpdatesMock.mockReset();
|
||||||
|
getUpdateSummaryMock.mockReset();
|
||||||
|
|
||||||
|
// Defaults: auto mode, update available, install permitted.
|
||||||
|
getUpdateModeMock.mockResolvedValue("auto");
|
||||||
|
getVersionMock.mockResolvedValue("1.0.0");
|
||||||
|
getUpdateSummaryMock.mockResolvedValue({ latest_version: "2.0.0" });
|
||||||
|
canInstallUpdatesMock.mockResolvedValue({
|
||||||
|
canInstall: true,
|
||||||
|
reason: null,
|
||||||
|
installDir: "C:/Program Files/Stirling-PDF",
|
||||||
|
});
|
||||||
|
listenMock.mockResolvedValue(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call restart_app when download_and_install_update fails", async () => {
|
||||||
|
invokeMock.mockImplementation((cmd: string) => {
|
||||||
|
if (cmd === "check_for_update") {
|
||||||
|
return Promise.resolve({
|
||||||
|
version: "2.0.0",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
releaseNotes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cmd === "download_and_install_update") {
|
||||||
|
return Promise.reject(new Error("signature mismatch"));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStartup();
|
||||||
|
|
||||||
|
const invocations = invokeMock.mock.calls.map((c) => c[0]);
|
||||||
|
expect(invocations).toContain("download_and_install_update");
|
||||||
|
expect(invocations).not.toContain("restart_app");
|
||||||
|
|
||||||
|
// Failure must be persisted so the next launch backs off instead of
|
||||||
|
// re-attempting a known-broken install.
|
||||||
|
expect(window.localStorage.getItem(AUTO_FAILURE_KEY)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls restart_app when download_and_install_update succeeds", async () => {
|
||||||
|
invokeMock.mockImplementation((cmd: string) => {
|
||||||
|
if (cmd === "check_for_update") {
|
||||||
|
return Promise.resolve({
|
||||||
|
version: "2.0.0",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
releaseNotes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// download_and_install_update + restart_app both resolve.
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
// Pre-seed a stale failure timestamp — a successful install must clear it.
|
||||||
|
window.localStorage.setItem(AUTO_FAILURE_KEY, "1");
|
||||||
|
|
||||||
|
await runStartup();
|
||||||
|
|
||||||
|
const invocations = invokeMock.mock.calls.map((c) => c[0]);
|
||||||
|
expect(invocations).toContain("download_and_install_update");
|
||||||
|
expect(invocations).toContain("restart_app");
|
||||||
|
expect(window.localStorage.getItem(AUTO_FAILURE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the install entirely when a recent failure is within the backoff window", async () => {
|
||||||
|
// Recorded 1 hour ago — well inside the 6-hour backoff.
|
||||||
|
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
|
window.localStorage.setItem(AUTO_FAILURE_KEY, String(oneHourAgo));
|
||||||
|
|
||||||
|
invokeMock.mockImplementation((cmd: string) => {
|
||||||
|
if (cmd === "check_for_update") {
|
||||||
|
return Promise.resolve({
|
||||||
|
version: "2.0.0",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
releaseNotes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStartup();
|
||||||
|
|
||||||
|
const invocations = invokeMock.mock.calls.map((c) => c[0]);
|
||||||
|
expect(invocations).not.toContain("download_and_install_update");
|
||||||
|
expect(invocations).not.toContain("restart_app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts the install again once the backoff has elapsed", async () => {
|
||||||
|
// Recorded 7 hours ago — past the 6-hour backoff.
|
||||||
|
const sevenHoursAgo = Date.now() - 7 * 60 * 60 * 1000;
|
||||||
|
window.localStorage.setItem(AUTO_FAILURE_KEY, String(sevenHoursAgo));
|
||||||
|
|
||||||
|
invokeMock.mockImplementation((cmd: string) => {
|
||||||
|
if (cmd === "check_for_update") {
|
||||||
|
return Promise.resolve({
|
||||||
|
version: "2.0.0",
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
releaseNotes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStartup();
|
||||||
|
|
||||||
|
const invocations = invokeMock.mock.calls.map((c) => c[0]);
|
||||||
|
expect(invocations).toContain("download_and_install_update");
|
||||||
|
expect(invocations).toContain("restart_app");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { updateService, UpdateSummary } from "@app/services/updateService";
|
||||||
|
import { useDesktopInstall } from "@app/hooks/useDesktopInstall";
|
||||||
|
import {
|
||||||
|
desktopUpdateService,
|
||||||
|
type CanInstallResult,
|
||||||
|
} from "@app/services/desktopUpdateService";
|
||||||
|
|
||||||
|
const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil";
|
||||||
|
const STARTUP_DELAY_MS = 15_000;
|
||||||
|
/**
|
||||||
|
* When the headless auto-update path fails (bad signature, corrupt download,
|
||||||
|
* disk full, network drop mid-install) we record the timestamp here and skip
|
||||||
|
* the auto attempt for [`AUTO_FAILURE_BACKOFF_MS`] before trying again. Without
|
||||||
|
* this, a persistent failure restart-loops the app on every launch.
|
||||||
|
*
|
||||||
|
* The interactive flow still works during the backoff window — the user can
|
||||||
|
* open Settings → Software Updates and click Install Now manually, which
|
||||||
|
* clears the backoff on success.
|
||||||
|
*/
|
||||||
|
const AUTO_FAILURE_KEY = "stirling-pdf-updater:autoFailedAt";
|
||||||
|
const AUTO_FAILURE_BACKOFF_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only hook that checks for updates on startup and handles the
|
||||||
|
* three update modes configured via the tauri store (or the MDM
|
||||||
|
* provisioning file):
|
||||||
|
*
|
||||||
|
* * `disabled` — no check is performed, no UI is shown, no network call.
|
||||||
|
* * `auto` — FULLY HEADLESS. We download + install + restart in the
|
||||||
|
* background with no modal and no countdown. The whole
|
||||||
|
* point of `auto` is that the admin has already decided
|
||||||
|
* for the user; adding a UI on top would defeat that.
|
||||||
|
* If the Rust `can_install_updates` probe reports the
|
||||||
|
* current process can't write to the install directory
|
||||||
|
* (non-admin on a per-machine install, typical MDM case),
|
||||||
|
* we silently skip — no annoying popup on every launch.
|
||||||
|
* * `prompt` — default interactive flow. Shows the UpdateModal so the
|
||||||
|
* user can decide. If the probe reports we can't install,
|
||||||
|
* the modal renders an inline "administrator permissions
|
||||||
|
* required" alert and disables the Install Now button —
|
||||||
|
* the user learns about the restriction at the moment
|
||||||
|
* they try to act on it.
|
||||||
|
*
|
||||||
|
* The `Later` snooze is only honoured in interactive mode. Auto mode
|
||||||
|
* ignores the snooze so enterprise installs never drift onto old versions
|
||||||
|
* just because a previous user clicked Later once.
|
||||||
|
*/
|
||||||
|
export function useDesktopUpdatePopup() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [currentVersion, setCurrentVersion] = useState("");
|
||||||
|
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
// Popup-local canInstall — kept separate from install.canInstall so the
|
||||||
|
// modal can be guaranteed to receive the probe result on the same render
|
||||||
|
// that opens the modal. Setting setCanInstall + setShowModal in the same
|
||||||
|
// microtask lets React batch them into a single render.
|
||||||
|
const [canInstall, setCanInstall] = useState<CanInstallResult | null>(null);
|
||||||
|
const install = useDesktopInstall();
|
||||||
|
const hasChecked = useRef(false);
|
||||||
|
|
||||||
|
// Keep a ref to install so the startup effect can call into it without
|
||||||
|
// re-firing on every re-render (install.actions is a fresh object each time).
|
||||||
|
const installRef = useRef(install);
|
||||||
|
installRef.current = install;
|
||||||
|
|
||||||
|
// Startup check
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasChecked.current) return;
|
||||||
|
hasChecked.current = true;
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
let mode: Awaited<ReturnType<typeof desktopUpdateService.getUpdateMode>> =
|
||||||
|
"prompt";
|
||||||
|
try {
|
||||||
|
mode = await desktopUpdateService.getUpdateMode();
|
||||||
|
} catch {
|
||||||
|
/* fall through with default */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "disabled") {
|
||||||
|
// Managed deployment opted out of updates entirely — nothing to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Honour the user's "remind me later" snooze, but only in the interactive
|
||||||
|
// flow. `auto` mode always applies updates so enterprise installs never
|
||||||
|
// drift onto old versions just because a previous user pressed Later.
|
||||||
|
if (mode === "prompt") {
|
||||||
|
const snoozedUntil = localStorage.getItem(SNOOZE_KEY);
|
||||||
|
if (snoozedUntil && Date.now() < parseInt(snoozedUntil, 10)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const version = await getVersion();
|
||||||
|
setCurrentVersion(version);
|
||||||
|
|
||||||
|
const platform = navigator.platform?.toLowerCase() ?? "";
|
||||||
|
const machineType = platform.includes("mac")
|
||||||
|
? "Client-mac"
|
||||||
|
: platform.includes("linux")
|
||||||
|
? "Client-unix"
|
||||||
|
: "Client-win";
|
||||||
|
const machineInfo = {
|
||||||
|
machineType,
|
||||||
|
activeSecurity: false,
|
||||||
|
licenseType: "NORMAL",
|
||||||
|
};
|
||||||
|
const summary = await updateService.getUpdateSummary(
|
||||||
|
version,
|
||||||
|
machineInfo,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!summary?.latest_version ||
|
||||||
|
updateService.compareVersions(summary.latest_version, version) <= 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Ask the Tauri updater whether it can provide an in-app install.
|
||||||
|
// This may fail (placeholder pubkey, 404 on latest.json, signature
|
||||||
|
// mismatch, …) — that's fine, it just means we show "Download
|
||||||
|
// Latest" instead of "Install Now". The Supabase summary above is
|
||||||
|
// the source of truth for "is there a newer version at all"; the
|
||||||
|
// Tauri endpoint is only about "can we install it in-process".
|
||||||
|
//
|
||||||
|
// IMPORTANT: use the RETURN VALUE, not installRef.current.tauriInstallReady.
|
||||||
|
// React state updates are async — the ref would still hold the
|
||||||
|
// stale pre-check value at this point in the microtask.
|
||||||
|
const tauriReady = await installRef.current.checkTauriUpdate();
|
||||||
|
|
||||||
|
if (mode === "auto") {
|
||||||
|
// Auto mode requires a working Tauri updater — we can't headless-
|
||||||
|
// install via an external download link. If the tauri endpoint
|
||||||
|
// is broken, or the user can't install, silently skip. The
|
||||||
|
// interactive flow will still show the "Download Latest" fallback
|
||||||
|
// if they ever open the popup manually.
|
||||||
|
if (!tauriReady) {
|
||||||
|
console.warn(
|
||||||
|
"[DesktopUpdatePopup] auto-update skipped: tauri updater not available (pubkey/endpoint/signature issue?)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the headless attempt if a previous one failed recently.
|
||||||
|
// hasChecked is in-memory only, so without this guard a persistent
|
||||||
|
// failure (bad signature, corrupt download) restart-loops the app.
|
||||||
|
const failedAt = localStorage.getItem(AUTO_FAILURE_KEY);
|
||||||
|
if (
|
||||||
|
failedAt &&
|
||||||
|
Date.now() - parseInt(failedAt, 10) < AUTO_FAILURE_BACKOFF_MS
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"[DesktopUpdatePopup] auto-update skipped: recent failure within backoff window",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ci: CanInstallResult =
|
||||||
|
await desktopUpdateService.canInstallUpdates();
|
||||||
|
if (!ci.canInstall) {
|
||||||
|
console.warn(
|
||||||
|
"[DesktopUpdatePopup] auto-update silently skipped: install dir not writable",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fully headless flow: download + install + restart with no UI.
|
||||||
|
// restart_app MUST be gated on install success — otherwise a failed
|
||||||
|
// install (which startInstall catches internally) cascades into a
|
||||||
|
// restart, and the in-memory `hasChecked` ref resets, so the next
|
||||||
|
// launch retries the same broken update. Restart loop.
|
||||||
|
let installed = false;
|
||||||
|
try {
|
||||||
|
installed = await installRef.current.actions.startInstall();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[DesktopUpdatePopup] auto-update failed:", err);
|
||||||
|
}
|
||||||
|
if (installed) {
|
||||||
|
localStorage.removeItem(AUTO_FAILURE_KEY);
|
||||||
|
try {
|
||||||
|
await installRef.current.actions.restartApp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"[DesktopUpdatePopup] restart after auto-update failed:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(AUTO_FAILURE_KEY, String(Date.now()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive mode: always show the modal. The footer renders
|
||||||
|
// "Install Now" when tauriInstallReady is true, or falls back
|
||||||
|
// to "Download Latest" (external link) when the Tauri updater
|
||||||
|
// is unavailable — so the user always has a path forward even
|
||||||
|
// when latest.json is missing, the pubkey is wrong, or the
|
||||||
|
// signature doesn't match.
|
||||||
|
const ci: CanInstallResult =
|
||||||
|
await desktopUpdateService.canInstallUpdates();
|
||||||
|
setUpdateSummary(summary);
|
||||||
|
setCanInstall(ci);
|
||||||
|
setShowModal(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[DesktopUpdatePopup] Startup check failed:", err);
|
||||||
|
}
|
||||||
|
}, STARTUP_DELAY_MS);
|
||||||
|
|
||||||
|
// Intentionally empty deps — this is a one-shot startup effect guarded
|
||||||
|
// by `hasChecked.current`. Adding `install` to the deps would re-fire
|
||||||
|
// the timer every time the install state changes, which is wrong.
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissModal = useCallback(() => setShowModal(false), []);
|
||||||
|
const remindLater = useCallback(() => {
|
||||||
|
localStorage.setItem(SNOOZE_KEY, String(Date.now() + 24 * 60 * 60 * 1000));
|
||||||
|
setShowModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
showModal,
|
||||||
|
currentVersion,
|
||||||
|
updateSummary,
|
||||||
|
...install,
|
||||||
|
// Override the install hook's canInstall with the popup-local copy
|
||||||
|
// so consumers see the value captured at the moment the modal was
|
||||||
|
// opened. Prevents a race where install.canInstall is still null
|
||||||
|
// when the modal first renders.
|
||||||
|
canInstall,
|
||||||
|
},
|
||||||
|
actions: { dismissModal, remindLater, ...install.actions },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
/** New version string, e.g. "2.8.0" */
|
||||||
|
version: string;
|
||||||
|
/** Currently installed version string */
|
||||||
|
currentVersion: string;
|
||||||
|
/** Release notes from the update endpoint, if any */
|
||||||
|
releaseNotes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgress {
|
||||||
|
/** Total bytes downloaded so far */
|
||||||
|
downloaded: number;
|
||||||
|
/** Total bytes to download (null if unknown) */
|
||||||
|
total: number | null;
|
||||||
|
/** Download percentage 0–100 */
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless-install update policy, set via the `updateMode` field of a
|
||||||
|
* `stirling-provisioning.json` file dropped by MDM / Intune tooling — or
|
||||||
|
* from the Settings → Software Updates panel by the user.
|
||||||
|
*
|
||||||
|
* * `prompt` – default. Show the update popup and let the user decide.
|
||||||
|
* * `auto` – silently download, install, and restart on startup.
|
||||||
|
* * `disabled` – never check for updates or surface update UI.
|
||||||
|
*/
|
||||||
|
export type UpdateMode = "prompt" | "auto" | "disabled";
|
||||||
|
|
||||||
|
/** Current [`UpdateMode`] plus whether the UI can change it. */
|
||||||
|
export interface UpdateModeInfo {
|
||||||
|
mode: UpdateMode;
|
||||||
|
/**
|
||||||
|
* `true` when the mode was written by a provisioning file. The Settings
|
||||||
|
* control should render disabled with a "Managed by your administrator"
|
||||||
|
* hint; attempts to call [`setUpdateMode`] are rejected by the Rust
|
||||||
|
* command with an error so the UI can't quietly fall out of sync.
|
||||||
|
*/
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by the `can_install_updates` Tauri command. */
|
||||||
|
export interface CanInstallResult {
|
||||||
|
/** `true` when the current process can write to the install directory. */
|
||||||
|
canInstall: boolean;
|
||||||
|
/** Machine-readable reason when `canInstall` is `false`. */
|
||||||
|
reason: string | null;
|
||||||
|
/** The directory that was probed, for display in error messages. */
|
||||||
|
installDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── localStorage keys ────────────────────────────────────────────────────────
|
||||||
|
const KEY_PREFIX = "stirling-pdf-updater:";
|
||||||
|
const KEY_LAST_CHECKED = `${KEY_PREFIX}lastChecked`;
|
||||||
|
const KEY_CHECK_INTERVAL_HOURS = `${KEY_PREFIX}checkIntervalHours`;
|
||||||
|
const KEY_AUTO_DOWNLOAD = `${KEY_PREFIX}autoDownload`;
|
||||||
|
const KEY_SNOOZED_UNTIL = `${KEY_PREFIX}snoozedUntil`;
|
||||||
|
|
||||||
|
const DEFAULT_CHECK_INTERVAL_HOURS = 24;
|
||||||
|
|
||||||
|
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DesktopUpdateService {
|
||||||
|
private periodicCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private progressUnlisten: UnlistenFn | null = null;
|
||||||
|
private finishUnlisten: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
// ── Tauri command wrappers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Rust updater to check the configured endpoint.
|
||||||
|
* Returns `null` when up-to-date or when the check fails silently
|
||||||
|
* (e.g. no network, bad pubkey configuration).
|
||||||
|
*/
|
||||||
|
async checkForUpdate(): Promise<UpdateInfo | null> {
|
||||||
|
try {
|
||||||
|
return await invoke<UpdateInfo | null>("check_for_update");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DesktopUpdateService] check_for_update failed:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install the available update.
|
||||||
|
*
|
||||||
|
* `onProgress` is called for each received chunk.
|
||||||
|
* `onFinish` is called when the download is complete and the installer
|
||||||
|
* has been written to disk (install is underway).
|
||||||
|
*
|
||||||
|
* Throws if the download or install fails — callers should catch and fall
|
||||||
|
* back to opening the download page.
|
||||||
|
*/
|
||||||
|
async downloadAndInstall(
|
||||||
|
onProgress: (progress: UpdateProgress) => void,
|
||||||
|
onFinish: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
this.cleanupEventListeners();
|
||||||
|
|
||||||
|
this.progressUnlisten = await listen<UpdateProgress>(
|
||||||
|
"update-download-progress",
|
||||||
|
(event) => onProgress(event.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.finishUnlisten = await listen<void>("update-download-finished", () =>
|
||||||
|
onFinish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke<void>("download_and_install_update");
|
||||||
|
} finally {
|
||||||
|
this.cleanupEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the app to apply an already-installed update.
|
||||||
|
* The process will be replaced — this call never returns normally.
|
||||||
|
*/
|
||||||
|
async restartApp(): Promise<void> {
|
||||||
|
await invoke<void>("restart_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently running app version string. */
|
||||||
|
async getAppVersion(): Promise<string> {
|
||||||
|
return invoke<string>("get_app_version");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured update mode plus whether it's locked by provisioning.
|
||||||
|
*
|
||||||
|
* Falls back to `{mode: 'prompt', locked: false}` if the Tauri command is
|
||||||
|
* unavailable (e.g. running in a browser dev environment) so non-managed
|
||||||
|
* installs always get the default interactive flow.
|
||||||
|
*/
|
||||||
|
async getUpdateModeInfo(): Promise<UpdateModeInfo> {
|
||||||
|
try {
|
||||||
|
return await invoke<UpdateModeInfo>("get_update_mode");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[DesktopUpdateService] get_update_mode failed, defaulting to prompt/unlocked:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return { mode: "prompt", locked: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience wrapper when callers only need the mode itself. */
|
||||||
|
async getUpdateMode(): Promise<UpdateMode> {
|
||||||
|
return (await this.getUpdateModeInfo()).mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a user-chosen update mode. Throws when the mode is locked by
|
||||||
|
* provisioning — callers should not swallow that error; it should be
|
||||||
|
* surfaced as a UI toast or equivalent so the user knows why nothing
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
async setUpdateMode(mode: UpdateMode): Promise<void> {
|
||||||
|
await invoke<void>("set_update_mode", { mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the Tauri updater is likely to be able to install a new
|
||||||
|
* version silently on this machine. See the Rust docstring on
|
||||||
|
* `can_install_updates` for the underlying write-probe heuristic.
|
||||||
|
*
|
||||||
|
* Returns an optimistic `{canInstall: true, ...}` if the Tauri command is
|
||||||
|
* unavailable so we don't block updates in non-Tauri dev environments.
|
||||||
|
*/
|
||||||
|
async canInstallUpdates(): Promise<CanInstallResult> {
|
||||||
|
try {
|
||||||
|
return await invoke<CanInstallResult>("can_install_updates");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[DesktopUpdateService] can_install_updates failed, assuming allowed:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return { canInstall: true, reason: null, installDir: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Periodic checking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a recurring timer that checks for updates at the configured
|
||||||
|
* interval. `onUpdateAvailable` is called when a newer version is found.
|
||||||
|
*
|
||||||
|
* The timer respects the snooze setting — if the user has snoozed
|
||||||
|
* notifications, the callback is suppressed until the snooze expires.
|
||||||
|
*/
|
||||||
|
startPeriodicChecks(
|
||||||
|
onUpdateAvailable: (update: UpdateInfo) => void,
|
||||||
|
intervalHours?: number,
|
||||||
|
): void {
|
||||||
|
this.stopPeriodicChecks();
|
||||||
|
|
||||||
|
const hours = intervalHours ?? this.getCheckIntervalHours();
|
||||||
|
const intervalMs = hours * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
this.periodicCheckTimer = setInterval(async () => {
|
||||||
|
if (!this.shouldCheckNow()) return;
|
||||||
|
const update = await this.checkForUpdate();
|
||||||
|
this.setLastChecked();
|
||||||
|
if (update) onUpdateAvailable(update);
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPeriodicChecks(): void {
|
||||||
|
if (this.periodicCheckTimer !== null) {
|
||||||
|
clearInterval(this.periodicCheckTimer);
|
||||||
|
this.periodicCheckTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings (persisted to localStorage) ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` when enough time has elapsed since the last check
|
||||||
|
* AND the user has not snoozed update notifications.
|
||||||
|
*/
|
||||||
|
shouldCheckNow(): boolean {
|
||||||
|
const snoozedUntil = this.getSnoozedUntil();
|
||||||
|
if (snoozedUntil !== null && Date.now() < snoozedUntil) return false;
|
||||||
|
|
||||||
|
const lastChecked = this.getLastChecked();
|
||||||
|
if (lastChecked === null) return true;
|
||||||
|
|
||||||
|
const intervalMs = this.getCheckIntervalHours() * 60 * 60 * 1000;
|
||||||
|
return Date.now() - lastChecked >= intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCheckIntervalHours(): number {
|
||||||
|
const stored = localStorage.getItem(KEY_CHECK_INTERVAL_HOURS);
|
||||||
|
return stored !== null
|
||||||
|
? parseInt(stored, 10)
|
||||||
|
: DEFAULT_CHECK_INTERVAL_HOURS;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckIntervalHours(hours: number): void {
|
||||||
|
localStorage.setItem(KEY_CHECK_INTERVAL_HOURS, String(hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoDownloadEnabled(): boolean {
|
||||||
|
return localStorage.getItem(KEY_AUTO_DOWNLOAD) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoDownload(value: boolean): void {
|
||||||
|
localStorage.setItem(KEY_AUTO_DOWNLOAD, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastChecked(): number | null {
|
||||||
|
const stored = localStorage.getItem(KEY_LAST_CHECKED);
|
||||||
|
return stored !== null ? parseInt(stored, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastChecked(): void {
|
||||||
|
localStorage.setItem(KEY_LAST_CHECKED, String(Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnoozedUntil(): number | null {
|
||||||
|
const stored = localStorage.getItem(KEY_SNOOZED_UNTIL);
|
||||||
|
return stored !== null ? parseInt(stored, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snooze update notifications for the given number of hours. */
|
||||||
|
snoozeFor(hours: number): void {
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY_SNOOZED_UNTIL,
|
||||||
|
String(Date.now() + hours * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSnooze(): void {
|
||||||
|
localStorage.removeItem(KEY_SNOOZED_UNTIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private cleanupEventListeners(): void {
|
||||||
|
this.progressUnlisten?.();
|
||||||
|
this.progressUnlisten = null;
|
||||||
|
this.finishUnlisten?.();
|
||||||
|
this.finishUnlisten = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const desktopUpdateService = new DesktopUpdateService();
|
||||||
@@ -85,7 +85,13 @@
|
|||||||
"update:minor": "node scripts/update-minor.js",
|
"update:minor": "node scripts/update-minor.js",
|
||||||
"update:major": "npx npm-check-updates -u && npm install",
|
"update:major": "npx npm-check-updates -u && npm install",
|
||||||
"update:interactive": "npx npm-check-updates -i",
|
"update:interactive": "npx npm-check-updates -i",
|
||||||
"update:minor-strict": "npx npm-check-updates -u --target minor && npm install"
|
"update:minor-strict": "npx npm-check-updates -u --target minor && npm install",
|
||||||
|
"tauri:setup-dev-update": "bash scripts/dev-update-test/setup-dev-updater.sh",
|
||||||
|
"tauri:build-dev-update": "bash scripts/dev-update-test/build-dev-update.sh",
|
||||||
|
"tauri:serve-dev-update": "bash scripts/dev-update-test/serve-update.sh",
|
||||||
|
"tauri:test-update-e2e": "bash scripts/dev-update-test/test-update-e2e.sh",
|
||||||
|
"tauri:test-update-e2e:install": "bash scripts/dev-update-test/test-update-e2e.sh --install",
|
||||||
|
"tauri:dev-with-update": "cd editor && npx tsx scripts/setup-env.mts --desktop && node scripts/generate-icons.js && npx tauri dev --config src-tauri/tauri.conf.dev-update.json"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Auto-Update Testing
|
||||||
|
|
||||||
|
## One command (automated)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First time only:
|
||||||
|
npm run tauri:setup-dev-update
|
||||||
|
|
||||||
|
# Run tests (builds JRE + JAR + signed bundle, starts server + app, runs checks):
|
||||||
|
npm run tauri:test-update-e2e
|
||||||
|
|
||||||
|
# Full install test (downloads + installs the update):
|
||||||
|
npm run tauri:test-update-e2e:install
|
||||||
|
|
||||||
|
# Skip rebuild if bundle already exists:
|
||||||
|
bash scripts/dev-update-test/test-update-e2e.sh --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 - serve the signed v99.0.0 update:
|
||||||
|
npm run tauri:serve-dev-update
|
||||||
|
|
||||||
|
# Terminal 2 - run the app at v0.0.1:
|
||||||
|
npm run tauri:dev-with-update
|
||||||
|
```
|
||||||
|
|
||||||
|
Go to Settings > General > Software Updates. Click "Check for Updates" then "Install Now".
|
||||||
|
|
||||||
|
## Requires
|
||||||
|
|
||||||
|
- Java 21+ JDK (with `jlink`)
|
||||||
|
- Node.js, Python 3 (with `pip install websockets`)
|
||||||
|
- First-time setup generates signing keys + config override
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds a signed update bundle at version 99.0.0 using the dev key pair.
|
||||||
|
# After this runs, start the server with: npm run tauri:serve-dev-update
|
||||||
|
# The server will automatically serve the real bundle instead of the placeholder.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
KEYS_DIR="$SCRIPT_DIR/.keys"
|
||||||
|
FRONTEND_DIR="$SCRIPT_DIR/../.."
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/.update-dist"
|
||||||
|
|
||||||
|
# ── pre-flight ────────────────────────────────────────────────────────────────
|
||||||
|
if [ ! -f "$KEYS_DIR/dev-update-key" ]; then
|
||||||
|
echo "Error: dev key not found."
|
||||||
|
echo "Run setup first: npm run tauri:setup-dev-update"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PRIVATE_KEY="$(cat "$KEYS_DIR/dev-update-key")"
|
||||||
|
VERSION="99.0.0"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# ── detect platform ───────────────────────────────────────────────────────────
|
||||||
|
OS="$(uname -s)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS" in
|
||||||
|
Linux)
|
||||||
|
BUNDLES="appimage"
|
||||||
|
TAURI_PLATFORM="linux-x86_64"
|
||||||
|
BUNDLE_GLOB="*.AppImage.tar.gz"
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
BUNDLES="app"
|
||||||
|
TAURI_PLATFORM="darwin-$([ "$ARCH" = "arm64" ] && echo aarch64 || echo x86_64)"
|
||||||
|
BUNDLE_GLOB="*.app.tar.gz"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Use build-dev-update.ps1 on Windows."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── build ─────────────────────────────────────────────────────────────────────
|
||||||
|
echo "Building signed update bundle (version $VERSION, platform $TAURI_PLATFORM)..."
|
||||||
|
echo "This takes a few minutes — Rust is compiling."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$FRONTEND_DIR/editor"
|
||||||
|
|
||||||
|
# Prepare desktop env/assets before building
|
||||||
|
npx tsx scripts/setup-env.mts --desktop && node scripts/generate-icons.js
|
||||||
|
# Build the Windows installer provisioner + thumbnail-handler (no-op on macOS/Linux).
|
||||||
|
# The WiX fragment references these binaries, so light.exe fails to bind without them.
|
||||||
|
node scripts/build-provisioner.mjs
|
||||||
|
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY="$PRIVATE_KEY" \
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \
|
||||||
|
npx tauri build \
|
||||||
|
--config "{\"version\":\"$VERSION\"}" \
|
||||||
|
--bundles "$BUNDLES"
|
||||||
|
|
||||||
|
# ── locate outputs ────────────────────────────────────────────────────────────
|
||||||
|
BUNDLE="$(find src-tauri/target -name "$BUNDLE_GLOB" -not -name "*.sig" 2>/dev/null | sort | tail -1)"
|
||||||
|
SIG="$(find src-tauri/target -name "${BUNDLE_GLOB}.sig" 2>/dev/null | sort | tail -1)"
|
||||||
|
|
||||||
|
if [ -z "$BUNDLE" ]; then
|
||||||
|
echo "Error: bundle matching '$BUNDLE_GLOB' not found in src-tauri/target."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$SIG" ]; then
|
||||||
|
echo "Error: .sig file not found alongside bundle."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE_FILENAME="$(basename "$BUNDLE")"
|
||||||
|
SIGNATURE="$(cat "$SIG")"
|
||||||
|
PUB_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
|
||||||
|
# ── assemble serve directory ──────────────────────────────────────────────────
|
||||||
|
cp "$BUNDLE" "$OUTPUT_DIR/$BUNDLE_FILENAME"
|
||||||
|
|
||||||
|
cat > "$OUTPUT_DIR/latest.json" << EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"notes": "Dev test update v$VERSION (local signed build)",
|
||||||
|
"pub_date": "$PUB_DATE",
|
||||||
|
"platforms": {
|
||||||
|
"$TAURI_PLATFORM": {
|
||||||
|
"signature": "$SIGNATURE",
|
||||||
|
"url": "http://localhost:8090/$BUNDLE_FILENAME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── done ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "Build complete."
|
||||||
|
echo " bundle : $OUTPUT_DIR/$BUNDLE_FILENAME"
|
||||||
|
echo " manifest: $OUTPUT_DIR/latest.json"
|
||||||
|
echo ""
|
||||||
|
echo "Start the server: npm run tauri:serve-dev-update"
|
||||||
|
echo "Run the app: npm run tauri:dev-with-update"
|
||||||
|
echo ""
|
||||||
|
echo "The running app (v0.0.1) will see v$VERSION as an available update."
|
||||||
|
echo "Clicking 'Install' will download and verify the real signed bundle."
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Starts a local HTTP server on port 8090 serving an update manifest.
|
||||||
|
#
|
||||||
|
# If a real signed build exists in .update-dist/ (produced by build-dev-update.sh),
|
||||||
|
# it is served automatically — enabling the full download + install flow.
|
||||||
|
# Otherwise a placeholder manifest is served, which is enough to test the UI only.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT=8090
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DIST_DIR="$SCRIPT_DIR/.update-dist"
|
||||||
|
|
||||||
|
if [ -f "$DIST_DIR/latest.json" ]; then
|
||||||
|
# ── real signed build ─────────────────────────────────────────────────────
|
||||||
|
SERVE_DIR="$DIST_DIR"
|
||||||
|
VERSION="$(python3 -c "import json; print(json.load(open('$DIST_DIR/latest.json'))['version'])" 2>/dev/null || grep -o '"version":"[^"]*"' "$DIST_DIR/latest.json" | head -1 | cut -d'"' -f4)"
|
||||||
|
echo "Serving real signed build from .update-dist/"
|
||||||
|
echo " version : $VERSION"
|
||||||
|
echo " manifest: http://localhost:$PORT/latest.json"
|
||||||
|
echo " install : full download + verify flow will work"
|
||||||
|
else
|
||||||
|
# ── placeholder (UI testing only) ─────────────────────────────────────────
|
||||||
|
SERVE_DIR="$(mktemp -d)"
|
||||||
|
VERSION="99.0.0"
|
||||||
|
|
||||||
|
OS="$(uname -s)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$OS" in
|
||||||
|
Linux) TAURI_PLATFORM="linux-x86_64" ;;
|
||||||
|
Darwin)
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) TAURI_PLATFORM="darwin-aarch64" ;;
|
||||||
|
*) TAURI_PLATFORM="darwin-x86_64" ;;
|
||||||
|
esac ;;
|
||||||
|
*) TAURI_PLATFORM="windows-x86_64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PUB_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
|
||||||
|
cat > "$SERVE_DIR/latest.json" << EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"notes": "Dev test update v$VERSION — placeholder (UI testing only)",
|
||||||
|
"pub_date": "$PUB_DATE",
|
||||||
|
"platforms": {
|
||||||
|
"$TAURI_PLATFORM": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHBsYWNlaG9sZGVyIHNpZyBmb3IgZGV2IHRlc3Rpbmcgb25seQ==",
|
||||||
|
"url": "http://localhost:$PORT/dummy-update-$VERSION.tar.gz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "No real build found in .update-dist/ — serving placeholder manifest."
|
||||||
|
echo " version : $VERSION (placeholder)"
|
||||||
|
echo " manifest: http://localhost:$PORT/latest.json"
|
||||||
|
echo " install : will fail signature verification (expected for UI testing)"
|
||||||
|
echo ""
|
||||||
|
echo " To test the full install flow, run first:"
|
||||||
|
echo " npm run tauri:build-dev-update"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$SERVE_DIR" && python3 -m http.server "$PORT"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sets up a dev/local Tauri signing key pair and config override for update testing.
|
||||||
|
# Run once before using tauri:dev-with-update.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
KEYS_DIR="$SCRIPT_DIR/.keys"
|
||||||
|
FRONTEND_DIR="$SCRIPT_DIR/../.."
|
||||||
|
TAURI_CONF_OVERRIDE="$FRONTEND_DIR/editor/src-tauri/tauri.conf.dev-update.json"
|
||||||
|
|
||||||
|
mkdir -p "$KEYS_DIR"
|
||||||
|
|
||||||
|
echo "Generating Tauri Ed25519 signing key pair for dev update testing..."
|
||||||
|
|
||||||
|
# Generate key pair — private key goes to .keys/dev-update-key, public key to .keys/dev-update-key.pub
|
||||||
|
(cd "$FRONTEND_DIR/editor" && npx tauri signer generate -w "$KEYS_DIR/dev-update-key" --ci -p "")
|
||||||
|
|
||||||
|
PUBKEY=$(cat "$KEYS_DIR/dev-update-key.pub")
|
||||||
|
echo ""
|
||||||
|
echo "Public key: $PUBKEY"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Write the override config — sets version to 0.0.1 so any "available" version looks newer,
|
||||||
|
# and points the updater endpoint to the local dev server on port 8090.
|
||||||
|
cat > "$TAURI_CONF_OVERRIDE" << EOF
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "$PUBKEY",
|
||||||
|
"endpoints": [
|
||||||
|
"http://localhost:8090/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Created: editor/src-tauri/tauri.conf.dev-update.json"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. In a separate terminal: npm run tauri:serve-dev-update"
|
||||||
|
echo " 2. Then run: npm run tauri:dev-with-update"
|
||||||
|
echo ""
|
||||||
|
echo "The private key is stored at:"
|
||||||
|
echo " $KEYS_DIR/dev-update-key"
|
||||||
|
echo "Keep it secret — it is gitignored."
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end auto-update test for macOS/Linux.
|
||||||
|
#
|
||||||
|
# Builds JRE + JAR + signed update bundle, starts server + app,
|
||||||
|
# runs CDP tests against the WebView, then cleans up.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/dev-update-test/test-update-e2e.sh # check-only
|
||||||
|
# bash scripts/dev-update-test/test-update-e2e.sh --install # full install
|
||||||
|
# bash scripts/dev-update-test/test-update-e2e.sh --skip-build # reuse existing MSI
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Java 21+ JDK (with jlink)
|
||||||
|
# - Node.js + npm
|
||||||
|
# - Python 3 (for HTTP server + CDP tests)
|
||||||
|
# - First-time: bash scripts/dev-update-test/setup-dev-updater.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL=false
|
||||||
|
SKIP_BUILD=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--install) INSTALL=true ;;
|
||||||
|
--skip-build) SKIP_BUILD=true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
FRONTEND_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$FRONTEND_DIR/.." && pwd)"
|
||||||
|
TAURI_DIR="$FRONTEND_DIR/editor/src-tauri"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/.update-dist"
|
||||||
|
KEYS_DIR="$SCRIPT_DIR/.keys"
|
||||||
|
PORT=8090
|
||||||
|
DEBUG_PORT=9222
|
||||||
|
|
||||||
|
# PIDs to clean up
|
||||||
|
SERVER_PID=""
|
||||||
|
APP_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "=== Cleanup ==="
|
||||||
|
[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true
|
||||||
|
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
pkill -f "stirling-pdf" 2>/dev/null || true
|
||||||
|
echo " Done"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ── pre-flight ────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Pre-flight checks ==="
|
||||||
|
|
||||||
|
if [ ! -f "$KEYS_DIR/dev-update-key" ]; then
|
||||||
|
echo " Running first-time setup..."
|
||||||
|
bash "$SCRIPT_DIR/setup-dev-updater.sh"
|
||||||
|
fi
|
||||||
|
echo " Signing keys: OK"
|
||||||
|
|
||||||
|
JAVA_VER=$(java -version 2>&1 | head -1 | tr -d '\r"' | awk '{print $3}' | cut -d. -f1)
|
||||||
|
if [ -z "$JAVA_VER" ] || [ "$JAVA_VER" -lt 21 ]; then
|
||||||
|
echo " Error: Java 21+ required, found Java $JAVA_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Java $JAVA_VER: OK"
|
||||||
|
|
||||||
|
# Detect python command (python3 on macOS/Linux, python on Windows Git Bash)
|
||||||
|
PYTHON="python3"
|
||||||
|
$PYTHON --version >/dev/null 2>&1 || PYTHON="python"
|
||||||
|
$PYTHON --version >/dev/null 2>&1 || { echo " Error: Python 3 required"; exit 1; }
|
||||||
|
$PYTHON -c "import websockets" 2>/dev/null || {
|
||||||
|
echo " Installing Python websockets..."
|
||||||
|
$PYTHON -m pip install websockets --quiet
|
||||||
|
}
|
||||||
|
echo " Python: OK"
|
||||||
|
|
||||||
|
# ── detect platform ───────────────────────────────────────────────────────────
|
||||||
|
OS="$(uname -s)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$OS" in
|
||||||
|
Darwin)
|
||||||
|
BUNDLES="app"
|
||||||
|
TAURI_PLATFORM="darwin-$([ "$ARCH" = "arm64" ] && echo aarch64 || echo x86_64)"
|
||||||
|
BUNDLE_GLOB="*.app.tar.gz"
|
||||||
|
WEBVIEW_DEBUG_ENV=""
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
BUNDLES="appimage"
|
||||||
|
TAURI_PLATFORM="linux-x86_64"
|
||||||
|
BUNDLE_GLOB="*.AppImage.tar.gz"
|
||||||
|
WEBVIEW_DEBUG_ENV=""
|
||||||
|
;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
BUNDLES="msi"
|
||||||
|
TAURI_PLATFORM="windows-x86_64"
|
||||||
|
BUNDLE_GLOB="*.msi"
|
||||||
|
WEBVIEW_DEBUG_ENV="WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=$DEBUG_PORT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported OS: $OS"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Step 1: Build JRE + JAR ──────────────────────────────────────────────────
|
||||||
|
JRE_JAVA="$TAURI_DIR/runtime/jre/bin/java"
|
||||||
|
JAR_GLOB="$TAURI_DIR/libs/stirling-pdf-*.jar"
|
||||||
|
|
||||||
|
if [ "$SKIP_BUILD" = false ] || ! ls $JAR_GLOB >/dev/null 2>&1 || [ ! -f "$JRE_JAVA" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Building backend JAR ==="
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
DISABLE_ADDITIONAL_FEATURES=true ./gradlew bootJar -x test --no-daemon 2>&1 | tail -3
|
||||||
|
|
||||||
|
BUILT_JAR=$(ls -t app/core/build/libs/stirling-pdf-*.jar 2>/dev/null | head -1 || true)
|
||||||
|
if [ -z "$BUILT_JAR" ]; then echo "Error: JAR not found"; exit 1; fi
|
||||||
|
|
||||||
|
mkdir -p "$TAURI_DIR/libs"
|
||||||
|
# Remove any dummy jars
|
||||||
|
find "$TAURI_DIR/libs" -name "*.jar" -size -1k -delete 2>/dev/null || true
|
||||||
|
cp "$BUILT_JAR" "$TAURI_DIR/libs/"
|
||||||
|
echo " JAR: $(basename "$BUILT_JAR") ($(du -h "$BUILT_JAR" | cut -f1))"
|
||||||
|
|
||||||
|
if [ ! -f "$JRE_JAVA" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Building JRE with jlink ==="
|
||||||
|
rm -rf "$TAURI_DIR/runtime/jre"
|
||||||
|
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||||
|
jlink \
|
||||||
|
--add-modules "$MODULES" \
|
||||||
|
--strip-debug \
|
||||||
|
--no-header-files \
|
||||||
|
--no-man-pages \
|
||||||
|
--output "$TAURI_DIR/runtime/jre"
|
||||||
|
echo " JRE: OK"
|
||||||
|
else
|
||||||
|
echo " JRE: already exists"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 2: Build signed update bundle ────────────────────────────────────────
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
BUNDLE_FILE=$(ls "$OUTPUT_DIR"/$BUNDLE_GLOB 2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
if [ "$SKIP_BUILD" = false ] || [ -z "$BUNDLE_FILE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Building signed v99.0.0 update bundle ==="
|
||||||
|
echo " This takes a few minutes (Rust release build)..."
|
||||||
|
|
||||||
|
PRIVATE_KEY="$(cat "$KEYS_DIR/dev-update-key")"
|
||||||
|
|
||||||
|
cd "$FRONTEND_DIR/editor"
|
||||||
|
# Build the Windows installer provisioner + thumbnail-handler (no-op on macOS/Linux).
|
||||||
|
# The WiX fragment references these binaries, so light.exe fails to bind without them.
|
||||||
|
node scripts/build-provisioner.mjs
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY="$PRIVATE_KEY" \
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \
|
||||||
|
npx tauri build \
|
||||||
|
--config '{"version":"99.0.0"}' \
|
||||||
|
--bundles "$BUNDLES" 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Find and copy bundle (search release/bundle first, then broader)
|
||||||
|
BUILT_BUNDLE=$(find "$TAURI_DIR/target/release/bundle" -name "$BUNDLE_GLOB" -not -name "*.sig" 2>/dev/null | sort | tail -1)
|
||||||
|
[ -z "$BUILT_BUNDLE" ] && BUILT_BUNDLE=$(find "$TAURI_DIR/target" -maxdepth 5 -name "$BUNDLE_GLOB" -not -name "*.sig" 2>/dev/null | sort | tail -1)
|
||||||
|
BUILT_SIG="${BUILT_BUNDLE}.sig"
|
||||||
|
|
||||||
|
if [ -z "$BUILT_BUNDLE" ] || [ ! -f "$BUILT_SIG" ]; then
|
||||||
|
# Sign manually if .sig missing
|
||||||
|
if [ -n "$BUILT_BUNDLE" ]; then
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY="$PRIVATE_KEY" \
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \
|
||||||
|
npx tauri signer sign "$BUILT_BUNDLE"
|
||||||
|
BUILT_SIG="${BUILT_BUNDLE}.sig"
|
||||||
|
else
|
||||||
|
echo "Error: bundle not found"; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE_FILENAME="$(basename "$BUILT_BUNDLE")"
|
||||||
|
SIGNATURE="$(cat "$BUILT_SIG")"
|
||||||
|
PUB_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
|
||||||
|
cp "$BUILT_BUNDLE" "$OUTPUT_DIR/$BUNDLE_FILENAME"
|
||||||
|
|
||||||
|
cat > "$OUTPUT_DIR/latest.json" << EOF
|
||||||
|
{
|
||||||
|
"version": "99.0.0",
|
||||||
|
"notes": "Test update v99.0.0 - built with real JRE + backend",
|
||||||
|
"pub_date": "$PUB_DATE",
|
||||||
|
"platforms": {
|
||||||
|
"$TAURI_PLATFORM": {
|
||||||
|
"signature": "$SIGNATURE",
|
||||||
|
"url": "http://localhost:$PORT/$BUNDLE_FILENAME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
BUNDLE_SIZE=$(du -h "$OUTPUT_DIR/$BUNDLE_FILENAME" | cut -f1)
|
||||||
|
echo " Bundle: ${BUNDLE_SIZE}, signed and ready"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "=== Skipping build (use without --skip-build to rebuild) ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 3: Start HTTP server ─────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Starting update server on port $PORT ==="
|
||||||
|
cd "$OUTPUT_DIR"
|
||||||
|
$PYTHON -m http.server "$PORT" &>/dev/null &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
curl -sf "http://localhost:$PORT/latest.json" >/dev/null || { echo " Error: server not responding"; exit 1; }
|
||||||
|
echo " Server: OK (PID $SERVER_PID)"
|
||||||
|
|
||||||
|
# ── Step 4: Start Tauri app ──────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Starting Tauri app (v0.0.1) ==="
|
||||||
|
cd "$FRONTEND_DIR/editor"
|
||||||
|
|
||||||
|
# Enable WebView remote debugging (platform-specific)
|
||||||
|
if [ -n "$WEBVIEW_DEBUG_ENV" ]; then
|
||||||
|
export ${WEBVIEW_DEBUG_ENV}
|
||||||
|
else
|
||||||
|
export WEBKIT_INSPECTOR_SERVER="127.0.0.1:$DEBUG_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
npx tauri dev --config src-tauri/tauri.conf.dev-update.json &>"$OUTPUT_DIR/tauri-dev.log" &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
# ── Step 5: CDP tests ────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Running CDP tests ==="
|
||||||
|
|
||||||
|
INSTALL_FLAG=""
|
||||||
|
if [ "$INSTALL" = true ]; then INSTALL_FLAG="--install"; fi
|
||||||
|
|
||||||
|
$PYTHON - $INSTALL_FLAG << 'PYEOF'
|
||||||
|
import json, asyncio, websockets, base64, sys, urllib.request, time
|
||||||
|
|
||||||
|
INSTALL = '--install' in sys.argv
|
||||||
|
PORT = 9222
|
||||||
|
|
||||||
|
async def wait_for_app(timeout=180):
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
try:
|
||||||
|
data = urllib.request.urlopen(f'http://localhost:{PORT}/json', timeout=2).read()
|
||||||
|
targets = json.loads(data)
|
||||||
|
page = next((t for t in targets if t['type'] == 'page'), None)
|
||||||
|
if page: return page
|
||||||
|
except: pass
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
raise TimeoutError('App did not start')
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
print(' Waiting for app to start...')
|
||||||
|
page = await wait_for_app()
|
||||||
|
print(f' Connected: {page["url"]}')
|
||||||
|
await asyncio.sleep(8)
|
||||||
|
|
||||||
|
uri = page['webSocketDebuggerUrl']
|
||||||
|
async with websockets.connect(uri) as ws:
|
||||||
|
await ws.send(json.dumps({'id': 0, 'method': 'Runtime.enable'}))
|
||||||
|
for _ in range(30):
|
||||||
|
try: await asyncio.wait_for(ws.recv(), timeout=0.3)
|
||||||
|
except: break
|
||||||
|
|
||||||
|
async def ev(expr, mid, t=30):
|
||||||
|
await ws.send(json.dumps({'id': mid, 'method': 'Runtime.evaluate', 'params': {'expression': expr, 'awaitPromise': True}}))
|
||||||
|
while True:
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=t))
|
||||||
|
if msg.get('id') == mid:
|
||||||
|
err = msg.get('result', {}).get('exceptionDetails')
|
||||||
|
if err: return 'EX: ' + str(err.get('exception', {}).get('description', ''))[:200]
|
||||||
|
r = msg.get('result', {}).get('result', {})
|
||||||
|
return r.get('value', r.get('description', str(r)))
|
||||||
|
|
||||||
|
# Test 1: Rust updater check
|
||||||
|
print('\n Test 1: check_for_update via Tauri IPC')
|
||||||
|
r = await ev('(async()=>{const r=await window.__TAURI_INTERNALS__.invoke("check_for_update");return JSON.stringify(r)})()', 10)
|
||||||
|
if r.startswith('EX:'): print(f' FAIL: {r}'); sys.exit(1)
|
||||||
|
data = json.loads(r)
|
||||||
|
if not data: print(' FAIL: no update found'); sys.exit(1)
|
||||||
|
print(f' PASS: {data["currentVersion"]} -> {data["version"]}')
|
||||||
|
|
||||||
|
# Test 2: get_app_version
|
||||||
|
r = await ev('window.__TAURI_INTERNALS__.invoke("get_app_version")', 11)
|
||||||
|
print(f'\n Test 2: App version = {r}')
|
||||||
|
assert r == '0.0.1', f'Expected 0.0.1, got {r}'
|
||||||
|
print(' PASS')
|
||||||
|
|
||||||
|
# Test 3: Download + install
|
||||||
|
if INSTALL:
|
||||||
|
print('\n Test 3: download_and_install_update')
|
||||||
|
print(' Downloading update (this may take a minute)...')
|
||||||
|
try:
|
||||||
|
r = await ev('(async()=>{await window.__TAURI_INTERNALS__.invoke("download_and_install_update");return"OK"})()', 20, t=300)
|
||||||
|
print(f' Result: {r}')
|
||||||
|
except Exception:
|
||||||
|
print(f' App exited (installer took over) - SUCCESS')
|
||||||
|
else:
|
||||||
|
print('\n Test 3: Skipped (use --install for full install)')
|
||||||
|
|
||||||
|
print('\n ALL TESTS PASSED\n')
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
PYEOF
|
||||||
Reference in New Issue
Block a user