9 Commits
dev ... v1.2.4

Author SHA1 Message Date
Ricardo Pardini
9cf8ca299b gha: build: linux: rework caching for consistency
- `actions/setup-node` doesn't allow for setting cache keys
  - even in recent versions... (bumped to v6)
  - so move npm caching to `actions/cache`:
    - disable `setup-node` caching via `package-manager-cache: false`
    - add new step for `actions/cache` "npm dependencies"
      - cache key includes the runner image, the container distro (if any), and hash of `package-lock.json`
- for `Swatinem/rust-cache`
  - use a cache key that includes the runner image, the container distro (if any), and TAURI_CLI_VERSION
  - add a TODO ref Cargo.lock missing/.gitignored, as it would be hashed too automatically had it existed
- for `actions/cache` based "cargo bin tauri-cli" caching
  - use a cache key that includes the runner image, the container distro (if any), and TAURI_CLI_VERSION
  - also TODO ref Cargo.lock, which was spelled out, but doesn't exist
  - also TODO as it seems to me this is already covered by the `Swatinem/rust-cache` cache

Signed-off-by: Ricardo Pardini <ricardo@pardini.net>
2026-01-03 13:39:37 +01:00
Ricardo Pardini
0431b243bb gha: build: set a specific TAURI_CLI_VERSION (2.9.6)
- so we can hash it into the cache keys (done in later commit) for consistency

Signed-off-by: Ricardo Pardini <ricardo@pardini.net>
2026-01-03 13:39:37 +01:00
Ricardo Pardini
4a3aa68051 gha: build: linux: rework into matrix; use bookworm for .deb builds
- this should reduce the glibc dep version requirement of .deb's, allowing them to run on old but still supported systems
  - See https://v2.tauri.app/distribute/debian/#limitations
    - "you must build your Tauri application using the oldest base system you intend to support"
    - Debian oldstable (Bookworm) will be supported until late 2028, so fair to support it
    - also, there's no downsides; imager itself runs great either way, and .deb install pulls updated deps on newer distros
- fold linux-x64 and linux-amd64 into a single matrix job (1st level)
- 2nd matrix level is per-type:
  - `deb` is now built using an oldtable container
  - `appimage` is built without container, directly on runner, as before
     - seems like appimage/`linuxdeploy` doesn't wanna be run in a container
     - also, the AppImage does seem to contain libs, so we don't wanna ship old ones

Signed-off-by: Ricardo Pardini <ricardo@pardini.net>
2026-01-03 13:39:37 +01:00
SuperKali
d63d047a0f fix: pass APPLE_SIGNING_IDENTITY to Tauri for proper code signing 2025-12-31 17:56:16 +01:00
SuperKali
4b9f5289f7 fix: notarize only DMG file instead of .app bundle 2025-12-31 17:30:48 +01:00
SuperKali
840509ec0c feat: add Apple code signing and notarization for macOS builds 2025-12-31 17:06:14 +01:00
SuperKali
8e99d25c8d feat: add settings panel with theme, language, and developer options
Implement comprehensive settings modal with:
- Theme switching (light/dark/auto) with system preference detection
- Language selection for 17 languages with native name sorting
- Developer mode with detailed logging and log viewer
- About section with app info and external links
- Update notification improvements with reduced log spam

Technical improvements:
- Added ThemeContext with persistent state management
- Implemented memory-safe log file reading (5MB limit)
- Fixed all ESLint, TypeScript, and Clippy warnings
- Added JSDoc documentation for public APIs
- Updated README.md and DEVELOPMENT.md with new features
2025-12-30 09:59:24 +01:00
SuperKali
41c0e90f54 feat: detect board from custom image filename and improve decompression
Board detection:
- Parse Armbian filename pattern (Armbian_VERSION_BOARD_DISTRO_...) to extract board name
- Match extracted board name against database to show board image instead of generic icon
- Auto-load board data if not cached with race condition protection
- Fallback to generic icon for non-Armbian images

Decompression improvements:
- Decompress custom images to app cache directory (custom-decompress/)
- Use timestamp-based unique filenames to avoid conflicts
- Cleanup decompressed files after successful flash

Performance:
- Optimize lock scope to release mutex early after extracting boards
- Use compare-and-swap pattern to prevent race conditions
2025-12-29 13:37:11 +01:00
SuperKali
054c41fcec refactor: organize scripts into dedicated directories
- Move build scripts (build-*.sh) to scripts/build/ directory
- Move sync-locales.js to scripts/locales/ directory
- Update GitHub workflow to reference new script paths
- Improve DEVELOPMENT.md with new script paths and detailed Project Structure
- Remove obsolete scripts/README.md
2025-12-26 22:02:36 +01:00
72 changed files with 4278 additions and 541 deletions

View File

@@ -184,10 +184,38 @@ jobs:
os: macos-latest
runs-on: ${{ matrix.os }}
env:
APPLE_SIGNING_IDENTITY: "-"
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
steps:
- uses: actions/checkout@v4
- name: Import Apple Developer Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
echo -n "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Verify certificate
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# Set as default keychain
security default-keychain -s "$KEYCHAIN_PATH"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -241,6 +269,39 @@ jobs:
ditto -c -k --sequesterRsrc --keepParent "$app" "$CARGO_TARGET_DIR/release/bundle/macos/${base}-${{ matrix.arch }}.app.zip"
done
- name: Notarize and staple DMG
shell: bash
env:
CARGO_TARGET_DIR: src-tauri/target/${{ matrix.target }}
run: |
set -euo pipefail
# Find the DMG file
DMG_FILE=$(find "$CARGO_TARGET_DIR/release/bundle/dmg/" -name "*.dmg" -type f | head -n 1)
if [[ -z "$DMG_FILE" ]]; then
echo "No DMG file found"
exit 1
fi
echo "Notarizing: $DMG_FILE"
# Submit DMG for notarization
xcrun notarytool submit "$DMG_FILE" \
--apple-id "$APPLE_ID" \
--password "$APPLE_ID_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait \
--output-format json
# Staple the notarization ticket to DMG
xcrun stapler staple "$DMG_FILE"
# Verify stapling
xcrun stapler validate -v "$DMG_FILE"
echo "Notarization completed successfully"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:

View File

