Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.3",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] }
# - Last checked: 2025-01-21
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"dialog:default",
"updater:default",
"process:allow-restart",
"store:default"
"store:default",
"fs:default",
"fs:allow-write-text-file",
"dialog:allow-save"
]
}
95 changes: 95 additions & 0 deletions src-tauri/src/commands/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,98 @@ pub async fn cleanup_failed_download(state: State<'_, AppState>) -> Result<(), S
crate::download::cleanup_pending_download(state.download_state.clone()).await;
Ok(())
}

/// Inject the autoconfig payload dynamically onto the newly flashed Linux filesystem
#[tauri::command]
pub async fn inject_autoconfig(device_path: String, payload: String) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
use std::process::Command;
use std::fs::{self, File};
use std::io::Write;
use std::thread;
use std::time::Duration;

log_info!("operations", "Injecting armbian-firstlogin.conf to block device: {}", device_path);

// 1. Force the kernel to re-read the partition table
log_info!("operations", "Running partprobe to refresh partitions on {}", device_path);
let _ = Command::new("partprobe").arg(&device_path).status();

// Brief sleep to allow dev handler to surface the partition block node
thread::sleep(Duration::from_secs(3));

// 2. Identify the boot partition
// On SD cards/eMMC (mmcblkX) or NVMe (nvmeXn1), partition 1 usually adds 'p1'.
// On USB/SATA (sdX), partition 1 usually adds '1'.
let part_suffix = if device_path.contains("mmcblk") || device_path.contains("nvme") {
"p1"
} else {
"1"
};
let part_path = format!("{}{}", device_path, part_suffix);
log_info!("operations", "Targeting partition: {}", part_path);

// 3. Setup temporary mount directory
let mount_dir = "/tmp/armbian-config-mount";
let _ = fs::create_dir_all(mount_dir);

// 4. Mount partition
log_info!("operations", "Mounting {} to {}", part_path, mount_dir);
let mount_status = Command::new("mount")
.arg(&part_path)
.arg(mount_dir)
.status()
.map_err(|e| format!("Failed to execute mount: {}", e))?;

if !mount_status.success() {
log_error!("operations", "Failed to mount partition {}", part_path);
return Err("Failed to mount target ext4 partition".to_string());
}

// 5. Determine target path and write configuration
// Armbian traditionally checks /boot/armbian-firstlogin.conf, or fallback to /armbian-firstlogin.conf
let boot_dir = format!("{}/boot", mount_dir);
let target_file_path = if std::path::Path::new(&boot_dir).exists() {
format!("{}/armbian-firstlogin.conf", boot_dir)
} else {
format!("{}/armbian-firstlogin.conf", mount_dir)
};

log_info!("operations", "Writing configuration payload to {}", target_file_path);

let write_result = (|| -> std::io::Result<()> {
let mut file = File::create(&target_file_path)?;
file.write_all(payload.as_bytes())?;
file.sync_all()?;
Ok(())
})();

if let Err(e) = write_result {
log_error!("operations", "Failed to write payload: {}", e);
} else {
log_info!("operations", "Successfully wrote autoconfig payload.");
}

// 6. Cleanup: Unmount directory
log_info!("operations", "Unmounting {}", mount_dir);
let umount_status = Command::new("umount")
.arg(mount_dir)
.status()
.map_err(|e| format!("Failed to execute umount: {}", e))?;

if !umount_status.success() {
log_error!("operations", "Warning: Failed to gracefully unmount {}", mount_dir);
}

let _ = fs::remove_dir(mount_dir); // Attempt simple cleanup

Ok(())
}

