diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc5520f..10d4dd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 < 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') && diff --git a/package-lock.json b/package-lock.json index 5707a75..555b50a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8411e9a..0666765 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 10a8b6b..31add42 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc5960d..cc2d87c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,8 @@ "permissions": [ "core:default", "shell:allow-open", - "dialog:default" + "dialog:default", + "updater:default", + "process:allow-restart" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4bf456d..e1e0484 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 278a895..c5d2710 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" + } } } } diff --git a/src/components/shared/UpdateModal.tsx b/src/components/shared/UpdateModal.tsx index a44421c..4fc5c18 100644 --- a/src/components/shared/UpdateModal.tsx +++ b/src/components/shared/UpdateModal.tsx @@ -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(null); + const [state, setState] = useState('idle'); + const [update, setUpdate] = useState(null); + const [progress, setProgress] = useState({ downloaded: 0, total: null }); + const [error, setError] = useState(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( +

+ {trimmed.slice(3)} +

+ ); + } else if (trimmed.startsWith('### ')) { + elements.push( +
+ {trimmed.slice(4)} +
+ ); + } else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { + elements.push( +
  • + {trimmed.slice(2)} +
  • + ); + } else if (trimmed) { + elements.push( +

    + {trimmed} +

    + ); + } + }); + + return <>{elements}; + }; + + // Don't show anything if no update or dismissed + if (state === 'idle' || state === 'checking' || dismissed) return null; return (
    -
    -
    - +
    + {/* Close button for available state */} + {state === 'available' && ( + + )} + + {/* Icon */} +
    + {state === 'ready' ? ( + + ) : state === 'error' ? ( + + ) : ( + + )}
    -

    {t('update.title')}

    -

    - {t('update.newVersionAvailable', { version: latestVersion })} -

    + + {/* Title */} +

    + {state === 'available' && t('update.title')} + {state === 'downloading' && t('update.downloading')} + {state === 'ready' && t('update.ready')} + {state === 'error' && t('update.error')} +

    + + {/* Message / Content */} + {state === 'available' && update && ( + <> +
    + {update.currentVersion} + + {update.version} +
    + + {update.body && ( +
    +

    {t('update.changelog')}

    +
    + {renderChangelog(update.body)} +
    +
    + )} + + )} + + {state === 'downloading' && ( +
    +
    +
    +
    +
    + {progress.total ? ( + <> + {formatBytes(progress.downloaded)} / {formatBytes(progress.total)} + {getProgressPercentage()}% + + ) : ( + formatBytes(progress.downloaded) + )} +
    +
    + )} + + {state === 'ready' && ( +

    + {t('update.readyMessage')} +

    + )} + + {state === 'error' && ( +

    + {error || t('update.errorMessage')} +

    + )} + + {/* Buttons */}
    - - + {state === 'available' && ( + <> + + + + )} + + {state === 'downloading' && ( + + )} + + {state === 'ready' && ( + + )} + + {state === 'error' && ( + <> + + + + )}
    diff --git a/src/locales/de.json b/src/locales/de.json index fbb36ed..7c441bf 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -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." } } diff --git a/src/locales/en.json b/src/locales/en.json index 9ab995f..a0f0733 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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." } } diff --git a/src/locales/es.json b/src/locales/es.json index ca39330..ea2155e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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." } } diff --git a/src/locales/fr.json b/src/locales/fr.json index 3a1ae72..ea9b1e9 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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." } } diff --git a/src/locales/it.json b/src/locales/it.json index 42ae120..8698edb 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -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." } } diff --git a/src/locales/ja.json b/src/locales/ja.json index 44d07c3..5108b6b 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -103,6 +103,16 @@ "title": "アップデートが利用可能", "newVersionAvailable": "新しいバージョン {{version}} が利用可能です", "download": "ダウンロード", - "later": "後で" + "later": "後で", + "downloading": "アップデートをダウンロード中...", + "ready": "アップデート準備完了", + "error": "アップデート失敗", + "changelog": "新機能", + "installNow": "今すぐインストール", + "restartNow": "今すぐ再起動", + "cancel": "キャンセル", + "retry": "再試行", + "readyMessage": "アップデートがダウンロードされました。インストールを完了するにはアプリケーションを再起動してください。", + "errorMessage": "アップデートのダウンロードに失敗しました。後でもう一度お試しください。" } } diff --git a/src/locales/ko.json b/src/locales/ko.json index 1010b4a..e42b4a6 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -103,6 +103,16 @@ "title": "업데이트 가능", "newVersionAvailable": "새 버전 {{version}} 사용 가능", "download": "다운로드", - "later": "나중에" + "later": "나중에", + "downloading": "업데이트 다운로드 중...", + "ready": "업데이트 준비됨", + "error": "업데이트 실패", + "changelog": "새로운 기능", + "installNow": "지금 설치", + "restartNow": "지금 재시작", + "cancel": "취소", + "retry": "다시 시도", + "readyMessage": "업데이트가 다운로드되었습니다. 설치를 완료하려면 애플리케이션을 재시작하세요.", + "errorMessage": "업데이트를 다운로드하지 못했습니다. 나중에 다시 시도해 주세요." } } diff --git a/src/locales/nl.json b/src/locales/nl.json index 71ecfbc..2383885 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -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." } } diff --git a/src/locales/pl.json b/src/locales/pl.json index c1f2089..83c8054 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -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." } } diff --git a/src/locales/pt.json b/src/locales/pt.json index fe629e9..d60f76c 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index ccd1535..e098136 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -103,6 +103,16 @@ "title": "Доступно обновление", "newVersionAvailable": "Доступна новая версия {{version}}", "download": "Скачать", - "later": "Позже" + "later": "Позже", + "downloading": "Загрузка обновления...", + "ready": "Обновление готово", + "error": "Ошибка обновления", + "changelog": "Что нового", + "installNow": "Установить сейчас", + "restartNow": "Перезапустить сейчас", + "cancel": "Отмена", + "retry": "Повторить", + "readyMessage": "Обновление загружено. Перезапустите приложение для завершения установки.", + "errorMessage": "Не удалось загрузить обновление. Пожалуйста, попробуйте позже." } } diff --git a/src/locales/sl.json b/src/locales/sl.json index 541c046..5a92a0b 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -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." } } diff --git a/src/locales/tr.json b/src/locales/tr.json index f2daada..a8582d1 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -103,6 +103,16 @@ "title": "Güncelleme mevcut", "newVersionAvailable": "Yeni sürüm {{version}} mevcut", "download": "İndir", - "later": "Daha sonra" + "later": "Daha sonra", + "downloading": "Güncelleme indiriliyor...", + "ready": "Güncelleme hazır", + "error": "Güncelleme başarısız", + "changelog": "Yenilikler", + "installNow": "Şimdi yükle", + "restartNow": "Şimdi yeniden başlat", + "cancel": "İptal", + "retry": "Tekrar dene", + "readyMessage": "Güncelleme indirildi. Kurulumu tamamlamak için uygulamayı yeniden başlatın.", + "errorMessage": "Güncelleme indirilemedi. Lütfen daha sonra tekrar deneyin." } } diff --git a/src/locales/uk.json b/src/locales/uk.json index ed93f74..26390ad 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -103,6 +103,16 @@ "title": "Доступне оновлення", "newVersionAvailable": "Доступна нова версія {{version}}", "download": "Завантажити", - "later": "Пізніше" + "later": "Пізніше", + "downloading": "Завантаження оновлення...", + "ready": "Оновлення готове", + "error": "Помилка оновлення", + "changelog": "Що нового", + "installNow": "Встановити зараз", + "restartNow": "Перезапустити зараз", + "cancel": "Скасувати", + "retry": "Повторити", + "readyMessage": "Оновлення завантажено. Перезапустіть програму для завершення встановлення.", + "errorMessage": "Не вдалося завантажити оновлення. Спробуйте пізніше." } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 57d2d24..76b2e0c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -103,6 +103,16 @@ "title": "有可用更新", "newVersionAvailable": "新版本 {{version}} 可用", "download": "下载", - "later": "稍后" + "later": "稍后", + "downloading": "正在下载更新...", + "ready": "更新就绪", + "error": "更新失败", + "changelog": "更新内容", + "installNow": "立即安装", + "restartNow": "立即重启", + "cancel": "取消", + "retry": "重试", + "readyMessage": "更新已下载完成。请重启应用程序以完成安装。", + "errorMessage": "无法下载更新。请稍后再试。" } } diff --git a/src/styles/components.css b/src/styles/components.css index 46f22dc..9828301 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -938,6 +938,195 @@ background: var(--bg-hover); } +.update-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Large modal variant for changelog */ +.update-modal.update-modal-large { + max-width: 500px; + text-align: left; +} + +.update-modal-large .update-modal-icon { + margin: 0 auto 16px; +} + +.update-modal-large .update-modal-title { + text-align: center; +} + +/* Close button */ +.update-modal-close { + position: absolute; + top: 12px; + right: 12px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.update-modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.update-modal { + position: relative; +} + +/* Version info */ +.update-version-info { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 20px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; +} + +.update-version-current { + font-size: 14px; + color: var(--text-muted); + font-weight: 500; +} + +.update-version-arrow { + color: var(--text-muted); +} + +.update-version-new { + font-size: 14px; + font-weight: 600; + color: #10b981; +} + +/* Changelog */ +.update-changelog { + margin-bottom: 20px; + max-height: 200px; + overflow-y: auto; +} + +.update-changelog-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.update-changelog-content { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + padding-left: 16px; +} + +.update-changelog-heading { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 12px 0 8px; +} + +.update-changelog-heading:first-child { + margin-top: 0; +} + +.update-changelog-subheading { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin: 8px 0 6px; +} + +.update-changelog-item { + margin: 4px 0; + padding-left: 8px; + position: relative; +} + +.update-changelog-item::before { + content: "•"; + position: absolute; + left: -8px; + color: var(--accent); +} + +.update-changelog-text { + margin: 8px 0; +} + +/* Progress bar */ +.update-progress-container { + width: 100%; + margin: 16px 0 24px; +} + +.update-progress-bar { + width: 100%; + height: 8px; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; +} + +.update-progress-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb 0%, #3b82f6 100%); + border-radius: 4px; + transition: width 0.3s ease; +} + +.update-progress-text { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); +} + +.update-progress-percent { + font-weight: 600; + color: var(--text-primary); +} + +/* Icon states */ +.update-modal-icon.success { + background: linear-gradient(135deg, #10b981 0%, #34d399 100%); +} + +.update-modal-icon.error { + background: linear-gradient(135deg, #ef4444 0%, #f87171 100%); +} + +/* Spinning animation */ +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Error message */ +.update-error-message { + color: #ef4444; +} + /* ======================================== MOTD TIP (Banner under header) ======================================== */