@@ -6,12 +6,8 @@ on:
- 'v*'
workflow_dispatch:
inputs:
build_linux_x64:
description: 'Build Linux x64'
type: boolean
default: true
build_linux_arm64:
description: 'Build Linux ARM64'
build_linux:
description: 'Build Linux (x64 + ARM64)'
type: boolean
default: true
build_macos:
@@ -34,7 +30,7 @@ concurrency:
env:
CARGO_TERM_COLOR: always
NODE_VERSION: '20'
TAURI_CLI_VERSION: '^2'
TAURI_CLI_VERSION: '2.9.6'
# 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 }}
@@ -93,11 +89,35 @@ jobs:
omitNameDuringUpdate: true
replacesArtifacts: false
build-linux-x64:
name: build-linux (x86_64-unknown-linux-gnu)
needs: [create-release]
if: ${{ github.event_name == 'push' || inputs.build_linux_x64 }}
runs-on: ubuntu-24.04
build-linux:
name: build-linux ${{ matrix.type.name }} (${{ matrix.arch.name }}/${{ matrix.type.distro_id }})
needs: [ create-release ]
if: ${{ github.event_name == 'push' || inputs.build_linux }} # can't use matrix here; if is evaluated before matrix expansion
strategy:
fail-fast: false # let other jobs try to complete if one fails
matrix:
arch:
- { name: 'amd64', runner: 'ubuntu-24.04' }
- { name: 'arm64', runner: "ubuntu-24.04-arm" }
type:
# deb: build in the oldest still-supported matching container: oldstable (bookworm)
- name: 'deb'
distro_id: "bookworm"
container: { image: 'debian:bookworm' }
bundles: 'deb'
deps: "apt"
artifacts: "src-tauri/target/release/bundle/deb/*.deb"
# appimage: doesn't use a container (instead, runs directly on the runner); requires sudo to install deps
- name: 'appimage'
distro_id: "gharunner"
bundles: 'appimage'
deps: "apt"
deps_gain_root: "sudo"
artifacts: |
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/appimage/*.AppImage.sig
runs-on: ${{ matrix.arch.runner }}
container: ${{ matrix.type.container }}
permissions:
contents: write
steps:
@@ -125,10 +145,13 @@ jobs:
-e "s/\"version\": \"[0-9.]*\"/\"version\": \"$VERSION\"/" \
src-tauri/tauri.conf.json
- name: Install dependencies
- name: Install dependencies (apt)
if: ${{ matrix.type.deps == 'apt' }}
run: |
sudo apt-get update
sudo apt-get install -y \
${{ matrix.type.deps_gain_root || '' }} apt-get update
${{ matrix.type.deps_gain_root || '' }} apt-get install -y \
build-essential \
curl \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
@@ -139,10 +162,17 @@ jobs:
xdg-utils
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
package-manager-cache: false # setup-node can't add key to cache, so we do it ourselves in below step
# Cache npm, just like setup-node would do it with "cache: npm", but with our own key that includes arch and distro
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ matrix.arch.runner }}-${{ matrix.arch.name }}-${{ matrix.type.distro_id }}-${{ hashFiles('**/package-lock.json') }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -151,12 +181,16 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
# ensure cache separation per host distro; this also ends up caching tauri-cli, so add that to key too.
key: "rust-cache-${{ matrix.arch.runner }}-${{ matrix.arch.name }}-${{ matrix.type.distro_id }}-${{env.TAURI_CLI_VERSION}}"
# @TODO: Cargo.lock is in gitignore, so it's not included here - it would via rust-cache Action magic, no need to specify it.
- name: Cache cargo bin (tauri-cli)
- name: Cache cargo bin (tauri-cli) # @TODO: Swatinem/rust-cache already caches this. maybe just drop this.
uses: actions/cache@v4
with:
path: ~/.cargo/bin
key: cargo-bin-${{ runner.os }}-${{ runner.arch }}-stable-${{ hashFiles('**/Cargo.lock') }}
key: "cargo-bin-${{ matrix.arch.runner }}-${{ matrix.arch.name }}-${{ matrix.type.distro_id }}-stable-${{env.TAURI_CLI_VERSION}}-${{ hashFiles('**/Cargo.lock') }}"
# @TODO: Cargo.lock is in gitignore, so it's not included here, albeit being specified.
- name: Install npm dependencies
run: npm ci
@@ -172,7 +206,7 @@ jobs:
fi
- name: Build Tauri app
run: cargo tauri build --bundles deb,appimage
run: cargo tauri build --bundles "${{ matrix.type.bundles }}"
- name: Upload artifacts to GitHub Release
uses: ncipollo/release-action@v1
@@ -186,106 +220,7 @@ jobs:
omitNameDuringUpdate: true
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)
needs: [create-release]
if: ${{ github.event_name == 'push' || inputs.build_linux_arm64 }}
runs-on: ubuntu-24.04-arm
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set version from release tag
shell: bash
run: |
set -euo pipefail
TAG='${{ needs.create-release.outputs.release_tag }}'
VERSION="${TAG#v}"
echo "Setting version to $VERSION"
sed -i \
-e "s/\"version\": \"[0-9.]*\"/\"version\": \"$VERSION\"/" \
package.json
sed -i \
-e "s/^version = \".*\"/version = \"$VERSION\"/" \
src-tauri/Cargo.toml
sed -i \
-e "s/\"version\": \"[0-9.]*\"/\"version\": \"$VERSION\"/" \
src-tauri/tauri.conf.json
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libgtk-3-dev \
squashfs-tools \
xdg-utils
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Cache cargo bin (tauri-cli)
uses: actions/cache@v4
with:
path: ~/.cargo/bin
key: cargo-bin-${{ runner.os }}-${{ runner.arch }}-stable-${{ hashFiles('**/Cargo.lock') }}
- name: Install npm dependencies
run: npm ci
- name: Build frontend
run: npm run build
- name: Install Tauri CLI (if missing)
shell: bash
run: |
if ! command -v cargo-tauri >/dev/null 2>&1; then
cargo install tauri-cli --version "${TAURI_CLI_VERSION}" --locked
fi
- name: Build Tauri app
run: cargo tauri build --bundles deb,appimage
- name: Upload artifacts to GitHub Release
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.create-release.outputs.release_tag }}
name: "Armbian Imager ${{ needs.create-release.outputs.release_tag }}"
draft: true
prerelease: false
allowUpdates: true
omitBodyDuringUpdate: true
omitNameDuringUpdate: true
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
${{ matrix.type.artifacts }}
build-macos:
name: build-macos (${{ matrix.target }})
@@ -306,12 +241,39 @@ jobs:
permissions:
contents: write
env:
# Ad-hoc signing: allows app to run after "xattr -cr" on macOS
APPLE_SIGNING_IDENTITY: "-"
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
steps:
- uses: actions/checkout@v4
- name: Import Apple Developer Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
echo -n "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Verify certificate
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# Set as default keychain
security default-keychain -s "$KEYCHAIN_PATH"
- name: Set version from release tag
shell: bash
run: |
@@ -397,6 +359,39 @@ jobs:
mv "$sig" "$CARGO_TARGET_DIR/release/bundle/macos/${base}-${{ matrix.arch }}.tar.gz.sig"
done
- name: Notarize and staple DMG
shell: bash
env:
CARGO_TARGET_DIR: src-tauri/target/${{ matrix.target }}
run: |
set -euo pipefail
# Find the DMG file
DMG_FILE=$(find "$CARGO_TARGET_DIR/release/bundle/dmg/" -name "*.dmg" -type f | head -n 1)
if [[ -z "$DMG_FILE" ]]; then
echo "No DMG file found"
exit 1
fi
echo "Notarizing: $DMG_FILE"
# Submit DMG for notarization
xcrun notarytool submit "$DMG_FILE" \
--apple-id "$APPLE_ID" \
--password "$APPLE_ID_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait \
--output-format json
# Staple the notarization ticket to DMG
xcrun stapler staple "$DMG_FILE"
# Verify stapling
xcrun stapler validate -v "$DMG_FILE"
echo "Notarization completed successfully"
- name: Upload macOS artifacts to GitHub Release
uses: ncipollo/release-action@v1
with:
@@ -593,8 +588,7 @@ jobs:
name: Generate latest.json for updater
needs:
- create-release
- build-linux-x64
- build-linux-arm64
- build-linux
- build-macos
- build-windows-x64
- build-windows-arm64
@@ -710,8 +704,7 @@ jobs:
name: Publish release (draft -> false) + cleanup
needs:
- create-release
- build-linux-x64
- build-linux-arm64
- build-linux
- build-macos
- build-windows-x64
- build-windows-arm64

