mirror of
https://github.com/armbian/imager.git
synced 2026-01-06 12:31:28 -08:00
Add Tauri auto-update functionality with in-app download
- Add tauri-plugin-updater and tauri-plugin-process dependencies - Configure updater endpoint for GitHub Releases - Rewrite UpdateModal with download progress and changelog display - Add update-related i18n translations (15 languages) - Configure createUpdaterArtifacts for .sig file generation - Update workflow to use correct bundle types for each platform
This commit is contained in:
154
.github/workflows/build.yml
vendored
154
.github/workflows/build.yml
vendored
@@ -35,6 +35,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
NODE_VERSION: '20'
|
||||
TAURI_CLI_VERSION: '^2'
|
||||
# Tauri updater signing key (set in GitHub Secrets)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
@@ -166,9 +169,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
run: cargo tauri build --bundles deb
|
||||
run: cargo tauri build --bundles deb,appimage
|
||||
|
||||
- name: Upload .deb to GitHub Release
|
||||
- name: Upload artifacts to GitHub Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.create-release.outputs.release_tag }}
|
||||
@@ -180,6 +183,8 @@ jobs:
|
||||
replacesArtifacts: false
|
||||
artifacts: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
|
||||
build-linux-arm64:
|
||||
name: build-linux (aarch64-unknown-linux-gnu)
|
||||
@@ -259,9 +264,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build Tauri app
|
||||
run: cargo tauri build --bundles deb
|
||||
run: cargo tauri build --bundles deb,appimage
|
||||
|
||||
- name: Upload .deb to GitHub Release
|
||||
- name: Upload artifacts to GitHub Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.create-release.outputs.release_tag }}
|
||||
@@ -273,6 +278,8 @@ jobs:
|
||||
replacesArtifacts: false
|
||||
artifacts: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.sig
|
||||
|
||||
build-macos:
|
||||
name: build-macos (${{ matrix.target }})
|
||||
@@ -363,7 +370,7 @@ jobs:
|
||||
run: |
|
||||
cargo tauri build --target "${{ matrix.target }}" --bundles dmg,app
|
||||
|
||||
- name: Zip .app bundle(s)
|
||||
- name: Zip .app bundle(s) and rename updater artifacts
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_TARGET_DIR: src-tauri/target/${{ matrix.target }}
|
||||
@@ -374,6 +381,15 @@ jobs:
|
||||
base="$(basename "$app" .app)"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$CARGO_TARGET_DIR/release/bundle/macos/${base}-${{ matrix.arch }}.app.zip"
|
||||
done
|
||||
# Rename updater tar.gz to include arch
|
||||
for tarball in "$CARGO_TARGET_DIR/release/bundle/macos/"*.tar.gz; do
|
||||
base="$(basename "$tarball" .tar.gz)"
|
||||
mv "$tarball" "$CARGO_TARGET_DIR/release/bundle/macos/${base}-${{ matrix.arch }}.tar.gz"
|
||||
done
|
||||
for sig in "$CARGO_TARGET_DIR/release/bundle/macos/"*.tar.gz.sig; do
|
||||
base="$(basename "$sig" .tar.gz.sig)"
|
||||
mv "$sig" "$CARGO_TARGET_DIR/release/bundle/macos/${base}-${{ matrix.arch }}.tar.gz.sig"
|
||||
done
|
||||
|
||||
- name: Upload macOS artifacts to GitHub Release
|
||||
uses: ncipollo/release-action@v1
|
||||
@@ -387,6 +403,8 @@ jobs:
|
||||
replacesArtifacts: false
|
||||
artifacts: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*-${{ matrix.arch }}.app.zip
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*-${{ matrix.arch }}.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*-${{ matrix.arch }}.tar.gz.sig
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
|
||||
build-windows-x64:
|
||||
@@ -473,6 +491,8 @@ jobs:
|
||||
artifacts: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
|
||||
build-windows-arm64:
|
||||
name: build-windows (aarch64-pc-windows-msvc)
|
||||
@@ -559,6 +579,129 @@ jobs:
|
||||
artifacts: |
|
||||
src-tauri/target/aarch64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip
|
||||
src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
|
||||
generate-update-manifest:
|
||||
name: Generate latest.json for updater
|
||||
needs:
|
||||
- create-release
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos
|
||||
- build-windows-x64
|
||||
- build-windows-arm64
|
||||
if: |
|
||||
always() &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download release assets and generate latest.json
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.create-release.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
REPO="${{ github.repository }}"
|
||||
BASE_URL="https://github.com/${REPO}/releases/download/${TAG}"
|
||||
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Function to get signature content from release
|
||||
get_sig() {
|
||||
local asset_name="$1"
|
||||
gh release download "$TAG" --pattern "${asset_name}.sig" --output - 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# Wait for assets to be available
|
||||
sleep 10
|
||||
|
||||
# Get list of assets
|
||||
ASSETS=$(gh release view "$TAG" --json assets --jq '.assets[].name')
|
||||
|
||||
# Initialize platforms object
|
||||
declare -A PLATFORMS
|
||||
|
||||
# macOS x64
|
||||
MAC_X64_SIG=$(get_sig "Armbian Imager-x64.tar.gz" || echo "")
|
||||
if [[ -n "$MAC_X64_SIG" ]]; then
|
||||
PLATFORMS["darwin-x86_64"]="{\"signature\": \"${MAC_X64_SIG}\", \"url\": \"${BASE_URL}/Armbian%20Imager-x64.tar.gz\"}"
|
||||
fi
|
||||
|
||||
# macOS ARM64
|
||||
MAC_ARM_SIG=$(get_sig "Armbian Imager-arm64.tar.gz" || echo "")
|
||||
if [[ -n "$MAC_ARM_SIG" ]]; then
|
||||
PLATFORMS["darwin-aarch64"]="{\"signature\": \"${MAC_ARM_SIG}\", \"url\": \"${BASE_URL}/Armbian%20Imager-arm64.tar.gz\"}"
|
||||
fi
|
||||
|
||||
# Linux x64
|
||||
LINUX_X64_SIG=$(get_sig "armbian-imager_${VERSION}_amd64.AppImage" || echo "")
|
||||
if [[ -n "$LINUX_X64_SIG" ]]; then
|
||||
PLATFORMS["linux-x86_64"]="{\"signature\": \"${LINUX_X64_SIG}\", \"url\": \"${BASE_URL}/armbian-imager_${VERSION}_amd64.AppImage\"}"
|
||||
fi
|
||||
|
||||
# Linux ARM64
|
||||
LINUX_ARM_SIG=$(get_sig "armbian-imager_${VERSION}_arm64.AppImage" || echo "")
|
||||
if [[ -n "$LINUX_ARM_SIG" ]]; then
|
||||
PLATFORMS["linux-aarch64"]="{\"signature\": \"${LINUX_ARM_SIG}\", \"url\": \"${BASE_URL}/armbian-imager_${VERSION}_arm64.AppImage\"}"
|
||||
fi
|
||||
|
||||
# Windows x64
|
||||
WIN_X64_SIG=$(get_sig "Armbian Imager_${VERSION}_x64-setup.nsis.zip" || echo "")
|
||||
if [[ -n "$WIN_X64_SIG" ]]; then
|
||||
PLATFORMS["windows-x86_64"]="{\"signature\": \"${WIN_X64_SIG}\", \"url\": \"${BASE_URL}/Armbian%20Imager_${VERSION}_x64-setup.nsis.zip\"}"
|
||||
fi
|
||||
|
||||
# Windows ARM64
|
||||
WIN_ARM_SIG=$(get_sig "Armbian Imager_${VERSION}_arm64-setup.nsis.zip" || echo "")
|
||||
if [[ -n "$WIN_ARM_SIG" ]]; then
|
||||
PLATFORMS["windows-aarch64"]="{\"signature\": \"${WIN_ARM_SIG}\", \"url\": \"${BASE_URL}/Armbian%20Imager_${VERSION}_arm64-setup.nsis.zip\"}"
|
||||
fi
|
||||
|
||||
# Build platforms JSON
|
||||
PLATFORMS_JSON="{"
|
||||
first=true
|
||||
for key in "${!PLATFORMS[@]}"; do
|
||||
if [[ "$first" == "true" ]]; then
|
||||
first=false
|
||||
else
|
||||
PLATFORMS_JSON+=","
|
||||
fi
|
||||
PLATFORMS_JSON+="\"${key}\": ${PLATFORMS[$key]}"
|
||||
done
|
||||
PLATFORMS_JSON+="}"
|
||||
|
||||
# Get release notes
|
||||
NOTES=$(gh release view "$TAG" --json body --jq '.body' | jq -Rs .)
|
||||
|
||||
# Generate latest.json
|
||||
cat > latest.json <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": ${NOTES},
|
||||
"pub_date": "${PUB_DATE}",
|
||||
"platforms": ${PLATFORMS_JSON}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Pretty print for debugging
|
||||
cat latest.json | jq .
|
||||
|
||||
- name: Upload latest.json to release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ needs.create-release.outputs.release_tag }}
|
||||
allowUpdates: true
|
||||
omitBodyDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: latest.json
|
||||
|
||||
finalize-release:
|
||||
name: Publish release (draft -> false) + cleanup
|
||||
@@ -569,6 +712,7 @@ jobs:
|
||||
- build-macos
|
||||
- build-windows-x64
|
||||
- build-windows-arm64
|
||||
- generate-update-manifest
|
||||
if: |
|
||||
always() &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') &&
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -10,7 +10,9 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.3",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"i18next": "^25.7.2",
|
||||
"lucide-react": "^0.560.0",
|
||||
@@ -1582,6 +1584,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz",
|
||||
@@ -1591,6 +1602,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.9.0.tgz",
|
||||
"integrity": "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-shell": "^2.3.3",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"i18next": "^25.7.2",
|
||||
"lucide-react": "^0.560.0",
|
||||
|
||||
@@ -13,6 +13,8 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::board_queries::get_boards,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -81,6 +82,15 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYxQzgxMEQ3QUI1RjRDQ0UKUldUT1RGK3IxeERJOGJnSzNrVG1EZ2JVcVRGYllCdFBQNDFVa0l0WjF5NFNndk9YVzhlMDIrS04K",
|
||||
"endpoints": [
|
||||
"https://github.com/armbian/imager/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,276 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Download, RefreshCw, CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '../../hooks/useTauri';
|
||||
import { check, Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
|
||||
const GITHUB_API_URL = 'https://api.github.com/repos/armbian/imager/releases/latest';
|
||||
const RELEASES_URL = 'https://github.com/armbian/imager/releases/latest';
|
||||
type UpdateState = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): number {
|
||||
const c = current.replace(/^v/, '').split('.').map(Number);
|
||||
const l = latest.replace(/^v/, '').split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(c.length, l.length); i++) {
|
||||
const cv = c[i] || 0;
|
||||
const lv = l[i] || 0;
|
||||
if (cv < lv) return -1;
|
||||
if (cv > lv) return 1;
|
||||
}
|
||||
return 0;
|
||||
interface DownloadProgress {
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export function UpdateModal() {
|
||||
const { t } = useTranslation();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
const [state, setState] = useState<UpdateState>('idle');
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
const [progress, setProgress] = useState<DownloadProgress>({ downloaded: 0, total: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdate = async () => {
|
||||
try {
|
||||
const currentVersion = await getVersion();
|
||||
const response = await fetch(GITHUB_API_URL);
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
setState('checking');
|
||||
setError(null);
|
||||
|
||||
if (!response.ok) return;
|
||||
try {
|
||||
const updateResult = await check();
|
||||
|
||||
const release: GitHubRelease = await response.json();
|
||||
const latest = release.tag_name;
|
||||
|
||||
if (compareVersions(currentVersion, latest) < 0) {
|
||||
setLatestVersion(latest);
|
||||
setUpdateAvailable(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for updates:', err);
|
||||
if (updateResult) {
|
||||
setUpdate(updateResult);
|
||||
setState('available');
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
checkForUpdate();
|
||||
} catch (err) {
|
||||
console.error('Failed to check for updates:', err);
|
||||
// Silently fail - don't show error modal for update check failures
|
||||
setState('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownload = () => {
|
||||
openUrl(RELEASES_URL).catch(console.error);
|
||||
setDismissed(true);
|
||||
useEffect(() => {
|
||||
checkForUpdate();
|
||||
}, [checkForUpdate]);
|
||||
|
||||
const handleDownloadAndInstall = async () => {
|
||||
if (!update) return;
|
||||
|
||||
setState('downloading');
|
||||
setProgress({ downloaded: 0, total: null });
|
||||
|
||||
try {
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
setProgress({ downloaded: 0, total: event.data.contentLength ?? null });
|
||||
break;
|
||||
case 'Progress':
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
downloaded: prev.downloaded + event.data.chunkLength,
|
||||
}));
|
||||
break;
|
||||
case 'Finished':
|
||||
setState('ready');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setState('ready');
|
||||
} catch (err) {
|
||||
console.error('Failed to download update:', err);
|
||||
setError(err instanceof Error ? err.message : 'Download failed');
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelaunch = async () => {
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
console.error('Failed to relaunch:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to restart');
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (!updateAvailable || dismissed) return null;
|
||||
const handleRetry = () => {
|
||||
if (state === 'error' && update) {
|
||||
handleDownloadAndInstall();
|
||||
} else {
|
||||
checkForUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getProgressPercentage = (): number => {
|
||||
if (!progress.total) return 0;
|
||||
return Math.round((progress.downloaded / progress.total) * 100);
|
||||
};
|
||||
|
||||
// Simple markdown renderer for changelog
|
||||
const renderChangelog = (body: string | undefined): React.ReactElement | null => {
|
||||
if (!body) return null;
|
||||
|
||||
const lines = body.split('\n');
|
||||
const elements: React.ReactElement[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (trimmed.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h4 key={index} className="update-changelog-heading">
|
||||
{trimmed.slice(3)}
|
||||
</h4>
|
||||
);
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h5 key={index} className="update-changelog-subheading">
|
||||
{trimmed.slice(4)}
|
||||
</h5>
|
||||
);
|
||||
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
elements.push(
|
||||
<li key={index} className="update-changelog-item">
|
||||
{trimmed.slice(2)}
|
||||
</li>
|
||||
);
|
||||
} else if (trimmed) {
|
||||
elements.push(
|
||||
<p key={index} className="update-changelog-text">
|
||||
{trimmed}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <>{elements}</>;
|
||||
};
|
||||
|
||||
// Don't show anything if no update or dismissed
|
||||
if (state === 'idle' || state === 'checking' || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="update-modal-overlay">
|
||||
<div className="update-modal">
|
||||
<div className="update-modal-icon">
|
||||
<RefreshCw size={32} />
|
||||
<div className={`update-modal ${state === 'available' ? 'update-modal-large' : ''}`}>
|
||||
{/* Close button for available state */}
|
||||
{state === 'available' && (
|
||||
<button className="update-modal-close" onClick={handleLater} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`update-modal-icon ${state === 'ready' ? 'success' : ''} ${state === 'error' ? 'error' : ''}`}>
|
||||
{state === 'ready' ? (
|
||||
<CheckCircle size={32} />
|
||||
) : state === 'error' ? (
|
||||
<AlertCircle size={32} />
|
||||
) : (
|
||||
<RefreshCw size={32} className={state === 'downloading' ? 'spinning' : ''} />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="update-modal-title">{t('update.title')}</h2>
|
||||
<p className="update-modal-message">
|
||||
{t('update.newVersionAvailable', { version: latestVersion })}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="update-modal-title">
|
||||
{state === 'available' && t('update.title')}
|
||||
{state === 'downloading' && t('update.downloading')}
|
||||
{state === 'ready' && t('update.ready')}
|
||||
{state === 'error' && t('update.error')}
|
||||
</h2>
|
||||
|
||||
{/* Message / Content */}
|
||||
{state === 'available' && update && (
|
||||
<>
|
||||
<div className="update-version-info">
|
||||
<span className="update-version-current">{update.currentVersion}</span>
|
||||
<span className="update-version-arrow">→</span>
|
||||
<span className="update-version-new">{update.version}</span>
|
||||
</div>
|
||||
|
||||
{update.body && (
|
||||
<div className="update-changelog">
|
||||
<h3 className="update-changelog-title">{t('update.changelog')}</h3>
|
||||
<div className="update-changelog-content">
|
||||
{renderChangelog(update.body)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'downloading' && (
|
||||
<div className="update-progress-container">
|
||||
<div className="update-progress-bar">
|
||||
<div
|
||||
className="update-progress-fill"
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="update-progress-text">
|
||||
{progress.total ? (
|
||||
<>
|
||||
{formatBytes(progress.downloaded)} / {formatBytes(progress.total)}
|
||||
<span className="update-progress-percent">{getProgressPercentage()}%</span>
|
||||
</>
|
||||
) : (
|
||||
formatBytes(progress.downloaded)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<p className="update-modal-message">
|
||||
{t('update.readyMessage')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<p className="update-modal-message update-error-message">
|
||||
{error || t('update.errorMessage')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="update-modal-buttons">
|
||||
<button className="update-modal-btn secondary" onClick={handleLater}>
|
||||
{t('update.later')}
|
||||
</button>
|
||||
<button className="update-modal-btn primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
{t('update.download')}
|
||||
</button>
|
||||
{state === 'available' && (
|
||||
<>
|
||||
<button className="update-modal-btn secondary" onClick={handleLater}>
|
||||
{t('update.later')}
|
||||
</button>
|
||||
<button className="update-modal-btn primary" onClick={handleDownloadAndInstall}>
|
||||
<Download size={16} />
|
||||
{t('update.installNow')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'downloading' && (
|
||||
<button className="update-modal-btn secondary" onClick={handleLater}>
|
||||
{t('update.cancel')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<button className="update-modal-btn primary" onClick={handleRelaunch}>
|
||||
<RefreshCw size={16} />
|
||||
{t('update.restartNow')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<>
|
||||
<button className="update-modal-btn secondary" onClick={handleLater}>
|
||||
{t('update.later')}
|
||||
</button>
|
||||
<button className="update-modal-btn primary" onClick={handleRetry}>
|
||||
{t('update.retry')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Update verfügbar",
|
||||
"newVersionAvailable": "Neue Version {{version}} verfügbar",
|
||||
"download": "Herunterladen",
|
||||
"later": "Später"
|
||||
"later": "Später",
|
||||
"downloading": "Update wird heruntergeladen...",
|
||||
"ready": "Update bereit",
|
||||
"error": "Update fehlgeschlagen",
|
||||
"changelog": "Neuerungen",
|
||||
"installNow": "Jetzt installieren",
|
||||
"restartNow": "Jetzt neu starten",
|
||||
"cancel": "Abbrechen",
|
||||
"retry": "Erneut versuchen",
|
||||
"readyMessage": "Das Update wurde heruntergeladen. Starten Sie die Anwendung neu, um die Installation abzuschließen.",
|
||||
"errorMessage": "Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es später erneut."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Update Available",
|
||||
"newVersionAvailable": "New version {{version}} available",
|
||||
"download": "Download",
|
||||
"later": "Later"
|
||||
"later": "Later",
|
||||
"downloading": "Downloading Update...",
|
||||
"ready": "Update Ready",
|
||||
"error": "Update Failed",
|
||||
"changelog": "What's New",
|
||||
"installNow": "Install Now",
|
||||
"restartNow": "Restart Now",
|
||||
"cancel": "Cancel",
|
||||
"retry": "Retry",
|
||||
"readyMessage": "The update has been downloaded. Restart the application to complete the installation.",
|
||||
"errorMessage": "Failed to download the update. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Actualización disponible",
|
||||
"newVersionAvailable": "Nueva versión {{version}} disponible",
|
||||
"download": "Descargar",
|
||||
"later": "Más tarde"
|
||||
"later": "Más tarde",
|
||||
"downloading": "Descargando actualización...",
|
||||
"ready": "Actualización lista",
|
||||
"error": "Error de actualización",
|
||||
"changelog": "Novedades",
|
||||
"installNow": "Instalar ahora",
|
||||
"restartNow": "Reiniciar ahora",
|
||||
"cancel": "Cancelar",
|
||||
"retry": "Reintentar",
|
||||
"readyMessage": "La actualización se ha descargado. Reinicie la aplicación para completar la instalación.",
|
||||
"errorMessage": "No se pudo descargar la actualización. Por favor, inténtelo más tarde."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Mise à jour disponible",
|
||||
"newVersionAvailable": "Nouvelle version {{version}} disponible",
|
||||
"download": "Télécharger",
|
||||
"later": "Plus tard"
|
||||
"later": "Plus tard",
|
||||
"downloading": "Téléchargement de la mise à jour...",
|
||||
"ready": "Mise à jour prête",
|
||||
"error": "Échec de la mise à jour",
|
||||
"changelog": "Nouveautés",
|
||||
"installNow": "Installer maintenant",
|
||||
"restartNow": "Redémarrer maintenant",
|
||||
"cancel": "Annuler",
|
||||
"retry": "Réessayer",
|
||||
"readyMessage": "La mise à jour a été téléchargée. Redémarrez l'application pour terminer l'installation.",
|
||||
"errorMessage": "Impossible de télécharger la mise à jour. Veuillez réessayer plus tard."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Aggiornamento Disponibile",
|
||||
"newVersionAvailable": "Nuova versione {{version}} disponibile",
|
||||
"download": "Scarica",
|
||||
"later": "Più tardi"
|
||||
"later": "Più tardi",
|
||||
"downloading": "Download in corso...",
|
||||
"ready": "Aggiornamento Pronto",
|
||||
"error": "Aggiornamento Fallito",
|
||||
"changelog": "Novità",
|
||||
"installNow": "Installa Ora",
|
||||
"restartNow": "Riavvia Ora",
|
||||
"cancel": "Annulla",
|
||||
"retry": "Riprova",
|
||||
"readyMessage": "L'aggiornamento è stato scaricato. Riavvia l'applicazione per completare l'installazione.",
|
||||
"errorMessage": "Impossibile scaricare l'aggiornamento. Riprova più tardi."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "アップデートが利用可能",
|
||||
"newVersionAvailable": "新しいバージョン {{version}} が利用可能です",
|
||||
"download": "ダウンロード",
|
||||
"later": "後で"
|
||||
"later": "後で",
|
||||
"downloading": "アップデートをダウンロード中...",
|
||||
"ready": "アップデート準備完了",
|
||||
"error": "アップデート失敗",
|
||||
"changelog": "新機能",
|
||||
"installNow": "今すぐインストール",
|
||||
"restartNow": "今すぐ再起動",
|
||||
"cancel": "キャンセル",
|
||||
"retry": "再試行",
|
||||
"readyMessage": "アップデートがダウンロードされました。インストールを完了するにはアプリケーションを再起動してください。",
|
||||
"errorMessage": "アップデートのダウンロードに失敗しました。後でもう一度お試しください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "업데이트 가능",
|
||||
"newVersionAvailable": "새 버전 {{version}} 사용 가능",
|
||||
"download": "다운로드",
|
||||
"later": "나중에"
|
||||
"later": "나중에",
|
||||
"downloading": "업데이트 다운로드 중...",
|
||||
"ready": "업데이트 준비됨",
|
||||
"error": "업데이트 실패",
|
||||
"changelog": "새로운 기능",
|
||||
"installNow": "지금 설치",
|
||||
"restartNow": "지금 재시작",
|
||||
"cancel": "취소",
|
||||
"retry": "다시 시도",
|
||||
"readyMessage": "업데이트가 다운로드되었습니다. 설치를 완료하려면 애플리케이션을 재시작하세요.",
|
||||
"errorMessage": "업데이트를 다운로드하지 못했습니다. 나중에 다시 시도해 주세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Update beschikbaar",
|
||||
"newVersionAvailable": "Nieuwe versie {{version}} beschikbaar",
|
||||
"download": "Downloaden",
|
||||
"later": "Later"
|
||||
"later": "Later",
|
||||
"downloading": "Update downloaden...",
|
||||
"ready": "Update gereed",
|
||||
"error": "Update mislukt",
|
||||
"changelog": "Nieuw",
|
||||
"installNow": "Nu installeren",
|
||||
"restartNow": "Nu herstarten",
|
||||
"cancel": "Annuleren",
|
||||
"retry": "Opnieuw proberen",
|
||||
"readyMessage": "De update is gedownload. Start de applicatie opnieuw om de installatie te voltooien.",
|
||||
"errorMessage": "Kan de update niet downloaden. Probeer het later opnieuw."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Dostępna aktualizacja",
|
||||
"newVersionAvailable": "Dostępna nowa wersja {{version}}",
|
||||
"download": "Pobierz",
|
||||
"later": "Później"
|
||||
"later": "Później",
|
||||
"downloading": "Pobieranie aktualizacji...",
|
||||
"ready": "Aktualizacja gotowa",
|
||||
"error": "Błąd aktualizacji",
|
||||
"changelog": "Co nowego",
|
||||
"installNow": "Zainstaluj teraz",
|
||||
"restartNow": "Uruchom ponownie",
|
||||
"cancel": "Anuluj",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"readyMessage": "Aktualizacja została pobrana. Uruchom ponownie aplikację, aby zakończyć instalację.",
|
||||
"errorMessage": "Nie udało się pobrać aktualizacji. Spróbuj ponownie później."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Atualização disponível",
|
||||
"newVersionAvailable": "Nova versão {{version}} disponível",
|
||||
"download": "Baixar",
|
||||
"later": "Mais tarde"
|
||||
"later": "Mais tarde",
|
||||
"downloading": "Baixando atualização...",
|
||||
"ready": "Atualização pronta",
|
||||
"error": "Falha na atualização",
|
||||
"changelog": "Novidades",
|
||||
"installNow": "Instalar agora",
|
||||
"restartNow": "Reiniciar agora",
|
||||
"cancel": "Cancelar",
|
||||
"retry": "Tentar novamente",
|
||||
"readyMessage": "A atualização foi baixada. Reinicie o aplicativo para concluir a instalação.",
|
||||
"errorMessage": "Não foi possível baixar a atualização. Por favor, tente novamente mais tarde."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Доступно обновление",
|
||||
"newVersionAvailable": "Доступна новая версия {{version}}",
|
||||
"download": "Скачать",
|
||||
"later": "Позже"
|
||||
"later": "Позже",
|
||||
"downloading": "Загрузка обновления...",
|
||||
"ready": "Обновление готово",
|
||||
"error": "Ошибка обновления",
|
||||
"changelog": "Что нового",
|
||||
"installNow": "Установить сейчас",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"cancel": "Отмена",
|
||||
"retry": "Повторить",
|
||||
"readyMessage": "Обновление загружено. Перезапустите приложение для завершения установки.",
|
||||
"errorMessage": "Не удалось загрузить обновление. Пожалуйста, попробуйте позже."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@
|
||||
"title": "Na voljo posodobitev",
|
||||
"newVersionAvailable": "Na voljo nova različica {{version}}",
|
||||
"download": "Prenesi",
|
||||
"later": "Kasneje"
|
||||
"later": "Kasneje",
|
||||
"downloading": "Prenašanje posodobitve...",
|
||||
"ready": "Posodobitev pripravljena",
|
||||
"error": "Posodobitev ni uspela",
|
||||
"changelog": "Novosti",
|
||||
"installNow": "Namesti zdaj",
|
||||
"restartNow": "Znova zaženi",
|
||||
"cancel": "Prekliči",
|
||||
"retry": "Poskusi znova",
|
||||
"readyMessage": "Posodobitev je prenesena. Znova zaženite aplikacijo za dokončanje namestitve.",
|
||||
"errorMessage": "Posodobitve ni bilo mogoče prenesti. Poskusite pozneje."
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user