10 Commits
dev ... test

Author SHA1 Message Date
Igor Pecovnik
009a36fac3 Generate simple website with download autodetection
Todo: styling
2026-01-03 23:35:31 +01:00
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
74 changed files with 4747 additions and 552 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

44
.github/workflows/pages-test.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Deploy test branch to GitHub Pages
on:
push:
branches: [ test ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: test
- name: Fetch Latest Release
id: fetch_release
run: |
# Fetch the release JSON from the Armbian repo
curl -s https://api.github.com/repos/armbian/imager/releases/latest > latest.json
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: .
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

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 |

View File

@@ -1,13 +1,427 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/armbian-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Armbian Imager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Armbian Imager - Download</title>
<style>
/* --- CSS Reset & Base Variables --- */
:root {
--primary: #f98d05; /* Armbian Orange */
--primary-hover: #d67d04;
--dark-bg: #1a1a1a;
--card-bg: #ffffff;
--text-main: #333333;
--text-light: #666666;
--border-radius: 8px;
--font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background-color: var(--dark-bg);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* --- Header --- */
header {
padding: 20px 40px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo { font-weight: 700; font-size: 24px; color: white; text-decoration: none; display: flex; align-items: center; gap: 10px; }
.logo span { color: var(--primary); }
.nav-links a { color: #ccc; text-decoration: none; margin-left: 20px; font-size: 14px; transition: 0.2s; }
.nav-links a:hover { color: white; }
/* --- Hero Section --- */
.hero {
background: linear-gradient(135deg, #2b2b2b 0%, #1a1a1a 100%);
padding: 60px 20px;
text-align: center;
border-bottom: 1px solid #333;
}
.hero h1 { font-size: 3rem; color: white; margin-bottom: 15px; font-weight: 700; }
.hero p { font-size: 1.2rem; color: #aaa; max-width: 600px; margin: 0 auto; }
/* --- Main Download Card --- */
.download-section {
flex: 1;
display: flex;
justify-content: center;
padding: 40px 20px;
}
.card {
background: var(--card-bg);
width: 100%;
max-width: 800px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
overflow: hidden;
}
/* 1. Auto Download Area */
.auto-download {
padding: 40px;
background: #f9f9f9;
text-align: center;
border-bottom: 1px solid #eee;
}
.auto-label { text-transform: uppercase; letter-spacing: 1px; font-size: 12px; color: var(--text-light); margin-bottom: 15px; display: block; }
.main-btn {
display: inline-block;
background-color: var(--primary);
color: white;
text-decoration: none;
padding: 18px 40px;
font-size: 20px;
font-weight: 700;
border-radius: 50px; /* Pill shape */
box-shadow: 0 4px 15px rgba(249, 141, 5, 0.4);
transition: transform 0.2s, background-color 0.2s;
cursor: pointer;
pointer-events: none; /* Disabled until loaded */
opacity: 0.8;
min-width: 300px;
}
.main-btn:hover { background-color: var(--primary-hover); transform: translateY(-2px); }
.main-btn.ready { pointer-events: auto; opacity: 1; }
.main-btn .sub { display: block; font-size: 14px; font-weight: 400; opacity: 0.9; margin-top: 4px; }
.detected-info { margin-top: 15px; font-size: 14px; color: var(--text-light); }
/* 2. Manual Tabs Area */
.manual-download { padding: 30px; min-height: 200px; }
.tabs-header { display: flex; justify-content: center; border-bottom: 2px solid #eee; margin-bottom: 20px; }
.tab-btn {
background: none; border: none; padding: 10px 20px;
font-size: 16px; font-weight: 600; color: var(--text-light);
cursor: pointer; border-bottom: 3px solid transparent; transition: 0.2s;
}
.tab-btn:hover { color: var(--primary); }
.tab-btn.active { border-bottom-color: var(--primary); color: var(--text-main); }
.tab-content { display: none; }
.tab-content.active { display: block; animation: fadeIn 0.3s; }
.file-list { list-style: none; }
.file-item {
display: flex; justify-content: space-between; align-items: center;
padding: 12px; border-bottom: 1px solid #f0f0f0; transition: 0.2s;
}
.file-item:hover { background-color: #fafafa; }
.file-name { font-weight: 500; color: #333; display: flex; align-items: center; gap: 10px; }
.file-meta { font-size: 13px; color: #888; text-align: right; }
.download-link {
color: var(--primary); text-decoration: none; font-weight: 600; margin-left: 15px;
border: 1px solid var(--primary); padding: 5px 12px; border-radius: 4px;
font-size: 12px;
}
.download-link:hover { background-color: var(--primary); color: white; }
.empty-msg { text-align: center; color: #999; font-style: italic; padding: 20px; }
/* Loading Spinner */
.spinner { border: 3px solid #f3f3f3; border-top: 3px solid var(--primary); border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block; margin-bottom: 5px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* Footer */
footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<header>
<a href="#" class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
<span>Armbian</span> Imager
</a>
<nav class="nav-links">
<a href="#">Docs</a>
<a href="#">Source</a>
<a href="#">Community</a>
</nav>
</header>
<div class="hero">
<h1>Flash OS images easily</h1>
<p>The ultimate tool for creating bootable USB drives and SD cards for Armbian.</p>
</div>
<div class="download-section">
<div class="card">
<!-- Automatic Section -->
<div class="auto-download">
<span class="auto-label">Recommended for your system</span>
<br>
<a id="main-btn" href="#" class="main-btn">
<span class="spinner"></span> Detecting...
</a>
<div id="detected-info" class="detected-info"></div>
</div>
<!-- Manual Section -->
<div class="manual-download">
<div class="tabs-header">
<button class="tab-btn active" onclick="switchTab('windows')">Windows</button>
<button class="tab-btn" onclick="switchTab('mac')">macOS</button>
<button class="tab-btn" onclick="switchTab('linux')">Linux</button>
</div>
<div id="tab-windows" class="tab-content active">
<ul class="file-list" id="list-windows"></ul>
</div>
<div id="tab-mac" class="tab-content">
<ul class="file-list" id="list-mac"></ul>
</div>
<div id="tab-linux" class="tab-content">
<ul class="file-list" id="list-linux"></ul>
</div>
</div>
</div>
</div>
<footer>
&copy; 2024 Armbian. Open Source software.
</footer>
<script>
//const REPO_OWNER = "armbian";
//const REPO_NAME = "imager";
//const API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
// This points to the file generated by the GitHub Action
const API_URL = `./latest.json`;
const mainBtn = document.getElementById('main-btn');
const infoDiv = document.getElementById('detected-info');
// --- 1. INIT ---
window.addEventListener('DOMContentLoaded', async () => {
try {
// Fetch Data
const response = await fetch(API_URL);
if (!response.ok) throw new Error("API Limit Reached");
const release = await response.json();
// Detect Client
const { os, arch } = await getClientInfo();
// Render Auto Button
renderAutoButton(release, os, arch);
// Render Manual Lists (Updated Logic)
renderManualLists(release);
} catch (err) {
console.error(err);
mainBtn.innerHTML = "Manual Download";
mainBtn.href = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
mainBtn.classList.add('ready');
infoDiv.innerText = "Could not auto-detect. Please select manually below.";
}
});
// --- 2. RENDER FUNCTIONS ---
function renderAutoButton(release, os, arch) {
const asset = findBestAsset(release.assets, os, arch);
const tagName = release.tag_name;
if (asset) {
mainBtn.href = asset.browser_download_url;
mainBtn.setAttribute('download', asset.name);
const osDisplay = os.charAt(0).toUpperCase() + os.slice(1);
mainBtn.innerHTML = `Download for ${osDisplay}`;
const sub = document.createElement('span');
sub.className = 'sub';
sub.innerText = `${tagName} • ${formatBytes(asset.size)}`;
mainBtn.appendChild(sub);
mainBtn.classList.add('ready');
infoDiv.innerText = `Detected Architecture: ${arch.toUpperCase()}`;
} else {
mainBtn.innerHTML = "Download Latest";
mainBtn.href = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
mainBtn.classList.add('ready');
infoDiv.innerText = `We couldn't find a specific file for ${os} (${arch}). Check the tabs below.`;
}
}
function renderManualLists(release) {
const assets = release.assets;
// Helper to add "No files found" message if empty
const checkEmpty = (id) => {
const el = document.getElementById(id);
if(el.children.length === 0) {
el.innerHTML = `<li class="empty-msg">No files found for this OS in this release.</li>`;
}
};
// Clear lists
document.getElementById('list-windows').innerHTML = '';
document.getElementById('list-mac').innerHTML = '';
document.getElementById('list-linux').innerHTML = '';
let processedCount = 0;
assets.forEach(asset => {
const name = asset.name;
const lowerName = name.toLowerCase();
// Filter out non-binary files
if (name.endsWith('.txt') || name.endsWith('.json') || name.endsWith('.sha256') || name.endsWith('.md5')) return;
let targetListId = null;
// --- IMPROVED FILTERING LOGIC ---
// 1. Windows Check
if (lowerName.includes('win') || name.endsWith('.exe')) {
targetListId = 'list-windows';
}
// 2. macOS Check
else if (lowerName.includes('mac') || lowerName.includes('darwin') || name.endsWith('.dmg')) {
targetListId = 'list-mac';
}
// 3. Linux Check (Specific formats)
else if (name.endsWith('.AppImage') || name.endsWith('.deb') || name.endsWith('.rpm')) {
targetListId = 'list-linux';
}
// 4. Ambiguous Files (.zip, .tar, etc.)
else if (name.endsWith('.zip')) {
// For zips, we rely heavily on naming
if (lowerName.includes('win')) targetListId = 'list-windows';
else if (lowerName.includes('mac')) targetListId = 'list-mac';
else if (lowerName.includes('linux') || lowerName.includes('arm64') || lowerName.includes('amd64')) {
// Assuming zips with arch keywords are Linux unless proven otherwise
targetListId = 'list-linux';
}
}
// If we found a target, render it
if (targetListId) {
processedCount++;
const li = document.createElement('li');
li.className = 'file-item';
li.innerHTML = `
<div class="file-name">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
${name}
</div>
<div class="file-meta">
${formatBytes(asset.size)}
<a href="${asset.browser_download_url}" class="download-link">Download</a>
</div>
`;
document.getElementById(targetListId).appendChild(li);
}
});
console.log(`Processed ${processedCount} assets out of ${assets.length} total.`);
// Final check for empty tabs
checkEmpty('list-windows');
checkEmpty('list-mac');
checkEmpty('list-linux');
}
// --- 3. LOGIC HELPERS ---
function switchTab(os) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById(`tab-${os}`).classList.add('active');
}
async function getClientInfo() {
if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
const uaData = await navigator.userAgentData.getHighEntropyValues(['architecture', 'platform']);
return {
os: normalizeOS(uaData.platform),
arch: normalizeArch(uaData.architecture)
};
}
const ua = navigator.userAgent;
let os = "unknown", arch = "unknown";
if (ua.indexOf("Win") !== -1) os = "windows";
else if (ua.indexOf("Mac") !== -1) os = "mac";
else if (ua.indexOf("Linux") !== -1) os = "linux";
if (ua.indexOf("arm") !== -1 || ua.indexOf("aarch64") !== -1) arch = "arm64";
else if (ua.indexOf("x86_64") !== -1 || ua.indexOf("x64") !== -1) arch = "x64";
else if (os === "mac") arch = "arm64";
else arch = "x64";
return { os, arch };
}
function normalizeOS(platform) {
if (platform === "Windows") return "windows";
if (platform === "macOS") return "mac";
if (platform === "Linux") return "linux";
return platform.toLowerCase();
}
function normalizeArch(arch) {
if (arch === "arm" || arch === "arm64" || arch === "aarch64") return "arm64";
if (arch === "x86" || arch === "x86_64" || arch === "x64") return "x64";
return "unknown";
}
function findBestAsset(assets, os, arch) {
const compatible = assets.filter(a => {
const name = a.name.toLowerCase();
if (os === 'windows') {
if (arch === 'x64') return name.includes('win') && name.includes('x64');
}
if (os === 'mac') {
if (arch === 'arm64') return name.includes('macos') && (name.includes('arm64') || name.includes('aarch64'));
if (arch === 'x64') return name.includes('macos') && name.includes('x64');
}
if (os === 'linux') {
if (name.includes('win') || name.includes('macos') || name.includes('darwin')) return false;
if (arch === 'x64') return name.includes('amd64') || name.includes('x86_64') || name.includes('x64');
if (arch === 'arm64') return name.includes('arm64') || name.includes('aarch64');
}
return false;
});
// Prefer AppImage
compatible.sort((a, b) => {
const nameA = a.name.toLowerCase();
const nameB = b.name.toLowerCase();
if (nameA.includes('appimage') && !nameB.includes('appimage')) return -1;
return 0;
});
return compatible[0] || null;
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
</script>
</body>
</html>

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;

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