View File

@@ -32,7 +32,7 @@ jobs:
OPENAI_TIER: ${{ vars.OPENAI_TIER || 'free' }}
RETRY_FAILED: ${{ vars.RETRY_FAILED || 'false' }}
run: |
node scripts/sync-locales.js
node scripts/locales/sync-locales.js
continue-on-error: true
- name: Check for changes

View File

@@ -104,23 +104,23 @@ npm run tauri:dev
### Single Platform
```bash
./scripts/build-macos.sh # macOS (Intel + ARM)
./scripts/build-linux.sh # Linux (x64 + ARM)
npm run tauri:build # Windows
./scripts/build/build-macos.sh # macOS (Intel + ARM)
./scripts/build/build-linux.sh # Linux (x64 + ARM)
npm run tauri:build # Windows
```
### All Platforms
```bash
./scripts/build-all.sh
./scripts/build/build-all.sh
```
### Build Options
```bash
./scripts/build-macos.sh --clean # Clean build
./scripts/build-macos.sh --dev # Debug symbols
./scripts/build-macos.sh --clean --dev # Both
./scripts/build/build-macos.sh --clean # Clean build
./scripts/build/build-macos.sh --dev # Debug symbols
./scripts/build/build-macos.sh --clean --dev # Both
```
### Output
@@ -132,46 +132,118 @@ npm run tauri:build # Windows
## Project Structure
### Directory Overview
```
armbian-imager/
├── src/ # React Frontend
│ ├── components/ # UI Components
│ │ ├── flash/ # Flash progress
│ │ ├── layout/ # Header, HomePage
│ │ ├── modals/ # Board, Image, Device, Manufacturer
│ │ ├── flash/ # Flash progress components
│ │ │ ├── FlashActions.tsx # Action buttons (cancel, retry)
│ │ │ ├── FlashProgress.tsx # Progress display
│ │ │ └── FlashStageIcon.tsx # Stage indicators
│ │ ├── layout/ # Main layout
│ │ │ ├── Header.tsx # Top navigation bar
│ │ │ └── HomePage.tsx # Main page
│ │ ├── modals/ # Selection flow modals
│ │ │ ├── ManufacturerModal.tsx
│ │ │ ├── BoardModal.tsx
│ │ │ ├── ImageModal.tsx
│ │ │ └── DeviceModal.tsx
│ │ ├── settings/ # Settings modal components
│ │ │ ├── SettingsModal.tsx # Main settings modal
│ │ │ ├── GeneralSection.tsx# General settings (MOTD, updates)
│ │ │ ├── ThemeSection.tsx # Theme selection (light/dark/auto)
│ │ │ ├── LanguageSection.tsx# Language selection (17 languages)
│ │ │ ├── AdvancedSection.tsx# Developer mode & logs
│ │ │ └── AboutSection.tsx # App info & links
│ │ └── shared/ # Reusable components
│ ├── hooks/ # React Hooks
│ ├── config/ # Badges, manufacturers, OS info
│ ├── locales/ # i18n (15 languages)
│ │ ├── AppVersion.tsx # Version display
│ │ ├── ErrorDisplay.tsx # Error presentation
│ │ ├── LoadingState.tsx # Loading indicators
│ │ └── SearchBox.tsx # Search functionality
│ ├── hooks/ # Custom React Hooks
│ │ ├── useTauri.ts # Tauri IPC wrappers
│ │ ├── useVendorLogos.ts # Logo validation
│ │ ├── useAsyncData.ts # Async data fetching pattern
│ │ └── useSettings.ts # Settings persistence hook
│ ├── contexts/ # React Context providers
│ │ └── ThemeContext.tsx # Theme state management (light/dark/auto)
│ ├── config/ # Static configuration
│ │ ├── constants.ts # App constants
│ │ ├── deviceColors.ts # Device color mapping
│ │ ├── os-info.ts # OS information
│ │ └── i18n.ts # i18n config & language metadata
│ ├── locales/ # i18n translations (17 languages)
│ ├── styles/ # Modular CSS
│ │ ├── theme.css # Theme variables (light/dark)
│ │ ├── components.css # Component styles
│ │ └── responsive.css # Responsive design
│ ├── types/ # TypeScript interfaces
│ ├── utils/ # Utilities
│ └── assets/ # Images, logos, icons
│ ├── utils/ # Utility functions
│ ├── assets/ # Static assets
│ ├── App.tsx # Main app component
│ └── main.tsx # React entry point
│
├── src-tauri/ # Rust Backend
│ ├── src/
│ │ ├── commands/ # Tauri IPC handlers
│ │ ├── config/ # Configuration
│ │ ├── devices/ # Device detection
│ │ ├── flash/ # Platform flash implementations
│ │ ├── images/ # Image management
│ │ ├── commands/ # Tauri IPC command handlers
│ │ │ ├── board_queries.rs # Board/image API queries
│ │ │ ├── operations.rs # Download & flash operations
│ │ │ ├── custom_image.rs # Custom image handling
│ │ │ ├── progress.rs # Progress event emission
│ │ │ ├── settings.rs # Settings commands (get/set dev mode, logs)
│ │ │ ├── system.rs # System utilities
│ │ │ └── state.rs # Shared application state
│ │ ├── devices/ # Platform-specific device detection
│ │ │ ├── linux.rs # Linux (UDisks2)
│ │ │ ├── macos.rs # macOS (diskutil)
│ │ │ └── windows.rs # Windows (WMI)
│ │ ├── flash/ # Platform-specific flash operations
│ │ │ ├── linux/ # Linux implementation
│ │ │ ├── macos/ # macOS implementation
│ │ │ └── windows/ # Windows implementation
│ │ ├── images/ # Image file management
│ │ ├── logging/ # Session logging
│ │ ├── paste/ # Log upload
│ │ ├── utils/ # Utilities
│ │ ├── download.rs # HTTP downloads
│ │ └── decompress.rs # XZ, GZ, ZSTD
│ ├── icons/ # App icons
│ │ ├── paste/ # Log upload service
│ │ ├── utils/ # Rust utilities
│ │ ├── download.rs # HTTP streaming downloads
│ │ ├── decompress.rs # Archive extraction
│ │ └── main.rs # Rust entry point
│ ├── icons/ # App icons (all platforms)
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ └── target/ # Compiled binaries (gitignored)
│
├── scripts/ # Build and setup
│ ├── build-*.sh # Platform build scripts
├── scripts/ # Build and utility scripts
│ ├── build/ # Platform build scripts
│ │ ├── build-all.sh # All platforms
│ │ ├── build-linux.sh # Linux builds
│ │ └── build-macos.sh # macOS universal binaries
│ ├── locales/ # Locale management
│ │ └── sync-locales.js # Translation sync script
│ └── setup/ # Dependency installation
│ ├── install.sh
│ ├── install-linux.sh
│ ├── install-macos.sh
│ └── install-windows.ps1
│ ├── install.sh # Universal installer
│ ├── install-linux.sh # Linux dependencies
│ ├── install-macos.sh # macOS dependencies
│ └── install-windows.ps1 # Windows dependencies
│
└── .github/workflows/ # CI/CD
├── .github/workflows/ # CI/CD pipelines
│ ├── build.yml # CI builds
│ ├── build-artifacts.yml # Release builds
│ ├── pr-check.yml # PR validation
│ └── sync-locales.yml # Auto translation sync
│
├── public/ # Static assets
│ └── locales/ # i18n fallback data
│
├── docs/ # Additional documentation
├── images/ # Project images/screenshots
├── package.json # Node dependencies & scripts
│
├── CONTRIBUTING.md # Contribution guidelines
├── DEVELOPMENT.md # This file
└── README.md # Project overview
```
---
@@ -185,7 +257,8 @@ armbian-imager/
| React 19 | UI Framework |
| TypeScript | Type Safety |
| Vite | Build Tool & Dev Server |
| i18next | i18n (15 languages) |
| React Context API | State Management (Theme) |
| i18next | i18n (17 languages) |
| Lucide | Icons |
### Backend
@@ -194,6 +267,7 @@ armbian-imager/
|------------|---------|
| Rust | Systems Programming |
| Tauri 2 | Desktop Framework |
| Tauri Store Plugin | Persistent Settings |
| Tokio | Async Runtime |
| Serde | Serialization |
| Reqwest | HTTP Client |

