diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml index a29d894..d1cc834 100644 --- a/.github/workflows/build-all.yml +++ b/.github/workflows/build-all.yml @@ -47,6 +47,7 @@ jobs: echo "Triggered builds for:" >> $GITHUB_STEP_SUMMARY echo "- Windows" >> $GITHUB_STEP_SUMMARY echo "- Linux (x64 + ARM64)" >> $GITHUB_STEP_SUMMARY + echo "- Linux 32-bit (i686 + ARMv7)" >> $GITHUB_STEP_SUMMARY echo "- macOS (Universal)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Check the [Actions tab](/${{ github.repository }}/actions) for build progress." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 3e902b8..bc76795 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -6,11 +6,6 @@ on: - main pull_request: {} -permissions: - attestations: write - contents: read - id-token: write - env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index b8585c2..f84bc56 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -6,11 +6,6 @@ on: - main pull_request: {} -permissions: - attestations: write - contents: read - id-token: write - env: CARGO_TERM_COLOR: always @@ -39,16 +34,16 @@ jobs: mkdir -p AppIcon.iconset # Convert PNG to various sizes required for macOS icon - sips -z 16 16 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_16x16.png" - sips -z 32 32 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_16x16@2x.png" - sips -z 32 32 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_32x32.png" - sips -z 64 64 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_32x32@2x.png" - sips -z 128 128 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_128x128.png" - sips -z 256 256 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_128x128@2x.png" - sips -z 256 256 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_256x256.png" - sips -z 512 512 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_256x256@2x.png" - sips -z 512 512 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_512x512.png" - sips -z 1024 1024 "assets/Icons/icon-macOS-Dark-1024x1024@1x.png" --out "AppIcon.iconset/icon_512x512@2x.png" + sips -z 16 16 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_16x16.png" + sips -z 32 32 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_16x16@2x.png" + sips -z 32 32 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_32x32.png" + sips -z 64 64 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_32x32@2x.png" + sips -z 128 128 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_128x128.png" + sips -z 256 256 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_128x128@2x.png" + sips -z 256 256 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_256x256.png" + sips -z 512 512 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_256x256@2x.png" + sips -z 512 512 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_512x512.png" + sips -z 1024 1024 "assets/Icons/icon.png" --out "AppIcon.iconset/icon_512x512@2x.png" # Convert iconset to icns iconutil -c icns AppIcon.iconset -o AppIcon.icns diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index c4cfc7b..5d7b009 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -6,11 +6,6 @@ on: - main pull_request: {} -permissions: - attestations: write - contents: read - id-token: write - env: CARGO_TERM_COLOR: always diff --git a/Cargo.toml b/Cargo.toml index 9485764..d6c3b89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,8 @@ name = "nextui-installer" version = "1.0.0" edition = "2021" -description = "NextUI SD Card Installer" -authors = ["SpruceOS Team", "NextUI Team"] +description = "NextUI SD Card Setup" +authors = ["spruceOS Team", "NextUI Team"] license = "GPL-3.0-or-later" [features] @@ -44,3 +44,4 @@ embed-resource = "2" opt-level = "z" lto = true strip = "debuginfo" + diff --git a/README.md b/README.md index 4d1c166..8a3cb42 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ ---- - -## To-Do - -- ~~x64 Linux hangs for a sec before ejecting safely, possible bug, it does eject though.~~ idk if this is really fixed it only happens sometimes for me? - ---- - - # NextUI Installer ## Overview @@ -18,7 +9,7 @@ It can be easily edited and adapted to work with **any custom firmware (CFW)** t GitHub Actions are set up to automatically **build and create releases per branch**. If you’d like to use this program for your own project, let us know—we can create a branch for you or add you directly to the repository. -> **Please do not remove the Spruce team from the authors section.** +> **Please do not remove the Spruce or NextUI teams from the authors section.** > Instead, add your name alongside the existing credits. @@ -65,46 +56,51 @@ Edit these constants: |-------|---------|---------| | `APP_NAME` | Display name of your OS (window title, UI) | `"NextUI"` | | `VOLUME_LABEL` | FAT32 SD card label (max 11 chars, uppercase) | `"NEXTUI"` | -| `REPO_OPTIONS` | Array of repositories to fetch | `[("NextUI stable", "LoveRetro/NextUI")]` | -| `DEFAULT_REPO_INDEX` | Index of the default repo selection | `0` | +| `REPO_OPTIONS` | Array of repositories to fetch releases from | `[("Stable", "LoveRetro/NextUI"), ("Nightlies", "LoveRetro/NextUI-nightly")]` | +| `DEFAULT_REPO_INDEX` | Index of the default repo selection (0 = first) | `0` | | `ASSET_EXTENSION` | File extension to download from releases | `".7z"` or `".zip"` | +| `WINDOW_SIZE` | Default window size (width, height) | `(679.5, 420.0)` | +| `WINDOW_MIN_SIZE` | Minimum window size (width, height) | `(679.5, 420.0)` | -> **Notes:** +> **Notes:** > - `WINDOW_TITLE`, `USER_AGENT`, and `TEMP_PREFIX` are auto-generated from `APP_NAME`. You usually **do not need to change these**. +> - The `setup_theme()` function in `config.rs` uses the Gruvbox Dark preset. This is a fallback; the actual theme is customized in `app.rs`. --- -### 2. Theme Colors +### 2. `src/app.rs` — Theme Colors & UI Customization -Customize the installer’s look and feel via RGB colors in `config.rs`: +The installer's visual theme is defined in the `get_theme_config()` method (around line 136 in `app.rs`). This method returns a `ThemeConfig` with color overrides in **RGBA format** `[R, G, B, A]` (values 0-255). -| Constant | Usage | -|----------|-------| -| `COLOR_BG_DARK` | Main window background | -| `COLOR_BG_MEDIUM` | Panels, input fields | -| `COLOR_BG_LIGHT` | Buttons and interactive elements | -| `COLOR_ACCENT` | Primary accents (headings, highlights, progress bars) | -| `COLOR_ACCENT_DIM` | Hover states, selections | -| `COLOR_TEXT` | Primary text | -| `COLOR_TEXT_DIM` | Secondary labels/text | -| `COLOR_SUCCESS` | Success messages | -| `COLOR_ERROR` | Error messages | -| `COLOR_WARNING` | Warning/destructive alerts | +**Key color fields to customize:** -The theme is applied automatically via `setup_theme(ctx)` in the internal theme setup. +| Field | Purpose | NextUI Default (RGBA) | +|-------|---------|------------------------| +| `override_text_color` | Primary text color | `[251, 241, 199, 255]` (cream) | +| `override_weak_text_color` | Secondary/dimmed text | `[124, 111, 100, 255]` (gray) | +| `override_hyperlink_color` | Clickable links | `[131, 165, 152, 255]` (teal) | +| `override_faint_bg_color` | Input fields, panels | `[48, 48, 48, 255]` (dark gray) | +| `override_extreme_bg_color` | Window background | `[29, 32, 33, 255]` (near black) | +| `override_warn_fg_color` | Warning messages | `[214, 93, 14, 255]` (orange) | +| `override_error_fg_color` | Error messages | `[204, 36, 29, 255]` (red) | +| `override_selection_bg` | Text selection, highlights | `[215, 180, 95, 255]` (gold) | +| `override_widget_inactive_bg_fill` | Inactive buttons | `[215, 180, 95, 255]` (gold) | +| `override_widget_inactive_fg_stroke_color` | Inactive button border | `[104, 157, 106, 255]` (green) | +| `override_widget_hovered_bg_stroke_color` | Hovered button border | `[215, 180, 95, 255]` (gold) | +| `override_widget_active_bg_stroke_color` | Active button border | `[215, 180, 95, 255]` (gold) | ---- +> **Note:** Set a field to `None` to use the default egui value. The theme config has many more fields for fine-grained control — see the full list in the `ThemeConfig` struct. -### 3. Window Settings +**Hardcoded UI colors** (also in `app.rs`): +- Line ~1036, 1097: Success message color `Color32::from_rgb(104, 157, 106)` (green) +- Line ~1397: Install button fill `Color32::from_rgb(104, 157, 106)` (green) +- Line ~1417: Cancel button fill `Color32::from_rgb(251, 73, 52)` (red) -| Constant | Purpose | -|----------|---------| -| `WINDOW_SIZE` | Default window size `(width, height)` | -| `WINDOW_MIN_SIZE` | Minimum window size `(width, height)` | +To change these, search for `Color32::from_rgb` in `app.rs` and update the RGB values. --- -### 4. Icons +### 3. Icons Customize the application icon: @@ -113,12 +109,42 @@ Customize the application icon: | PNG | `assets/Icons/icon.png` | Window, title bar (all platforms) | | ICO | `assets/Icons/icon.ico` | Windows Explorer, taskbar | -> Notes: -> - PNG: Recommended 64x64 or 128x128 with transparency -> - ICO: Multi-resolution preferred (16x16, 32x32, 48x48, 256x256) -> - Update `APP_ICON_PNG` path if needed +> Notes: +> - PNG: Recommended 64x64 or 128x128 with transparency +> - ICO: Multi-resolution preferred (16x16, 32x32, 48x48, 256x256) +> - The icon is loaded via `APP_ICON_PNG` in `config.rs` (requires the `icon` feature enabled) + +--- + +### 4. Custom Font + +The installer uses a custom font for all UI text. To use your own font: -> **Important:** Once your branch is pushed, GitHub Actions will automatically build your branch — no manual compilation is required. +**Replace the font file:** +```bash +# Replace the existing font with your own TTF/OTF file +cp /path/to/your/font.ttf assets/Fonts/nunwen.ttf +``` + +**Update the font configuration in `src/config.rs`:** + +| Constant | Purpose | Default | +|----------|---------|---------| +| `CUSTOM_FONT` | Path to the embedded font file | `"../assets/Fonts/nunwen.ttf"` | +| `CUSTOM_FONT_NAME` | Display name for the font (optional, cosmetic) | `"Nunwen"` | + +**Example:** +```rust +// If you want to use a different filename: +pub const CUSTOM_FONT: &[u8] = include_bytes!("../assets/Fonts/YourFont.ttf"); +pub const CUSTOM_FONT_NAME: &str = "YourFont"; +``` + +> **Notes:** +> - Supports TTF and OTF font formats +> - The font is embedded in the binary, so no external font files are needed at runtime +> - The custom font applies to all UI text (buttons, labels, dropdowns, etc.) +> - To also use the font for monospace text (logs), uncomment the Monospace section in `load_custom_fonts()` --- @@ -134,23 +160,119 @@ To fully rebrand the installer, also update: ## Advanced Notes -- **Internal Identifiers** (`WINDOW_TITLE`, `USER_AGENT`, `TEMP_PREFIX`) are auto-generated; modifying them is optional. -- `setup_theme(ctx)` configures `egui` visuals for all widgets and windows. Editing it is **only recommended for advanced developers**. -- `REPO_OPTIONS` can include multiple repos for stable, nightlies, or forks. +- **Internal Identifiers** (`WINDOW_TITLE`, `USER_AGENT`, `TEMP_PREFIX`) are auto-generated from `APP_NAME`; modifying them is optional. +- `setup_theme(ctx)` in `config.rs` is a fallback that applies the Gruvbox Dark preset. The actual theme used by the installer is defined in `app.rs` via `get_theme_config()`. +- `REPO_OPTIONS` can include multiple repos (e.g., stable, nightlies, forks). The user can select between them via a dropdown in the UI. +- The installer uses `egui` and `egui_thematic` for the UI. The theme can be edited live using the built-in theme editor (press Ctrl+T in the app). +- All color values in `ThemeConfig` use RGBA format `[R, G, B, A]` where each value is 0-255. --- ## Recommended Workflow for Developers -1. Fork or clone the repository. -2. Create a **new branch** for your customizations. -3. Update `APP_NAME`, `VOLUME_LABEL`, and `REPO_OPTIONS`. -4. Adjust theme colors if desired. -5. Replace icons and update `Info.plist` / `Cargo.toml`. -6. Push your branch to GitHub. +### Quick Start (Minimal Customization) + +1. Fork or clone the repository. +2. Create a **new branch** for your customizations (or use an existing branch). +3. Update `APP_NAME`, `VOLUME_LABEL`, and `REPO_OPTIONS` in `src/config.rs` +4. Replace `assets/Icons/icon.png` and `icon.ico` with your branding +5. **(Optional)** Replace `assets/Fonts/nunwen.ttf` with your custom font +6. Update `Cargo.toml` and `assets/Mac/Info.plist` with your project info +7. Push your branch to GitHub. + +> GitHub Actions will automatically build Windows, Linux (x64 + ARM64), and macOS (ARM64 + x64) binaries — **no local build setup required**. + +--- + +### Full Theme Customization (Using the Live Theme Editor) + +The installer includes a **built-in theme editor** that lets you customize colors visually and export the theme config directly. This is much faster than manually editing RGBA values in code. + +#### Step 1: Build and Run the Installer + +First, build the installer locally so you can use the theme editor: + +```bash +# Install Rust if you haven't already +# https://rustup.rs/ + +# Clone and build +git clone https://github.com/LoveRetro/NextUI-Installer.git +cd NextUI-Installer +cargo run +``` + +#### Step 2: Open the Theme Editor + +With the installer running, press **Ctrl+T** to open the theme editor panel. This will open on the right side of the window. + +#### Step 3: Customize Colors Visually + +The theme editor provides: +- **Color pickers** for all theme elements (text, backgrounds, borders, buttons, etc.) +- **Live preview** — changes apply immediately to the UI +- **RGBA sliders** for precise color control +- **Preset themes** you can use as starting points + +Adjust the colors until you're happy with how the installer looks with your branding. + +#### Step 4: Export the Theme Config + +At the bottom of the theme editor panel, there's a **"Copy Theme Config"** button (or similar export option). Click it to copy the complete `ThemeConfig` struct to your clipboard. + +The copied output will look like this: + +```rust +ThemeConfig { + name: "YourTheme".to_string(), + dark_mode: true, + override_text_color: Some([251, 241, 199, 255]), + override_weak_text_color: Some([124, 111, 100, 255]), + // ... all other color overrides +} +``` + +#### Step 5: Paste into Your Code + +1. Open `src/app.rs` and find the `get_theme_config()` method (around line 136) +2. Replace the entire `ThemeConfig { ... }` block with your copied config +3. Update the `name` field to match your project name +4. Save the file + +#### Step 6: Customize Hardcoded UI Colors (Optional) + +Some UI elements use hardcoded colors outside the theme system. Search for `Color32::from_rgb` in `app.rs` to find and update: + +- **Line ~1036, 1097**: Success message green `(104, 157, 106)` +- **Line ~1397**: Install button green `(104, 157, 106)` +- **Line ~1417**: Cancel button red `(251, 73, 52)` + +Replace the RGB values to match your brand colors. + +#### Step 7: Test and Push + +```bash +# Test your changes +cargo run + +# Commit and push to your branch +git add src/app.rs +git commit -m "Update theme colors for [YourProject]" +git push +``` + +GitHub Actions will automatically build your customized installer for all platforms. + +--- + +### Tips for Theme Customization -> GitHub Actions will automatically build your branch and generate artifacts — **no local build setup required**. +- **Start with a preset**: The theme editor includes several presets (Gruvbox, Solarized, etc.). Pick one close to your brand and adjust from there. +- **Test readability**: Make sure text is readable against backgrounds, especially for secondary text colors. +- **Match your brand**: Use your project's official brand colors for accents, buttons, and highlights. +- **Check all states**: Interact with buttons, dropdowns, and inputs to see hover/active/inactive states. +- **Dark mode only**: The installer currently only supports dark themes. Light theme support is not implemented. --- -> **PLEASE:** Keep the original spruceOS authors in `Cargo.toml` and `Info.plist` for credit. Add your name alongside ours. +> **PLEASE:** Keep the original authors in `Cargo.toml` and `Info.plist` for credit. Add your name alongside ours. diff --git a/assets/Fonts/nunwen.ttf b/assets/Fonts/nunwen.ttf new file mode 100644 index 0000000..afa7598 Binary files /dev/null and b/assets/Fonts/nunwen.ttf differ diff --git a/assets/LICENSE.7zip.txt b/assets/LICENSE.7zip.txt new file mode 100644 index 0000000..d0bca8f --- /dev/null +++ b/assets/LICENSE.7zip.txt @@ -0,0 +1,146 @@ + 7-Zip + ~~~~~ + License for use and distribution + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + 7-Zip Copyright (C) 1999-2025 Igor Pavlov. + + The licenses for files are: + + - 7z.dll: + - The "GNU LGPL" as main license for most of the code + - The "GNU LGPL" with "unRAR license restriction" for some code + - The "BSD 3-clause License" for some code + - The "BSD 2-clause License" for some code + - All other files: the "GNU LGPL". + + Redistributions in binary form must reproduce related license information from this file. + + Note: + You can use 7-Zip on any computer, including a computer in a commercial + organization. You don't need to register or pay for 7-Zip. + + +GNU LGPL information +-------------------- + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You can receive a copy of the GNU Lesser General Public License from + http://www.gnu.org/ + + + + +BSD 3-clause License in 7-Zip code +---------------------------------- + + The "BSD 3-clause License" is used for the following code in 7z.dll + 1) LZFSE data decompression. + That code was derived from the code in the "LZFSE compression library" developed by Apple Inc, + that also uses the "BSD 3-clause License". + 2) ZSTD data decompression. + that code was developed using original zstd decoder code as reference code. + The original zstd decoder code was developed by Facebook Inc, + that also uses the "BSD 3-clause License". + + Copyright (c) 2015-2016, Apple Inc. All rights reserved. + Copyright (c) Facebook, Inc. All rights reserved. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 3-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +BSD 2-clause License in 7-Zip code +---------------------------------- + + The "BSD 2-clause License" is used for the XXH64 code in 7-Zip. + + XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet. + + Copyright (c) 2012-2021 Yann Collet. + Copyright (c) 2023-2025 Igor Pavlov. + +Text of the "BSD 2-clause License" +---------------------------------- + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + + + + +unRAR license restriction +------------------------- + +The decompression engine for RAR archives was developed using source +code of unRAR program. +All copyrights to original unRAR code are owned by Alexander Roshal. + +The license for original unRAR code has the following restriction: + + The unRAR sources cannot be used to re-create the RAR compression algorithm, + which is proprietary. Distribution of modified unRAR sources in separate form + or as a part of other software is permitted, provided that it is clearly + stated in the documentation and source comments that the code may + not be used to develop a RAR (WinRAR) compatible archiver. + +-- \ No newline at end of file diff --git a/assets/Linux-aarch64/7zzs b/assets/Linux-aarch64/7zzs new file mode 100644 index 0000000..eca1c24 Binary files /dev/null and b/assets/Linux-aarch64/7zzs differ diff --git a/assets/Linux-armv7/7zzs b/assets/Linux-armv7/7zzs new file mode 100644 index 0000000..10e2004 Binary files /dev/null and b/assets/Linux-armv7/7zzs differ diff --git a/assets/Linux-i686/7zzs b/assets/Linux-i686/7zzs new file mode 100644 index 0000000..be01aeb Binary files /dev/null and b/assets/Linux-i686/7zzs differ diff --git a/assets/Linux/7zzs b/assets/Linux-x86_64/7zzs similarity index 100% rename from assets/Linux/7zzs rename to assets/Linux-x86_64/7zzs diff --git a/preview.png b/preview.png deleted file mode 100644 index 54e850c..0000000 Binary files a/preview.png and /dev/null differ diff --git a/src/app.rs b/src/app.rs index 15c2156..1985573 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use crate::drives::{get_removable_drives, DriveInfo}; use crate::eject::eject_drive; use crate::extract::{extract_7z_with_progress, ExtractProgress}; use crate::format::{format_drive_fat32, FormatProgress}; -use crate::github::{download_asset, find_release_asset, get_latest_release, DownloadProgress, Release}; +use crate::github::{download_asset, find_release_asset, get_latest_release, DownloadProgress}; use eframe::egui; use egui_thematic::{ThemeConfig, ThemeEditorState, render_theme_panel}; use std::path::PathBuf; @@ -46,16 +46,12 @@ pub struct InstallerApp { drives: Vec, selected_drive_idx: Option, selected_repo_idx: usize, - release_info: Option, // Progress tracking state: AppState, progress: Arc>, log_messages: Arc>>, - // Temp file for downloads - temp_download_path: Option, - // Drive that was installed to (for eject) installed_drive: Option, @@ -73,6 +69,57 @@ pub struct InstallerApp { last_system_dark_mode: bool, } +/// Get available disk space for a given path (in bytes) +fn get_available_disk_space(path: &std::path::Path) -> u64 { + #[cfg(target_os = "windows")] + { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + let path_wide: Vec = path.as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + + let mut free_bytes = 0u64; + unsafe { + if GetDiskFreeSpaceExW( + windows::core::PCWSTR(path_wide.as_ptr()), + None, + None, + Some(&mut free_bytes), + ).is_ok() { + return free_bytes; + } + } + crate::debug::log("WARNING: Failed to get disk space on Windows, assuming sufficient space"); + u64::MAX // Assume sufficient space if we can't check + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + use std::os::unix::ffi::OsStrExt; + let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap_or_default(); + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + + unsafe { + if libc::statvfs(path_cstr.as_ptr(), &mut stat) == 0 { + // Available space = block size * available blocks + // Cast both to u64 to handle platforms where they're u32 (macOS, ARM32) + return (stat.f_bavail as u64) * (stat.f_bsize as u64); + } + } + crate::debug::log("WARNING: Failed to get disk space on Unix, assuming sufficient space"); + u64::MAX // Assume sufficient space if we can't check + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + crate::debug::log("WARNING: Disk space check not supported on this platform"); + u64::MAX // Assume sufficient space on unsupported platforms + } +} + impl InstallerApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { // Apply theme from config @@ -111,7 +158,6 @@ impl InstallerApp { drives: Vec::new(), selected_drive_idx: None, selected_repo_idx: DEFAULT_REPO_INDEX, - release_info: None, state: AppState::Idle, progress: Arc::new(Mutex::new(ProgressInfo { current: 0, @@ -119,7 +165,6 @@ impl InstallerApp { message: String::new(), })), log_messages: Arc::new(Mutex::new(Vec::new())), - temp_download_path: None, installed_drive: None, cancel_token: None, drive_rx: rx, @@ -211,6 +256,24 @@ impl InstallerApp { crate::debug::log(&format!("Mount path: {:?}", drive.mount_path)); crate::debug::log(&format!("Repository: {} ({})", repo_name, repo_url)); + // Check if running as root on Linux + #[cfg(target_os = "linux")] + { + if unsafe { libc::geteuid() } == 0 { + crate::debug::log("WARNING: Running as root user"); + if let Ok(sudo_user) = std::env::var("SUDO_USER") { + crate::debug::log(&format!("Detected sudo execution by user: {}", sudo_user)); + self.log("Note: Running with sudo/root privileges"); + } else if let Ok(pkexec_uid) = std::env::var("PKEXEC_UID") { + crate::debug::log(&format!("Detected pkexec execution by UID: {}", pkexec_uid)); + self.log("Note: Running with elevated privileges (pkexec)"); + } else { + crate::debug::log("Running as actual root user (not via sudo/pkexec)"); + self.log("Note: Running as root user"); + } + } + } + let repo_url = repo_url.to_string(); let progress = self.progress.clone(); let log_messages = self.log_messages.clone(); @@ -290,11 +353,90 @@ impl InstallerApp { // Define temp/cache directory for later use // On Linux/macOS, use cache dir to avoid temp space issues // Linux: ~/.cache, macOS: ~/Library/Caches - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "linux")] + let temp_dir = { + // If running as root via sudo or pkexec, try to use the actual user's cache directory + if unsafe { libc::geteuid() } == 0 { + // First check for SUDO_USER (command-line sudo) + if let Ok(sudo_user) = std::env::var("SUDO_USER") { + let user_home = std::path::PathBuf::from(format!("/home/{}", sudo_user)); + if user_home.exists() { + let user_cache = user_home.join(".cache"); + crate::debug::log(&format!("Using cache dir for sudo user {}: {:?}", sudo_user, user_cache)); + user_cache + } else { + crate::debug::log(&format!("User home not found at {:?}, using default", user_home)); + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } + // Check for PKEXEC_UID (GUI elevation via pkexec) + else if let Ok(pkexec_uid) = std::env::var("PKEXEC_UID") { + if let Ok(uid) = pkexec_uid.parse::() { + // Get username from UID using libc + let pwd = unsafe { libc::getpwuid(uid) }; + if !pwd.is_null() { + let username = unsafe { + std::ffi::CStr::from_ptr((*pwd).pw_name) + .to_string_lossy() + .to_string() + }; + let user_home = std::path::PathBuf::from(format!("/home/{}", username)); + if user_home.exists() { + let user_cache = user_home.join(".cache"); + crate::debug::log(&format!("Using cache dir for pkexec user {} (UID {}): {:?}", username, uid, user_cache)); + user_cache + } else { + crate::debug::log(&format!("User home not found at {:?}, using default", user_home)); + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + crate::debug::log(&format!("Failed to get username for UID {}, using default", uid)); + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + crate::debug::log(&format!("Failed to parse PKEXEC_UID '{}', using default", pkexec_uid)); + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } + else { + crate::debug::log("Running as root, using root's cache dir"); + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + }; + #[cfg(target_os = "macos")] let temp_dir = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let temp_dir = std::env::temp_dir(); + crate::debug::log(&format!("Cache/temp directory: {:?}", temp_dir)); + + // Check available disk space before starting + // We need space for: download (asset.size) + extraction (~3x asset.size) + let required_space = asset.size * 4; // 4x for safety margin + let available_space = get_available_disk_space(&temp_dir); + + crate::debug::log(&format!("Required disk space: {} MB", required_space / 1_048_576)); + crate::debug::log(&format!("Available disk space: {} MB", available_space / 1_048_576)); + + if available_space < required_space { + let required_mb = required_space / 1_048_576; + let available_mb = available_space / 1_048_576; + let err_msg = format!( + "Insufficient disk space. Need {} MB, but only {} MB available in cache directory. Please free up disk space and try again.", + required_mb, available_mb + ); + log(&err_msg); + crate::debug::log(&format!("ERROR: {}", err_msg)); + let _ = state_tx_clone.send(AppState::Error); + let _ = drive_poll_tx_clone.send(true); + return; + } + + log(&format!("Disk space check passed: {} MB available", available_space / 1_048_576)); + // Step 2: Format drive (do this first so we fail fast if the card has issues) let _ = state_tx_clone.send(AppState::Formatting); log(&format!("Formatting {}...", drive.name)); @@ -316,9 +458,11 @@ impl InstallerApp { FormatProgress::Unmounting => { p.message = "Unmounting drive...".to_string(); } + #[cfg(not(target_os = "macos"))] FormatProgress::CleaningDisk => { p.message = "Cleaning disk...".to_string(); } + #[cfg(not(target_os = "macos"))] FormatProgress::CreatingPartition => { p.message = "Creating partition...".to_string(); } @@ -401,7 +545,8 @@ impl InstallerApp { // Step 3: Download let _ = state_tx_clone.send(AppState::Downloading); - log("Downloading release..."); + let size_mb = asset.size as f64 / 1_048_576.0; + log(&format!("Downloading release ({:.1} MB)...", size_mb)); crate::debug::log_section("Downloading Release"); let download_path = temp_dir.join(&asset.name); @@ -456,11 +601,15 @@ impl InstallerApp { if let Err(e) = download_asset(&asset_clone, &download_path_clone, dl_tx, cancel_token_clone.clone()).await { if e.contains("cancelled") { log("Download cancelled"); + let _ = tokio::fs::remove_file(&download_path_clone).await; + crate::debug::log("Cleaned up partial download file"); let _ = state_tx_clone.send(AppState::Idle); let _ = drive_poll_tx_clone.send(true); return; } log(&format!("Download error: {}", e)); + let _ = tokio::fs::remove_file(&download_path_clone).await; + crate::debug::log("Cleaned up partial download file"); let _ = state_tx_clone.send(AppState::Error); let _ = drive_poll_tx_clone.send(true); return; @@ -472,8 +621,11 @@ impl InstallerApp { write_card_log("Download complete, starting extraction..."); // Step 4: Extract to temp folder on local PC - // On Linux/macOS, use cache dir to avoid temp space issues - #[cfg(any(target_os = "linux", target_os = "macos"))] + // On Linux, use the same temp_dir we already determined + // On macOS, give cache_dir() another try (original behavior) + #[cfg(target_os = "linux")] + let extract_base_dir = temp_dir.clone(); + #[cfg(target_os = "macos")] let extract_base_dir = dirs::cache_dir().unwrap_or_else(|| temp_dir.clone()); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let extract_base_dir = temp_dir.clone(); @@ -543,6 +695,8 @@ impl InstallerApp { write_card_log("Extraction cancelled"); log("Extraction cancelled"); let _ = std::fs::remove_dir_all(&temp_extract_dir); + let _ = tokio::fs::remove_file(&download_path).await; + crate::debug::log("Cleaned up download file after cancellation"); let _ = state_tx_clone.send(AppState::Idle); let _ = drive_poll_tx_clone.send(true); return; @@ -550,6 +704,8 @@ impl InstallerApp { write_card_log(&format!("Extract error: {}", e)); log(&format!("Extract error: {}", e)); let _ = std::fs::remove_dir_all(&temp_extract_dir); + let _ = tokio::fs::remove_file(&download_path).await; + crate::debug::log("Cleaned up download file after error"); let _ = state_tx_clone.send(AppState::Error); let _ = drive_poll_tx_clone.send(true); return; @@ -629,6 +785,8 @@ impl InstallerApp { write_card_log("Copy cancelled"); log("Copy cancelled"); let _ = std::fs::remove_dir_all(&temp_extract_dir); + let _ = tokio::fs::remove_file(&download_path).await; + crate::debug::log("Cleaned up download file after cancellation"); let _ = state_tx_clone.send(AppState::Idle); let _ = drive_poll_tx_clone.send(true); return; @@ -636,6 +794,8 @@ impl InstallerApp { write_card_log(&format!("Copy error: {}", e)); log(&format!("Copy error: {}", e)); let _ = std::fs::remove_dir_all(&temp_extract_dir); + let _ = tokio::fs::remove_file(&download_path).await; + crate::debug::log("Cleaned up download file after error"); let _ = state_tx_clone.send(AppState::Error); let _ = drive_poll_tx_clone.send(true); return; @@ -895,23 +1055,13 @@ impl eframe::App for InstallerApp { ctx.request_repaint(); } - // Show modal dialogs for confirmation or status - let show_modal = matches!( - self.state, - AppState::AwaitingConfirmation - | AppState::Complete - | AppState::Ejecting - | AppState::Ejected - | AppState::Error - ); - if show_modal { // Background Dimmer egui::Area::new(egui::Id::from("modal_dimmer")) .order(egui::Order::Foreground) .fixed_pos(egui::pos2(0.0, 0.0)) .show(ctx, |ui| { - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); ui.allocate_rect(screen_rect, egui::Sense::click()); // Block clicks ui.painter() .rect_filled(screen_rect, 0.0, egui::Color32::from_black_alpha(140)); @@ -987,7 +1137,7 @@ impl eframe::App for InstallerApp { } AppState::Complete => { ui.add_space(12.0); - ui.colored_label(egui::Color32::from_rgb(91, 154, 91), "SUCCESS"); + ui.colored_label(egui::Color32::from_rgb(104, 157, 106), "SUCCESS"); ui.add_space(12.0); let selected_repo_name = REPO_OPTIONS[self.selected_repo_idx].0; ui.label(format!("{} has been successfully installed.", selected_repo_name)); @@ -1048,7 +1198,7 @@ impl eframe::App for InstallerApp { ui.add_space(12.0); ui.label("SD card ejected!"); ui.add_space(8.0); - ui.colored_label(egui::Color32::from_rgb(91, 154, 91), "You may now safely remove it."); + ui.colored_label(egui::Color32::from_rgb(104, 157, 106), "You may now safely remove it."); ui.add_space(15.0); if ui.button("OK").clicked() { self.state = AppState::Idle; @@ -1084,25 +1234,48 @@ impl eframe::App for InstallerApp { ui.vertical(|ui| { ui.add_space(8.0); ui.horizontal(|ui| { - ui.heading("Installation Log"); + ui.heading("Debug Log"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("X").on_hover_text("Close Log").clicked() { self.show_log = false; - let current_size = ui.ctx().screen_rect().size(); + let current_size = ui.ctx().content_rect().size(); ui.ctx().send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(current_size.x - 320.0, current_size.y))); } }); }); + + // Copy to Clipboard button + ui.horizontal(|ui| { + if ui.button("📋 Copy to Clipboard").clicked() { + let log_path = crate::debug::get_log_path(); + if let Ok(contents) = std::fs::read_to_string(&log_path) { + ui.ctx().copy_text(contents); + } + } + ui.label(format!("Log: {:?}", crate::debug::get_log_path().file_name().unwrap_or_default())); + }); + ui.separator(); - + egui::ScrollArea::vertical() .stick_to_bottom(true) .auto_shrink([false, false]) .show(ui, |ui| { ui.set_width(ui.available_width()); - if let Ok(logs) = self.log_messages.lock() { - for msg in logs.iter() { - ui.label(msg); + + // Read and display debug log file + let log_path = crate::debug::get_log_path(); + match std::fs::read_to_string(&log_path) { + Ok(contents) => { + ui.add( + egui::TextEdit::multiline(&mut contents.as_str()) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY) + .interactive(false) + ); + } + Err(e) => { + ui.label(format!("Failed to read log file: {}", e)); } } }); @@ -1134,8 +1307,8 @@ impl eframe::App for InstallerApp { columns[0].allocate_ui_with_layout( egui::Vec2::ZERO, egui::Layout::right_to_left(egui::Align::Center), - |ui| { - + |_ui| { + } ); @@ -1158,14 +1331,14 @@ impl eframe::App for InstallerApp { egui::Vec2::ZERO, egui::Layout::right_to_left(egui::Align::TOP), |ui| { - //if ui.button("🎨").on_hover_text("Toggle Theme Editor (Ctrl+T)").clicked() { - // self.show_theme_editor = !self.show_theme_editor; - //} + if ui.button("🎨").on_hover_text("Toggle Theme Editor (Ctrl+T)").clicked() { + self.show_theme_editor = !self.show_theme_editor; + } if ui.button("📜").on_hover_text("Toggle Log Area").clicked() { self.show_log = !self.show_log; // Adjust window size when toggling log - let current_size = ctx.screen_rect().size(); + let current_size = ctx.content_rect().size(); let new_width = if self.show_log { current_size.x + 320.0 } else { diff --git a/src/config.rs b/src/config.rs index e86c94a..30acdbf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ // ============================================================================ use eframe::egui; +use std::sync::Arc; // ---------------------------------------------------------------------------- // BRANDING @@ -36,7 +37,7 @@ pub const VOLUME_LABEL: &str = "NEXTUI"; // ---------------------------------------------------------------------------- /// Window title (displayed in title bar) -pub const WINDOW_TITLE: &str = "NextUI Setup"; +pub const WINDOW_TITLE: &str = "NextUI SD Card Setup"; /// User-Agent string for HTTP requests to GitHub pub const USER_AGENT: &str = env!("CARGO_PKG_NAME"); @@ -50,8 +51,8 @@ pub const TEMP_PREFIX: &str = env!("CARGO_PKG_NAME"); // Each entry is (Display Name, GitHub repo in "owner/repo" format) pub const REPO_OPTIONS: &[(&str, &str)] = &[ - ("NextUI stable", "LoveRetro/NextUI"), - ("beta", "LoveRetro/NextUI-nightly"), + ("Stable", "LoveRetro/NextUI"), + ("Nightly", "LoveRetro/NextUI-nightly"), ]; /// Index of the default repository selection (0 = first option) @@ -112,6 +113,42 @@ pub fn load_app_icon() -> Option { } } +// ---------------------------------------------------------------------------- +// CUSTOM FONT CONFIGURATION +// ---------------------------------------------------------------------------- +// To use a different font, replace the file at assets/Fonts/nunwen.ttf +// with your own TTF/OTF file and update CUSTOM_FONT_NAME if desired + +/// Embedded custom font (TTF/OTF format) +pub const CUSTOM_FONT: &[u8] = include_bytes!("../assets/Fonts/nunwen.ttf"); + +/// Font family name (used to reference the font in the UI) +pub const CUSTOM_FONT_NAME: &str = "Nunwen"; + +/// Load custom fonts into egui +/// Call this during app initialization, before creating the UI +pub fn load_custom_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + // Load the custom font data + fonts.font_data.insert( + CUSTOM_FONT_NAME.to_owned(), + Arc::new(egui::FontData::from_static(CUSTOM_FONT)), + ); + + // Set it as the first priority for proportional text (default UI text) + fonts.families.entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, CUSTOM_FONT_NAME.to_owned()); + + // Optionally also use it for monospace text (code, logs) + // fonts.families.entry(egui::FontFamily::Monospace) + // .or_default() + // .insert(0, CUSTOM_FONT_NAME.to_owned()); + + ctx.set_fonts(fonts); +} + // ============================================================================ // THEME SETUP (internal use) // ============================================================================ diff --git a/src/copy.rs b/src/copy.rs index 7f1a093..9938fbd 100644 --- a/src/copy.rs +++ b/src/copy.rs @@ -9,6 +9,7 @@ pub enum CopyProgress { Progress { copied_bytes: u64, total_bytes: u64, current_file: String }, Completed, Cancelled, + #[allow(dead_code)] Error(String), } diff --git a/src/drives.rs b/src/drives.rs index 4e15008..037af24 100644 --- a/src/drives.rs +++ b/src/drives.rs @@ -308,7 +308,6 @@ fn get_macos_disk_info(disk_id: &str) -> Option { // Enhanced detection let proto_lower = protocol.to_lowercase(); - let mt_lower = media_type.to_lowercase(); let loc_lower = device_location.to_lowercase(); // Skip disk images (DMGs) diff --git a/src/eject.rs b/src/eject.rs index a7d1392..293682d 100644 --- a/src/eject.rs +++ b/src/eject.rs @@ -10,7 +10,7 @@ use crate::drives::DriveInfo; #[cfg(target_os = "windows")] pub fn eject_drive(drive: &DriveInfo) -> Result<(), String> { use std::mem::size_of; - use windows::Win32::Foundation::{CloseHandle, HANDLE, GENERIC_READ, GENERIC_WRITE}; + use windows::Win32::Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE}; use windows::Win32::Storage::FileSystem::{ CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, @@ -174,42 +174,12 @@ pub fn eject_drive(drive: &DriveInfo) -> Result<(), String> { let _ = Command::new("umount").arg(&partition_path).output(); } - // Check if device still exists after unmount - if !Path::new(&drive.device_path).exists() { - crate::debug::log("Linux eject: device removed after unmount"); - return Ok(()); - } - - // Power off the device using udisksctl (cleanest method) - crate::debug::log(&format!("Linux eject: powering off {}...", drive.device_path)); - let udisks_result = Command::new("udisksctl") - .args(["power-off", "-b", &drive.device_path]) - .output(); - - if let Ok(output) = udisks_result { - if output.status.success() { - crate::debug::log("Linux eject: success via udisksctl power-off"); - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - crate::debug::log(&format!("Linux eject: udisksctl power-off failed: {}", stderr.trim())); - } - - // Fallback to eject command - crate::debug::log(&format!("Linux eject: trying eject command...")); - let output = Command::new("eject") - .arg(&drive.device_path) - .output() - .map_err(|e| format!("Failed to run eject: {}", e))?; + // Final sync to ensure all data is written + crate::debug::log("Linux eject: performing final system sync..."); + let _ = Command::new("sync").output(); - if output.status.success() { - crate::debug::log("Linux eject: success via eject"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - crate::debug::log(&format!("Linux eject failed: {}", stderr.trim())); - Err(format!("Eject failed: {}", stderr.trim())) - } + crate::debug::log("Linux eject: unmount successful, device is safe to remove"); + Ok(()) } // ============================================================================= diff --git a/src/extract.rs b/src/extract.rs index f883baf..2aa47c6 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -7,14 +7,24 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; #[cfg(target_os = "windows")] +#[allow(unused_imports)] use std::os::windows::process::CommandExt; // Embed platform-specific 7z binaries #[cfg(target_os = "windows")] const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Windows/7zr.exe"); -#[cfg(target_os = "linux")] -const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Linux/7zzs"); +#[cfg(all(target_os = "linux", target_arch = "x86_64"))] +const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Linux-x86_64/7zzs"); + +#[cfg(all(target_os = "linux", target_arch = "aarch64"))] +const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Linux-aarch64/7zzs"); + +#[cfg(all(target_os = "linux", target_arch = "x86"))] +const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Linux-i686/7zzs"); + +#[cfg(all(target_os = "linux", target_arch = "arm"))] +const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Linux-armv7/7zzs"); #[cfg(target_os = "macos")] const SEVEN_ZIP_EXE: &[u8] = include_bytes!("../assets/Mac/7zz"); @@ -80,30 +90,14 @@ pub async fn extract_7z( exe.parent() // Contents/MacOS .and_then(|p| p.parent()) // Contents .map(|contents| contents.join("Resources/7zz")) - }); + }) + .filter(|path| path.exists()); - if let Some(ref path) = bundled_path { - if path.exists() { - crate::debug::log(&format!("Using bundled 7zz from app bundle: {:?}", path)); - (path.clone(), true) - } else { - crate::debug::log("Bundled 7zz not found, extracting to temp..."); - // Fallback to temp extraction - let bin_dir = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); - let temp_path = bin_dir.join(format!("7zr_{}", TEMP_PREFIX)); - std::fs::write(&temp_path, SEVEN_ZIP_EXE) - .map_err(|e| format!("Failed to extract 7z tool: {}", e))?; - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&temp_path) - .map_err(|e| format!("Failed to get file permissions: {}", e))? - .permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&temp_path, perms) - .map_err(|e| format!("Failed to set executable permission: {}", e))?; - crate::debug::log(&format!("Extracted 7z binary to: {:?}", temp_path)); - (temp_path, false) - } + if let Some(path) = bundled_path { + crate::debug::log(&format!("Using bundled 7zz from app bundle: {:?}", path)); + (path, true) } else { + crate::debug::log("Bundled 7zz not found, extracting to temp..."); // Fallback to temp extraction let bin_dir = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); let temp_path = bin_dir.join(format!("7zr_{}", TEMP_PREFIX)); @@ -125,7 +119,52 @@ pub async fn extract_7z( #[cfg(not(target_os = "macos"))] let (seven_zip_path, is_bundled) = { #[cfg(target_os = "linux")] - let bin_dir = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); + let bin_dir = { + // If running as root via sudo or pkexec, try to use the actual user's cache directory + if unsafe { libc::geteuid() } == 0 { + // First check for SUDO_USER (command-line sudo) + if let Ok(sudo_user) = std::env::var("SUDO_USER") { + let user_home = std::path::PathBuf::from(format!("/home/{}", sudo_user)); + if user_home.exists() { + let user_cache = user_home.join(".cache"); + crate::debug::log(&format!("Extracting 7z binary using cache dir for sudo user {}: {:?}", sudo_user, user_cache)); + user_cache + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } + // Check for PKEXEC_UID (GUI elevation via pkexec) + else if let Ok(pkexec_uid) = std::env::var("PKEXEC_UID") { + if let Ok(uid) = pkexec_uid.parse::() { + let pwd = unsafe { libc::getpwuid(uid) }; + if !pwd.is_null() { + let username = unsafe { + std::ffi::CStr::from_ptr((*pwd).pw_name) + .to_string_lossy() + .to_string() + }; + let user_home = std::path::PathBuf::from(format!("/home/{}", username)); + if user_home.exists() { + let user_cache = user_home.join(".cache"); + crate::debug::log(&format!("Extracting 7z binary using cache dir for pkexec user {} (UID {}): {:?}", username, uid, user_cache)); + user_cache + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } + else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + } else { + dirs::cache_dir().unwrap_or_else(std::env::temp_dir) + } + }; #[cfg(not(target_os = "linux"))] let bin_dir = std::env::temp_dir(); @@ -183,25 +222,42 @@ pub async fn extract_7z( .spawn() .map_err(|e| format!("Failed to start 7z: {}", e))?; + crate::debug::log(&format!("7z process started (PID: {:?})", child.id())); + // Take stdout for progress parsing let mut stdout = child.stdout.take() .ok_or_else(|| "Failed to capture 7z stdout".to_string())?; - // Take stderr for error capture and read in background to prevent deadlock + // Take stderr for real-time logging let mut stderr = child.stderr.take() .ok_or_else(|| "Failed to capture 7z stderr".to_string())?; - + + // Log stderr in real-time instead of buffering let stderr_handle = tokio::spawn(async move { let mut buffer = Vec::new(); - if stderr.read_to_end(&mut buffer).await.is_ok() { - buffer - } else { - Vec::new() + let mut chunk = [0u8; 512]; + loop { + match stderr.read(&mut chunk).await { + Ok(0) => break, // EOF + Ok(n) => { + let text = String::from_utf8_lossy(&chunk[..n]); + if !text.trim().is_empty() { + crate::debug::log(&format!("7z stderr: {}", text.trim())); + } + buffer.extend_from_slice(&chunk[..n]); + } + Err(e) => { + crate::debug::log(&format!("Error reading 7z stderr: {}", e)); + break; + } + } } + buffer }); let mut last_percent: u8 = 0; let mut buffer = [0u8; 1024]; + let mut last_output_time = std::time::Instant::now(); // Read stdout looking for progress // 7z with -bsp1 uses backspaces or carriage returns, so we read raw chunks @@ -217,18 +273,34 @@ pub async fn extract_7z( let _ = progress_tx.send(ExtractProgress::Cancelled); return Err("Extraction cancelled".to_string()); } + _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => { + // Check if we've received output recently (within 5 minutes) + let elapsed = last_output_time.elapsed(); + if elapsed > std::time::Duration::from_secs(300) { + crate::debug::log(&format!("Extraction timeout: no output for {} seconds", elapsed.as_secs())); + let _ = child.kill().await; + if !is_bundled { + let _ = std::fs::remove_file(&seven_zip_path); + } + let _ = progress_tx.send(ExtractProgress::Error("Extraction timed out (no progress for 5 minutes)".to_string())); + return Err("Extraction timed out - the process may have hung".to_string()); + } + } read_result = stdout.read(&mut buffer) => { match read_result { Ok(0) => { // EOF - process finished output + crate::debug::log("7z stdout reached EOF"); break; } Ok(n) => { + last_output_time = std::time::Instant::now(); // Parse the buffer for percentage let text = String::from_utf8_lossy(&buffer[..n]); if let Some(percent) = parse_last_percentage(&text) { if percent != last_percent { last_percent = percent; + crate::debug::log(&format!("Extraction progress: {}%", percent)); let _ = progress_tx.send(ExtractProgress::Progress { percent }); } } diff --git a/src/fat32.rs b/src/fat32.rs index 5e20877..66016cf 100644 --- a/src/fat32.rs +++ b/src/fat32.rs @@ -6,11 +6,16 @@ use tokio::sync::mpsc; use crate::format::FormatProgress; +#[cfg(windows)] const SECTOR_SIZE: u32 = 512; +#[cfg(windows)] const RESERVED_SECTORS: u16 = 32; +#[cfg(windows)] const NUM_FATS: u8 = 2; +#[cfg(windows)] const PARTITION_START_SECTOR: u64 = 2048; // Standard 1MB alignment +#[cfg(windows)] #[derive(Debug)] struct Fat32Params { sectors_per_cluster: u8, @@ -19,6 +24,7 @@ struct Fat32Params { root_cluster: u32, } +#[cfg(windows)] fn calculate_params(total_bytes: u64) -> Fat32Params { let total_sectors = total_bytes / SECTOR_SIZE as u64; @@ -52,6 +58,7 @@ fn calculate_params(total_bytes: u64) -> Fat32Params { } } +#[cfg(windows)] fn create_boot_sector(params: &Fat32Params, volume_label: &str) -> [u8; 512] { let mut boot = [0u8; 512]; @@ -167,6 +174,7 @@ fn create_boot_sector(params: &Fat32Params, volume_label: &str) -> [u8; 512] { boot } +#[cfg(windows)] fn create_fsinfo_sector() -> [u8; 512] { let mut fsinfo = [0u8; 512]; @@ -188,6 +196,7 @@ fn create_fsinfo_sector() -> [u8; 512] { fsinfo } +#[cfg(windows)] fn create_fat_sector_with_entries() -> [u8; 512] { let mut fat = [0u8; 512]; @@ -211,7 +220,6 @@ pub async fn format_fat32_large( total_bytes: u64, progress_tx: mpsc::UnboundedSender, ) -> Result<(), String> { - use std::ptr; use windows::Win32::Foundation::{HANDLE, CloseHandle, GENERIC_READ, GENERIC_WRITE}; use windows::Win32::Storage::FileSystem::{ CreateFileW, SetFilePointerEx, WriteFile, FILE_BEGIN, @@ -339,6 +347,7 @@ pub async fn format_fat32_large( } #[cfg(not(windows))] +#[allow(dead_code)] pub async fn format_fat32_large( _disk_number: u32, _volume_label: &str, diff --git a/src/format.rs b/src/format.rs index 7660c70..ba57351 100644 --- a/src/format.rs +++ b/src/format.rs @@ -7,6 +7,7 @@ use std::process::Stdio; #[cfg(target_os = "windows")] use tokio::io::AsyncWriteExt; #[cfg(target_os = "windows")] +#[allow(unused_imports)] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] @@ -16,12 +17,15 @@ const CREATE_NO_WINDOW: u32 = 0x08000000; pub enum FormatProgress { Started, Unmounting, + #[cfg(not(target_os = "macos"))] CleaningDisk, + #[cfg(not(target_os = "macos"))] CreatingPartition, Formatting, Progress { percent: u8 }, Completed, Cancelled, + #[allow(dead_code)] Error(String), } @@ -515,7 +519,7 @@ pub async fn format_drive_fat32( progress_tx: mpsc::UnboundedSender, cancel_token: CancellationToken, ) -> Result<(), String> { - use tokio::time::{timeout, Duration, interval}; + use tokio::time::{timeout, Duration}; crate::debug::log_section("macOS Format Operation"); crate::debug::log(&format!("Device path: {}", device_path)); @@ -615,11 +619,12 @@ pub async fn format_drive_fat32( // Channel to signal "success signature found" from background reader let (finish_tx, mut finish_rx) = mpsc::unbounded_channel(); - + + use tokio::io::{AsyncBufReadExt, BufReader}; + if let Some(stdout) = child_stdout { let finish_tx = finish_tx.clone(); tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, BufReader}; let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { crate::debug::log(&format!("diskutil: {}", line)); @@ -629,10 +634,9 @@ pub async fn format_drive_fat32( } }); } - + if let Some(stderr) = child_stderr { tokio::spawn(async move { - use tokio::io::{AsyncBufReadExt, BufReader}; let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { crate::debug::log(&format!("diskutil stderr: {}", line)); @@ -718,11 +722,6 @@ pub async fn format_drive_fat32( Err("Formatting failed, please check your SD Card".to_string()) } -#[cfg(target_os = "macos")] -fn log_content(_path: &std::path::Path) { - // No-op, file logging removed -} - // ============================================================================= // Fallback for other platforms // ============================================================================= diff --git a/src/github.rs b/src/github.rs index 6096244..5ed8a89 100644 --- a/src/github.rs +++ b/src/github.rs @@ -10,6 +10,7 @@ use tokio_util::sync::CancellationToken; #[derive(Debug, Deserialize)] pub struct Release { pub tag_name: String, + #[allow(dead_code)] pub name: Option, pub assets: Vec, } @@ -27,6 +28,7 @@ pub enum DownloadProgress { Progress { downloaded: u64, total: u64 }, Completed, Cancelled, + #[allow(dead_code)] Error(String), } @@ -34,23 +36,40 @@ pub async fn get_latest_release(repo_url: &str) -> Result { let (owner, repo) = parse_github_url(repo_url)?; let api_url = format!("https://api.github.com/repos/{}/{}/releases/latest", owner, repo); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + let response = client .get(&api_url) .header("User-Agent", USER_AGENT) .header("Accept", "application/vnd.github.v3+json") .send() .await - .map_err(|e| format!("Failed to fetch release: {}", e))?; + .map_err(|e| { + if e.is_timeout() { + "Connection timed out. Please check your internet connection and try again.".to_string() + } else if e.is_connect() { + "Cannot reach GitHub. Please check your internet connection and firewall settings.".to_string() + } else { + format!("Failed to fetch release: {}", e) + } + })?; + + // Check for rate limiting (HTTP 403) + if response.status() == 403 { + return Err("GitHub API rate limit exceeded. Please wait an hour and try again, or check your internet connection.".to_string()); + } if !response.status().is_success() { - return Err(format!("GitHub API error: {}", response.status())); + return Err(format!("GitHub API returned error: {}. Please try again later.", response.status())); } response .json::() .await - .map_err(|e| format!("Failed to parse release: {}", e)) + .map_err(|e| format!("Failed to parse release data: {}. The release format may be invalid.", e)) } pub fn find_release_asset(release: &Release) -> Option<&Asset> { @@ -73,19 +92,37 @@ pub async fn download_asset( return Err("Download cancelled".to_string()); } - let client = reqwest::Client::new(); + // Create client with connection timeout (but no overall timeout for large downloads) + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + let response = client .get(&asset.browser_download_url) .header("User-Agent", USER_AGENT) .send() .await - .map_err(|e| format!("Failed to start download: {}", e))?; + .map_err(|e| { + if e.is_timeout() { + "Connection timed out while starting download. Please check your internet connection.".to_string() + } else if e.is_connect() { + "Cannot reach download server. Please check your internet connection and firewall settings.".to_string() + } else { + format!("Failed to start download: {}", e) + } + })?; if !response.status().is_success() { - return Err(format!("Download failed: {}", response.status())); + return Err(format!("Download failed with status {}: Please try again later.", response.status())); } let total_size = response.content_length().unwrap_or(asset.size); + + // Log download size for user awareness + let size_mb = total_size as f64 / 1_048_576.0; + crate::debug::log(&format!("Download size: {:.1} MB ({} bytes)", size_mb, total_size)); + let _ = progress_tx.send(DownloadProgress::Started { total_bytes: total_size }); let mut file = File::create(dest_path) diff --git a/src/main.rs b/src/main.rs index 262caea..234f816 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ mod format; mod github; use app::InstallerApp; -use config::{load_app_icon, WINDOW_MIN_SIZE, WINDOW_SIZE, WINDOW_TITLE}; +use config::{load_app_icon, load_custom_fonts, WINDOW_MIN_SIZE, WINDOW_SIZE, WINDOW_TITLE}; use eframe::egui; use std::sync::Arc; @@ -78,15 +78,9 @@ fn check_and_request_privileges() { } } -// Windows platforms don't need this function as privilege elevation is handled by the manifest -#[cfg(windows)] -fn check_and_request_privileges() { - // No-op for Windows -} - - fn main() -> eframe::Result<()> { - // Call the privilege check at the very beginning of main + // Call the privilege check at the very beginning of main (not needed on Windows due to manifest) + #[cfg(not(windows))] check_and_request_privileges(); let mut viewport = egui::ViewportBuilder::default() @@ -108,6 +102,9 @@ fn main() -> eframe::Result<()> { WINDOW_TITLE, options, Box::new(|cc| { + // Load custom fonts first (if configured) + load_custom_fonts(&cc.egui_ctx); + // Theme is applied in InstallerApp::new using setup_theme // Initialize image loaders for SVG support