Skip to content
Merged
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
760 changes: 0 additions & 760 deletions crates/surge-installer-ui/src/app.rs

This file was deleted.

104 changes: 104 additions & 0 deletions crates/surge-installer-ui/src/app/icons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::path::Path;

use eframe::egui;

use surge_core::config::installer::InstallerManifest;

pub(crate) fn load_window_icon(staging_dir: &Path, icon_name: &str) -> egui::IconData {
load_app_icon(staging_dir, icon_name).unwrap_or_else(default_surge_icon)
}

pub(crate) fn load_app_logo(staging_dir: &Path, icon_name: &str) -> Option<egui::IconData> {
Some(load_app_icon(staging_dir, icon_name).unwrap_or_else(default_surge_icon))
}

pub(crate) fn window_app_id(manifest: &InstallerManifest) -> String {
let preferred = manifest.runtime.install_directory.trim();
let fallback_name = manifest.runtime.name.trim();
let fallback_id = manifest.app_id.trim();

let raw = if !preferred.is_empty() {
preferred
} else if !fallback_name.is_empty() {
fallback_name
} else {
fallback_id
};

let mut normalized = String::with_capacity(raw.len());
for c in raw.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
normalized.push(c.to_ascii_lowercase());
} else {
normalized.push('-');
}
}

let cleaned = normalized.trim_matches(['-', '.'].as_ref());
if cleaned.is_empty() {
"surge-installer".to_string()
} else {
cleaned.to_string()
}
}

fn load_app_icon(staging_dir: &Path, icon_name: &str) -> Option<egui::IconData> {
let trimmed = icon_name.trim();
if trimmed.is_empty() {
return None;
}

let icon_rel = Path::new(trimmed);
let assets_dir = staging_dir.join("assets");
let mut candidates = vec![assets_dir.join(icon_rel)];
if let Some(file_name) = icon_rel.file_name() {
candidates.push(assets_dir.join(file_name));
}

let icon_path = candidates.into_iter().find(|candidate| candidate.is_file())?;
let bytes = std::fs::read(&icon_path).ok()?;
decode_icon(&bytes, icon_path.extension().and_then(std::ffi::OsStr::to_str))
}

fn default_surge_icon() -> egui::IconData {
decode_icon(include_bytes!("../../assets/logo.svg"), Some("svg")).unwrap_or_default()
}

fn decode_icon(bytes: &[u8], extension: Option<&str>) -> Option<egui::IconData> {
if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) || bytes.starts_with(b"<svg") {
return decode_svg_icon(bytes);
}

let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
Some(egui::IconData {
rgba: rgba.as_raw().clone(),
width: rgba.width(),
height: rgba.height(),
})
}

fn decode_svg_icon(bytes: &[u8]) -> Option<egui::IconData> {
let options = resvg::usvg::Options::default();
let tree = resvg::usvg::Tree::from_data(bytes, &options).ok()?;
let size = tree.size();

const TARGET_SIZE: u32 = 128;
let max_dim = size.width().max(size.height());
if max_dim <= 0.0 {
return None;
}

let scale = (TARGET_SIZE as f32) / max_dim;
let width = (size.width() * scale).round().max(1.0) as u32;
let height = (size.height() * scale).round().max(1.0) as u32;
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?;
let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());

Some(egui::IconData {
rgba: pixmap.data().to_vec(),
width,
height,
})
}
134 changes: 134 additions & 0 deletions crates/surge-installer-ui/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#![forbid(unsafe_code)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::items_after_statements,
clippy::unnecessary_wraps
)]

mod icons;
mod screens;
mod theme;
mod widgets;

use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};

use eframe::egui;

use surge_core::config::installer::InstallerManifest;

use crate::install::{self, ProgressUpdate};

pub(crate) use icons::{load_app_logo, load_window_icon, window_app_id};
pub(crate) use theme::configure_theme;

enum Screen {
Welcome,
Installing { progress: f32, status: String },
Complete { install_root: PathBuf },
Error(String),
}

pub struct InstallerApp {
manifest: InstallerManifest,
staging_dir: PathBuf,
app_logo: Option<egui::IconData>,
app_logo_texture: Option<egui::TextureHandle>,
simulator: bool,
install_error: Arc<Mutex<Option<String>>>,
screen: Screen,
progress_rx: Option<Receiver<ProgressUpdate>>,
}

impl InstallerApp {
pub fn new(
manifest: InstallerManifest,
staging_dir: PathBuf,
app_logo: Option<egui::IconData>,
simulator: bool,
install_error: Arc<Mutex<Option<String>>>,
) -> Self {
Self {
manifest,
staging_dir,
app_logo,
app_logo_texture: None,
simulator,
install_error,
screen: Screen::Welcome,
progress_rx: None,
}
}

fn start_install(&mut self, ctx: &egui::Context) {
let (tx, rx): (Sender<ProgressUpdate>, Receiver<ProgressUpdate>) = channel();
self.progress_rx = Some(rx);
self.screen = Screen::Installing {
progress: 0.0,
status: "Preparing\u{2026}".to_string(),
};

let manifest = self.manifest.clone();
let staging_dir = self.staging_dir.clone();
let shortcuts = manifest.runtime.shortcuts.clone();
let simulator = self.simulator;
let ctx_clone = ctx.clone();

std::thread::spawn(move || {
install::run_install(&manifest, &staging_dir, None, &shortcuts, &tx, &ctx_clone, simulator);
});
}

fn poll_progress(&mut self) {
if let Some(rx) = &self.progress_rx {
while let Ok(update) = rx.try_recv() {
match update {
ProgressUpdate::Status(status) => {
if let Screen::Installing { status: ref mut s, .. } = self.screen {
*s = status;
}
}
ProgressUpdate::Progress(p) => {
if let Screen::Installing { ref mut progress, .. } = self.screen {
*progress = p;
}
}
ProgressUpdate::Complete(root) => {
*self
.install_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
self.screen = Screen::Complete { install_root: root };
self.progress_rx = None;
return;
}
ProgressUpdate::Error(msg) => {
*self
.install_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(msg.clone());
self.screen = Screen::Error(msg);
self.progress_rx = None;
return;
}
}
}
}
}
}

impl eframe::App for InstallerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.poll_progress();

match &self.screen {
Screen::Welcome => self.render_welcome(ui),
Screen::Installing { .. } => self.render_installing(ui),
Screen::Complete { .. } => self.render_complete(ui),
Screen::Error(_) => self.render_error(ui),
}
}
}
Loading