View File

@@ -40,10 +40,6 @@ Prebuilt binaries are available for all supported platforms.
| Intel & Apple Silicon | x64 & ARM64 | x64 & ARM64 |
| <code>.dmg</code> / <code>.app.zip</code> | <code>.exe</code> / <code>.msi</code> | <code>.deb</code> / <code>.AppImage</code> |
**macOS: First Launch**
On first launch, macOS may block the application because it is not signed. If this happens, open **System Settings → Privacy & Security** and click **Open Anyway** next to *Armbian Imager was blocked*. This only needs to be done once.
## How It Works
1. **Select Manufacturer** — Choose from 70+ supported SBC manufacturers or load a custom image
@@ -51,6 +47,12 @@ On first launch, macOS may block the application because it is not signed. If th
3. **Select Image** — Choose desktop or server, kernel variant, and stable or nightly builds
4. **Flash** — Download, decompress, write, and verify automatically
## Customization
- **Theme Selection**: Light, dark, or automatic based on system preferences
- **Developer Mode**: Enable detailed logging and view application logs
- **Language Selection**: 17 languages with automatic system detection
## Platform Support
| Platform | Architecture | Notes |

107
package-lock.json generated
View File

@@ -12,14 +12,17 @@
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2",
"@types/qrcode": "^1.5.6",
"ansi-to-html": "^0.7.0",
"i18next": "^25.7.2",
"lucide-react": "^0.560.0",
"qrcode": "^1.5.4",
"react": "^19.2.2",
"react-dom": "^19.2.2",
"react-i18next": "^16.5.0"
"react-i18next": "^16.5.0",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1602,6 +1605,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz",
"integrity": "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@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",
@@ -2065,6 +2077,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"license": "MIT",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2310,6 +2337,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2650,6 +2686,29 @@
"dev": true,
"license": "ISC"
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2710,6 +2769,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2921,6 +2986,18 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3242,6 +3319,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-fhyD2BLrew6qYf4NNtHff1rLXvzR25rq49p+FeqByOazc6TcSi2n8EYulo5C1PbH+1uBW++5S1SG7FcUU6mlDg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3490,6 +3568,24 @@
"typescript": ">=4.8.4"
}
},
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"license": "MIT",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3548,6 +3644,15 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -31,16 +31,19 @@
"dependencies": {
"@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",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2",
"@types/qrcode": "^1.5.6",
"ansi-to-html": "^0.7.0",
"i18next": "^25.7.2",
"lucide-react": "^0.560.0",
"qrcode": "^1.5.4",
"react": "^19.2.2",
"react-dom": "^19.2.2",
"react-i18next": "^16.5.0"
"react-i18next": "^16.5.0",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -1,244 +0,0 @@
# Translation Sync Scripts
This directory contains automation scripts for managing i18n translations.
## sync-locales.js
Automatically syncs all translation files with `src/locales/en.json` (the source of truth) and translates missing keys using AI.
### Features
- **Automatic Detection**: Finds missing keys in all locale files
- **AI Translation**: Uses OpenAI API to automatically translate new keys with high quality
- **Context-Aware**: Provides section/key context to the AI for better translations
- **Placeholder Preservation**: Maintains i18next placeholders like `{{count}}` and `{{boardName}}`
- **Adaptive Rate Limiting**: Automatically adjusts based on model and payment tier
- **Error Handling**: Falls back to `TODO:` prefix if translation fails
- **Smart Prompts**: Uses specialized prompts for technical UI translation
### Usage
#### Local Development
```bash
# Basic usage (requires OpenAI API key)
export OPENAI_API_KEY=sk-...
node scripts/sync-locales.js
# With custom model (default: gpt-4o-mini)
OPENAI_MODEL=gpt-4o node scripts/sync-locales.js
# With custom OpenAI-compatible API endpoint
OPENAI_API=https://api.openai.com/v1 node scripts/sync-locales.js
# For paid tier (much faster - 50-100x speedup)
OPENAI_TIER=paid node scripts/sync-locales.js
# Retry failed translations (keys marked with TODO:)
RETRY_FAILED=true node scripts/sync-locales.js
```
#### GitHub Actions
The workflow runs automatically:
- **Daily** at 00:00 UTC
- **On push** to the branch
- **On manual trigger** via workflow_dispatch
### Configuration
#### Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `OPENAI_API_KEY` | OpenAI API key | - | Yes |
| `OPENAI_MODEL` | Model to use for translation | `gpt-4o-mini` | No |
| `OPENAI_API` | API endpoint URL | `https://api.openai.com/v1` | No |
| `OPENAI_TIER` | Account tier for rate limits | `free` | No |
| `RETRY_FAILED` | Retry keys marked with `TODO:` | `false` | No |
#### GitHub Secrets/Variables
To configure the GitHub Action:
1. **Required - Add API key**:
```bash
gh secret set OPENAI_API_KEY
```
2. **Optional - Custom model** (for cost/quality tuning):
```bash
gh variable set OPENAI_MODEL --value "gpt-4o-mini"
```
3. **Optional - Custom endpoint** (for OpenAI-compatible APIs):
```bash
gh variable set OPENAI_API --value "https://api.openai.com/v1"
```
4. **Optional - Set account tier** (for faster translations with paid account):
```bash
gh variable set OPENAI_TIER --value "paid"
```
5. **Optional - Retry failed translations** (re-attempt keys marked with `TODO:`):
```bash
gh variable set RETRY_FAILED --value "true"
```
### OpenAI Setup
#### Getting an API Key
1. Visit [platform.openai.com](https://platform.openai.com/)
2. Sign up or log in
3. Navigate to API Keys section
4. Create a new API key
5. Add it to your environment or GitHub secrets
#### Account Tier & Rate Limits
The script automatically adjusts speed based on your account tier:
| Tier | RPM Limit | Batch Size | Delay | 100 Keys Time |
|------|-----------|------------|-------|---------------|
| **Free** | 3/min | 1 | 21s | ~35 min |
| **Paid (Tier 1-2)** | 200/min | 50 | 300ms | ~1 min |
| **Paid (Tier 3-5)** | 500/min | 50 | 120ms | ~30 sec |
To use paid tier rates:
1. Add a payment method to your OpenAI account (even $5 works)
2. Set `OPENAI_TIER=paid` environment variable
**Recommendation**: With just a $5 balance, you get Tier 1-2 rates which are **65x faster** than free tier.
#### Choosing a Model
| Model | Cost | Quality | Speed | Best For |
|-------|------|---------|-------|----------|
| `gpt-4o-mini` | Low | High | Fast | Most translations (default) |
| `gpt-4o` | Medium | Very High | Fast | Complex UI text |
| `gpt-3.5-turbo` | Very Low | Medium | Very Fast | Simple translations |
**Recommendation**: Start with `gpt-4o-mini` for the best balance of cost, quality, and speed.
### Supported Languages
| Code | Language |
|------|----------|
| `de` | German |
| `es` | Spanish |
| `fr` | French |
| `it` | Italian |
| `ja` | Japanese |
| `ko` | Korean |
| `nl` | Dutch |
| `pl` | Polish |
| `pt` | Portuguese |
| `ru` | Russian |
| `sl` | Slovenian |
| `tr` | Turkish |
| `uk` | Ukrainian |
| `zh` | Chinese (Simplified) |
### Output
The script will:
1. âś… Show which keys are missing for each language
2. 🤖 Translate missing keys with OpenAI
3. 📊 Show translation statistics (success/failure)
4. ⚠️ Warn about any translation failures
5. đź’ľ Update locale files with translated content
6. 🔍 Exit with code 1 if changes were made (useful for CI/CD)
### Example Output
```
🔍 Syncing translation files with en.json (source of truth)
🤖 Using OpenAI API: https://api.openai.com/v1
📦 Model: gpt-4o-mini
âś… API key is configured
âś… Source file has 93 keys
📝 Processing de (German)...
âś… de is up to date (93 keys)
📝 Processing hr (Croatian)...
⚠️ Found 64 missing keys
🤖 Translating 64 strings with OpenAI...
âś… Updated hr with 64 new keys
✨ Translation files updated successfully!
📊 Summary:
- Total translated: 64 keys
- Please review translations for accuracy and context
```
### AI Translation Features
The script uses specialized prompts to ensure:
1. **Context Awareness**: Provides section/key context for each translation
2. **Technical Terminology**: Knows when to keep terms like "Flash", "SD card", "USB" in English
3. **Placeholder Preservation**: Maintains `{{variables}}` exactly as they appear
4. **UI Appropriateness**: Uses concise, natural text for buttons and labels
5. **Plural Forms**: Handles i18next plural suffixes (_one, _other) correctly
6. **Consistent Tone**: Maintains formal but friendly tone throughout
### Best Practices
1. **Review Translations**: AI translations are excellent but may need context-specific adjustments
2. **Test in App**: Always test translations in the actual application
3. **Handle Plurals**: The script preserves `_one` and `_other` suffixes for plural forms
4. **Check Placeholders**: Verify that `{{variables}}` are correctly preserved
5. **Cultural Nuances**: Review translations for cultural appropriateness
6. **Cost Management**: Use `gpt-4o-mini` for best cost/quality balance
### Troubleshooting
#### API Key Not Found
```
❌ OPENAI_API_KEY is not set!
```
**Solution**: Set the environment variable or GitHub secret:
```bash
export OPENAI_API_KEY=sk-...
```
#### Translation Failures
If some translations fail with `TODO:`:
- Check your API key has sufficient credits
- Verify API endpoint is accessible
- Check rate limits (especially for larger translation batches
#### Poor Quality Translations
If translations seem off:
- The AI might lack specific UI context
- Try a more capable model like `gpt-4o`
- Manually edit the JSON files to fix issues
- Consider the translation context provided in the prompt
#### Cost Concerns
For cost optimization:
- Use `gpt-4o-mini` (very cost-effective)
- Run the script less frequently
- Review and merge translations in batches
- Consider caching previous translations
### Cost Estimation
Approximate costs for translating missing keys (using `gpt-4o-mini`):
- **10 keys**: ~$0.00001
- **50 keys**: ~$0.00005
- **100 keys**: ~$0.0001
*Costs vary based on text length and model used.*

View File

@@ -15,6 +15,7 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

View File

@@ -1,4 +1,7 @@
fn main() {
// Extract Tauri version from Cargo.toml and expose it as a compile-time env var
println!("cargo:rustc-env=TAURI_VERSION={}", tauri_version());
// On Windows, embed the manifest to request admin privileges at startup
#[cfg(windows)]
{
@@ -11,3 +14,26 @@ fn main() {
#[cfg(not(windows))]
tauri_build::build();
}
/// Extract Tauri version from Cargo.toml dependencies
fn tauri_version() -> String {
let cargo_toml = std::path::PathBuf::from("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
// Look for tauri dependency version
for line in content.lines() {
if line.contains("tauri =") || line.contains("tauri =") {
// Extract version from line like: tauri = { version = "2.x", ... }
if let Some(start) = line.find("version = \"") {
let after_version = &line[start + 11..];
if let Some(end) = after_version.find('"') {
return after_version[..end].to_string();
}
}
}
}
}
// Fallback to unknown if parsing fails
"unknown".to_string()
}

View File

@@ -8,6 +8,7 @@
"shell:allow-open",
"dialog:default",
"updater:default",
"process:allow-restart"
"process:allow-restart",
"store:default"
]
}

View File

@@ -13,7 +13,7 @@ use crate::images::{
extract_images, fetch_all_images, filter_images_for_board, get_unique_boards, BoardInfo,
ImageInfo,
};
use crate::{log_error, log_info};
use crate::{log_debug, log_error, log_info};
use super::state::AppState;
@@ -59,6 +59,13 @@ pub async fn get_images_for_board(
board_slug,
stable_only
);
log_debug!(
"board_queries",
"Filters - preapp: {:?}, kernel: {:?}, variant: {:?}",
preapp_filter,
kernel_filter,
variant_filter
);
let json_guard = state.images_json.lock().await;
let json = json_guard.as_ref().ok_or_else(|| {
@@ -71,6 +78,7 @@ pub async fn get_images_for_board(
})?;
let images = extract_images(json);
log_debug!("board_queries", "Total images available: {}", images.len());
let filtered = filter_images_for_board(
&images,
&board_slug,
@@ -79,6 +87,12 @@ pub async fn get_images_for_board(
variant_filter.as_deref(),
stable_only,
);
log_debug!(
"board_queries",
"Filtered down to {} images for board {}",
filtered.len(),
board_slug
);
log_info!(
"board_queries",
"Found {} images for board {}",

View File

@@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::State;
use crate::config;
use crate::decompress::{decompress_local_file, needs_decompression};
use crate::images::{extract_images, fetch_all_images, get_unique_boards, BoardInfo};
use crate::utils::{get_cache_dir, normalize_slug};
use crate::{log_error, log_info};
use super::state::AppState;
@@ -130,3 +133,176 @@ pub async fn select_custom_image(window: tauri::Window) -> Result<Option<CustomI
}
}
}
/// Delete a decompressed custom image file
#[tauri::command]
pub async fn delete_decompressed_custom_image(image_path: String) -> Result<(), String> {
log_info!(
"custom_image",
"Deleting decompressed custom image: {}",
image_path
);
let path = PathBuf::from(&image_path);
// Safety check: only delete files in our custom-decompress directory
let custom_decompress_dir = get_cache_dir(config::app::NAME).join("custom-decompress");
if !path.starts_with(&custom_decompress_dir) {
log_error!(
"custom_image",
"Attempted to delete file outside custom-decompress cache: {}",
image_path
);
return Err("Cannot delete files outside custom-decompress directory".to_string());
}
if path.exists() {
std::fs::remove_file(&path).map_err(|e| {
log_error!(
"custom_image",
"Failed to delete decompressed image {}: {}",
image_path,
e
);
format!("Failed to delete decompressed image: {}", e)
})?;
log_info!("custom_image", "Deleted decompressed image: {}", image_path);
}
// Try to remove empty parent directory (ignore errors)
let _ = std::fs::remove_dir(&custom_decompress_dir);
Ok(())
}
/// Detect board information from custom image filename
/// Parses Armbian naming convention: Armbian_VERSION_BOARD_DISTRO_VENDOR_KERNEL_FLAVOR.img.xz
#[tauri::command]
pub async fn detect_board_from_filename(
filename: String,
state: State<'_, AppState>,
) -> Result<Option<BoardInfo>, String> {
log_info!(
"custom_image",
"=== Starting board detection from filename: {} ===",
filename
);
// 1. Extract filename from path (remove directory)
let path = PathBuf::from(&filename);
let filename_only = path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
// 2. Remove extension(s) - handle .img.xz, .img.gz, .img.zst, .img.bz2, .img
let stem = filename_only
.strip_suffix(".xz")
.or_else(|| filename_only.strip_suffix(".gz"))
.or_else(|| filename_only.strip_suffix(".zst"))
.or_else(|| filename_only.strip_suffix(".bz2"))
.or_else(|| filename_only.strip_suffix(".img"))
.unwrap_or(filename_only);
// 3. Parse Armbian naming pattern: Armbian_VERSION_BOARD_DISTRO_VENDOR_KERNEL_FLAVOR
let parts: Vec<&str> = stem.split('_').collect();
// 4. Validate Armbian format (at least 4 parts, starts with "Armbian")
if parts.len() < 4 || !parts[0].eq_ignore_ascii_case("Armbian") {
log_info!(
"custom_image",
"Not an Armbian image or invalid format: {}",
filename_only
);
return Ok(None);
}
// 5. Extract board name (index 2)
let board_name = parts[2];
log_info!(
"custom_image",
"Extracted board name from filename: {}",
board_name
);
// 6. Normalize board name to slug format
let normalized_slug = normalize_slug(board_name);
log_info!("custom_image", "Normalized board slug: {}", normalized_slug);
// 7. Ensure board data is loaded (auto-load if not cached)
// Use compare-and-swap pattern to prevent race conditions
log_info!("custom_image", "Checking if board data is cached...");
{
let needs_loading = {
let json_guard = state.images_json.lock().await;
json_guard.is_none()
};
if needs_loading {
log_info!(
"custom_image",
"Board data not cached, fetching from API..."
);
let json = fetch_all_images().await.map_err(|e| {
log_error!("custom_image", "Failed to fetch board data: {}", e);
format!("Failed to fetch board data: {}", e)
})?;
// Cache the fetched data
let mut json_guard = state.images_json.lock().await;
// Double-check: another thread might have loaded it while we were fetching
if json_guard.is_none() {
*json_guard = Some(json);
log_info!("custom_image", "Board data cached successfully");
} else {
log_info!(
"custom_image",
"Board data was already cached by another thread"
);
}
}
}
// 8. Get cached boards data (now guaranteed to be loaded)
// Extract boards in a scoped block to release lock early
let matching_board = {
log_info!("custom_image", "Accessing cached board data...");
let json_guard = state.images_json.lock().await;
let json = json_guard.as_ref().ok_or("Images not loaded")?;
log_info!("custom_image", "Loaded images JSON, extracting boards...");
let images = extract_images(json);
log_info!("custom_image", "Extracted {} images", images.len());
let boards = get_unique_boards(&images);
log_info!(
"custom_image",
"Found {} unique boards in database",
boards.len()
);
// Lock released here
// 9. Find matching board by slug
boards
.iter()
.find(|board| board.slug == normalized_slug)
.cloned()
}; // matching_board is now owned, lock is released
if let Some(ref board) = matching_board {
log_info!(
"custom_image",
"Detected board: {} (slug: {})",
board.name,
board.slug
);
} else {
log_info!(
"custom_image",
"Board not found in database: {}",
normalized_slug
);
}
log_info!("custom_image", "Board detection completed successfully");
Ok(matching_board)
}

View File

@@ -7,6 +7,7 @@ pub mod custom_image;
pub mod operations;
pub mod progress;
pub mod scraping;
pub mod settings;
mod state;
pub mod system;
pub mod update;

View File

@@ -3,13 +3,13 @@
//! Handles download and flash operations.
use std::path::PathBuf;
use tauri::State;
use tauri::{AppHandle, State};
use crate::config;
use crate::download::download_image as do_download;
use crate::flash::{flash_image as do_flash, request_authorization};
use crate::utils::get_cache_dir;
use crate::{log_error, log_info};
use crate::{log_debug, log_error, log_info};
use super::state::AppState;
@@ -57,10 +57,16 @@ pub async fn download_image(
state: State<'_, AppState>,
) -> Result<String, String> {
log_info!("operations", "Starting download: {}", file_url);
log_debug!(
"operations",
"Download directory: {:?}",
get_cache_dir(config::app::NAME).join("images")
);
if let Some(ref sha) = file_url_sha {
log_info!("operations", "SHA URL: {}", sha);
} else {
log_info!("operations", "No SHA URL provided");
log_debug!("operations", "SHA verification will be skipped");
}
let download_dir = get_cache_dir(config::app::NAME).join("images");
@@ -92,6 +98,7 @@ pub async fn flash_image(
device_path: String,
verify: bool,
state: State<'_, AppState>,
_app: AppHandle,
) -> Result<(), String> {
log_info!(
"operations",
@@ -100,15 +107,32 @@ pub async fn flash_image(
device_path,
verify
);
log_debug!(
"operations",
"Image path exists: {}",
std::path::Path::new(&image_path).exists()
);
log_debug!(
"operations",
"Device path exists: {}",
std::path::Path::new(&device_path).exists()
);
log_debug!("operations", "Verification enabled: {}", verify);
let path = PathBuf::from(&image_path);
let flash_state = state.flash_state.clone();
let result = do_flash(&path, &device_path, flash_state, verify).await;
if let Err(ref e) = result {
log_error!("operations", "Flash failed: {}", e);
} else {
log_info!("operations", "Flash completed successfully");
match &result {
Ok(_) => {
log_info!("operations", "Flash completed successfully");
}
Err(e) => {
log_error!("operations", "Flash failed: {}", e);
}
}
result
}

View File

@@ -0,0 +1,302 @@
//! Settings persistence commands using Tauri Store plugin
//!
//! Manages user preferences like theme and language using the Tauri Store plugin.
use crate::log_info;
use tauri_plugin_store::StoreExt;
const MODULE: &str = "commands::settings";
const SETTINGS_STORE: &str = "settings.json";
const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024; // 5MB
const MAX_LOG_LINES: usize = 10_000;
/// Default values for settings
fn default_theme() -> String {
"auto".to_string()
}
fn default_language() -> String {
"auto".to_string()
}
fn default_show_motd() -> bool {
true
}
fn default_show_updater_modal() -> bool {
true
}
fn default_developer_mode() -> bool {
false
}
/// Get the current theme preference
#[tauri::command]
pub fn get_theme(app: tauri::AppHandle) -> String {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("theme") {
Some(value) => value.as_str().unwrap_or("auto").to_string(),
None => {
log_info!(MODULE, "Theme not found in store, using default");
default_theme()
}
},
Err(e) => {
log_info!(MODULE, "Error loading store, using default theme: {}", e);
default_theme()
}
}
}
/// Set the theme preference
#[tauri::command]
pub fn set_theme(theme: String, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting theme to: {}", theme);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("theme", theme);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the current language preference
#[tauri::command]
pub fn get_language(app: tauri::AppHandle) -> String {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("language") {
Some(value) => value.as_str().unwrap_or("auto").to_string(),
None => {
log_info!(MODULE, "Language not found in store, using default");
default_language()
}
},
Err(e) => {
log_info!(MODULE, "Error loading store, using default language: {}", e);
default_language()
}
}
}
/// Set the language preference
#[tauri::command]
pub fn set_language(language: String, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting language to: {}", language);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("language", language);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the MOTD visibility preference
#[tauri::command]
pub fn get_show_motd(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("show_motd") {
Some(value) => value.as_bool().unwrap_or(true),
None => {
log_info!(MODULE, "show_motd not found in store, using default");
default_show_motd()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default show_motd: {}",
e
);
default_show_motd()
}
}
}
/// Set the MOTD visibility preference
#[tauri::command]
pub fn set_show_motd(show: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting show_motd to: {}", show);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("show_motd", show);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// System information structure
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct SystemInfo {
pub platform: String,
pub arch: String,
}
/// Get the real system platform and architecture
#[tauri::command]
pub fn get_system_info() -> SystemInfo {
let platform = std::env::consts::OS.to_string();
let arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "ARM64",
"x86" => "x86",
"arm" => "ARM",
_ => std::env::consts::ARCH,
}
.to_string();
SystemInfo { platform, arch }
}
/// Get the Tauri version
///
/// Returns the Tauri framework version as a compile-time constant.
/// The version is extracted from Cargo.toml during build time via build.rs.
#[tauri::command]
pub fn get_tauri_version() -> String {
env!("TAURI_VERSION").to_string()
}
/// Get the updater modal visibility preference
#[tauri::command]
pub fn get_show_updater_modal(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("show_updater_modal") {
Some(value) => value.as_bool().unwrap_or(true),
None => {
log_info!(
MODULE,
"show_updater_modal not found in store, using default"
);
default_show_updater_modal()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default show_updater_modal: {}",
e
);
default_show_updater_modal()
}
}
}
/// Set the updater modal visibility preference
#[tauri::command]
pub fn set_show_updater_modal(show: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting show_updater_modal to: {}", show);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("show_updater_modal", show);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Get the developer mode preference
#[tauri::command]
pub fn get_developer_mode(app: tauri::AppHandle) -> bool {
match app.store(SETTINGS_STORE) {
Ok(store) => match store.get("developer_mode") {
Some(value) => value.as_bool().unwrap_or_else(default_developer_mode),
None => {
log_info!(MODULE, "developer_mode not found in store, using default");
default_developer_mode()
}
},
Err(e) => {
log_info!(
MODULE,
"Error loading store, using default developer_mode: {}",
e
);
default_developer_mode()
}
}
}
/// Set the developer mode preference
#[tauri::command]
pub fn set_developer_mode(enabled: bool, app: tauri::AppHandle) -> Result<(), String> {
log_info!(MODULE, "Setting developer_mode to: {}", enabled);
// Update the log level based on developer mode
crate::logging::set_log_level(enabled);
match app.store(SETTINGS_STORE) {
Ok(store) => {
store.set("developer_mode", enabled);
Ok(())
}
Err(e) => Err(format!("Failed to access store: {}", e)),
}
}
/// Read only the last N lines from a file to avoid loading large files into memory
///
/// This function is optimized for large log files by reading line-by-line
/// and only keeping the last N lines in memory.
fn read_last_lines(path: &std::path::PathBuf, lines: usize) -> Result<String, String> {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open log file: {}", e))?;
let reader = BufReader::new(file);
let all_lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
let start = if all_lines.len() > lines {
all_lines.len() - lines
} else {
0
};
Ok(all_lines[start..].join("\n"))
}
/// Get the latest log file contents
///
/// For large log files (>5MB), only the last 10,000 lines are returned
/// to avoid memory issues. This prevents the application from consuming
/// excessive memory when viewing logs.
#[tauri::command]
pub fn get_logs() -> Result<String, String> {
use crate::logging;
use std::fs::Metadata;
match logging::get_current_log_path() {
Some(log_path) => {
if !log_path.exists() {
return Ok("No log file found".to_string());
}
// Get file metadata to check size
let metadata: Metadata = std::fs::metadata(&log_path)
.map_err(|e| format!("Failed to read log file metadata: {}", e))?;
// For large files, use optimized line reader
if metadata.len() > MAX_LOG_SIZE {
log_info!(
MODULE,
"Log file is large ({} bytes), reading last {} lines",
metadata.len(),
MAX_LOG_LINES
);
return read_last_lines(&log_path, MAX_LOG_LINES);
}
// For small files, read entire contents
std::fs::read_to_string(&log_path)
.map_err(|e| format!("Failed to read log file: {}", e))
}
None => Ok("No log file available".to_string()),
}
}

Some files were not shown because too many files have changed in this diff Show More