#[cfg(not(target_os = "linux"))]
{
// On non-Linux, this should not be called dynamically as the frontend catches it.
Err("Autoconfig dynamic injection is only supported on Linux native hosts.".to_string())
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ fn main() {

let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_store::Builder::new().build());
Expand Down Expand Up @@ -163,6 +164,7 @@ fn main() {
commands::operations::force_delete_cached_image,
commands::operations::continue_download_without_sha,
commands::operations::cleanup_failed_download,
commands::operations::inject_autoconfig,
commands::progress::cancel_operation,
commands::progress::get_download_progress,
commands::progress::get_flash_progress,
Expand Down
6 changes: 6 additions & 0 deletions src/components/flash/FlashStageIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Archive,
Shield,
ShieldCheck,
Settings,
} from 'lucide-react';
import { UI } from '../../config';

Expand All @@ -17,6 +18,7 @@ export type FlashStage =
| 'decompressing'
| 'flashing'
| 'verifying'
| 'configuring'
| 'complete'
| 'error';

Expand All @@ -39,6 +41,8 @@ export function FlashStageIcon({ stage, size = UI.ICON_SIZE.FLASH_STAGE }: Flash
return <HardDrive size={size} className="stage-icon flashing" />;
case 'verifying':
return <Check size={size} className="stage-icon verifying" />;
case 'configuring':
return <Settings size={size} className="stage-icon configuring" />;
case 'complete':
return <CheckCircle size={size} className="stage-icon complete" />;
case 'error':
Expand All @@ -61,6 +65,8 @@ export function getStageKey(stage: FlashStage): string {
return 'flash.writing';
case 'verifying':
return 'flash.verifying';
case 'configuring':
return 'flash.configuring';
case 'complete':
return 'flash.complete';
case 'error':
Expand Down
13 changes: 11 additions & 2 deletions src/components/layout/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Factory, Cpu, Database, HardDrive, FolderOpen, Archive } from 'lucide-r
import { useTranslation } from 'react-i18next';
import type { BoardInfo, ImageInfo, BlockDevice, Manufacturer } from '../../types';
import { MarqueeText } from '../shared';
import { AutoconfigButton } from '../settings/AutoconfigButton';

interface HomePageProps {
selectedManufacturer: Manufacturer | null;
Expand Down Expand Up @@ -63,6 +64,10 @@ export function HomePage({
</button>
</div>

<div className="home-button-group" style={{ display: 'flex', justifyContent: 'center', marginTop: '10px' }}>
<AutoconfigButton />
</div>

<div className="home-button-group">
<button
className="home-button"
Expand Down Expand Up @@ -187,7 +192,7 @@ export function HomePage({
</div>
</div>

<div className="home-custom-section">
<div className="home-custom-section" style={{ display: 'flex', gap: '15px', justifyContent: 'center' }}>
<button
className="home-custom-button"
onClick={selectedImage.image_variant === 'cached' ? onOpenCacheManager : onChooseCustomImage}
Expand All @@ -197,6 +202,8 @@ export function HomePage({
? t('home.changeCachedImage')
: t('home.changeCustomImage')}
</button>

<AutoconfigButton />
</div>
</div>
);
Expand Down Expand Up @@ -287,14 +294,16 @@ export function HomePage({
</div>

{!selectedManufacturer && (
<div className="home-custom-section">
<div className="home-custom-section" style={{ display: 'flex', gap: '15px', justifyContent: 'center' }}>
<button
className="home-custom-button"
onClick={onChooseCustomImage}
>
<FolderOpen size={16} />
{t('home.useCustomImage')}
</button>

<AutoconfigButton />
</div>
)}
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/components/settings/AutoconfigButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { Settings2 } from 'lucide-react';
import { AutoconfigModal } from './AutoconfigModal';
import { useModalExitAnimation } from '../../hooks/useModalExitAnimation';
import { EVENTS } from '../../config';
import { getAutoconfigEnabled } from '../../hooks/useAutoconfig';

export function AutoconfigButton() {
const [isOpen, setIsOpen] = useState(false);
const [openCount, setOpenCount] = useState(0);
const [isEnabled, setIsEnabled] = useState(false);

useEffect(() => {
// Refresh enabled state dynamically or on open/close
getAutoconfigEnabled().then(setIsEnabled);
}, [isOpen]);

useEffect(() => {
const handler = () => setIsOpen(false);
window.addEventListener(EVENTS.CACHE_IMAGE_REUSE, handler);
return () => window.removeEventListener(EVENTS.CACHE_IMAGE_REUSE, handler);
}, []);

const { isExiting, handleClose } = useModalExitAnimation({
onClose: () => setIsOpen(false),
duration: 200,
});

const handleOpen = () => {
setOpenCount((c) => c + 1);
setIsOpen(true);
};

return (
<>
<button
className="home-custom-button"
onClick={handleOpen}
title="OS Customization (Optional)"
>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Settings2 size={16} />
{isEnabled && (
<span style={{
position: 'absolute', top: '-4px', right: '-4px',
width: '6px', height: '6px', borderRadius: '50%',
backgroundColor: 'var(--color-success)', border: '1px solid var(--bg-primary)'
}} />
)}
</div>
OS Customization (Optional)
</button>

<AutoconfigModal
key={openCount}
isOpen={isOpen && !isExiting}
onClose={handleClose}
/>
</>
);
}
Loading