Add model management and macOS integration

This commit is contained in:
Georg Bauer
2026-07-24 15:18:35 +02:00
parent ec77ab94fb
commit ad7167e84a
11 changed files with 1486 additions and 251 deletions

View File

@@ -3,14 +3,13 @@ mod view;
pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions};
use crate::model::{self, DownloadOutcome, DownloadProgress, ModelChoice};
use iced::futures::{SinkExt, Stream};
use iced::{Subscription, Task, keyboard};
use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
use iced::{Size, Subscription, Task, keyboard, window};
use rfd::AsyncFileDialog;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
@@ -37,6 +36,11 @@ impl PreferenceDraft {
}
pub(crate) struct App {
main_window: window::Id,
pub(super) model_manager_window: Option<window::Id>,
pub(super) pending_model_delete: Option<ManagedArtifactId>,
#[cfg(target_os = "macos")]
_native_menu: Option<crate::native_menu::NativeMenu>,
database: Option<Database>,
projects: Vec<ProjectWithSessions>,
preferences: AppPreferences,
@@ -56,18 +60,25 @@ pub(crate) struct App {
pub(super) enum ModelDownload {
Idle,
Active(ActiveDownload),
Complete(ModelChoice, DownloadProgress),
Failed(ModelChoice, String, DownloadProgress),
Complete(ManagedArtifactId, ModelOperation, DownloadProgress),
Failed(ManagedArtifactId, String, DownloadProgress),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ModelOperation {
Download,
Validate,
}
#[derive(Debug)]
pub(super) struct ActiveDownload {
model: ModelChoice,
dspark_enabled: bool,
artifact: ManagedArtifactId,
operation: ModelOperation,
progress: DownloadProgress,
sampled_at: Instant,
sampled_bytes: u64,
bytes_per_second: f64,
verified_bytes: Arc<AtomicU64>,
cancel: Arc<AtomicBool>,
result: mpsc::Receiver<Result<DownloadOutcome, String>>,
stopping: bool,
@@ -75,13 +86,22 @@ pub(super) struct ActiveDownload {
#[derive(Debug, Clone)]
pub(crate) enum Message {
Noop,
OpenPreferences,
OpenModelManager,
ModelManagerOpened(window::Id),
WindowOpened(window::Id),
WindowClosed(window::Id),
DismissPanel,
PreferenceModelChanged(ModelChoice),
PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String),
SavePreferences,
DownloadSelectedModel,
DownloadArtifact(ManagedArtifactId),
ValidateArtifact(ManagedArtifactId),
DeleteArtifact(ManagedArtifactId),
ConfirmDeleteArtifact,
CancelDeleteArtifact,
StopModelDownload,
DownloadProgressTick,
ChooseProjectFolder,
@@ -97,16 +117,21 @@ pub(crate) enum Message {
}
impl App {
pub(crate) fn load() -> Self {
pub(crate) fn load(main_window: window::Id) -> Self {
let path = application_support_path().join("data.sqlite3");
match Database::open(&path) {
Ok(mut database) => match (database.load_projects(), database.load_preferences()) {
(Ok(projects), Ok(preferences)) => {
let preference_draft = match PreferenceDraft::from_saved(&preferences) {
Ok(draft) => draft,
Err(error) => return Self::failed(error),
Err(error) => return Self::failed(error, main_window),
};
Self {
main_window,
model_manager_window: None,
pending_model_delete: None,
#[cfg(target_os = "macos")]
_native_menu: None,
database: Some(database),
projects,
preferences,
@@ -122,17 +147,22 @@ impl App {
error: None,
}
}
(Err(error), _) | (_, Err(error)) => Self::failed(error),
(Err(error), _) | (_, Err(error)) => Self::failed(error, main_window),
},
Err(error) => Self::failed(error),
Err(error) => Self::failed(error, main_window),
}
}
fn failed(error: String) -> Self {
fn failed(error: String, main_window: window::Id) -> Self {
let preferences = AppPreferences::default();
let preference_draft = PreferenceDraft::from_saved(&preferences)
.expect("default preferences must use a supported model");
Self {
main_window,
model_manager_window: None,
pending_model_delete: None,
#[cfg(target_os = "macos")]
_native_menu: None,
database: None,
projects: Vec::new(),
preferences,
@@ -151,7 +181,36 @@ impl App {
pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Noop => {}
Message::OpenPreferences => self.open_preferences(),
Message::OpenModelManager => return self.open_model_manager(),
Message::ModelManagerOpened(id) => {
if self.model_manager_window == Some(id) {
return window::gain_focus(id);
}
}
Message::WindowOpened(id) =>
{
#[cfg(target_os = "macos")]
if id == self.main_window && self._native_menu.is_none() {
match crate::native_menu::install() {
Ok(menu) => self._native_menu = Some(menu),
Err(error) => {
self.error =
Some(format!("Could not install the application menu: {error}"))
}
}
}
}
Message::WindowClosed(id) => {
if id == self.main_window {
return iced::exit();
}
if self.model_manager_window == Some(id) {
self.model_manager_window = None;
self.pending_model_delete = None;
}
}
Message::DismissPanel => {
if self.preferences_open {
self.preferences_open = false;
@@ -179,41 +238,27 @@ impl App {
self.preference_error = None;
}
Message::SavePreferences => self.save_preferences(),
Message::DownloadSelectedModel => {
if matches!(self.model_download, ModelDownload::Active(_)) {
return Task::none();
}
let model = self.preference_draft.model;
let dspark_enabled =
model.supports_dspark() && self.preference_draft.dspark_enabled;
let models_path = models_path();
let progress = model::download_progress(model, dspark_enabled, &models_path);
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let (result_sender, result_receiver) = mpsc::channel();
if let Err(error) = thread::Builder::new()
.name("model-download".to_owned())
.spawn(move || {
let result =
model::download(model, dspark_enabled, &models_path, &worker_cancel);
let _ = result_sender.send(result);
})
{
self.error = Some(format!("Could not start model download: {error}"));
return Task::none();
}
self.model_download = ModelDownload::Active(ActiveDownload {
model,
dspark_enabled,
sampled_at: Instant::now(),
sampled_bytes: progress.downloaded,
progress,
bytes_per_second: 0.0,
cancel: Arc::clone(&cancel),
result: result_receiver,
stopping: false,
});
Message::DownloadArtifact(artifact) => {
self.start_model_operation(artifact, ModelOperation::Download)
}
Message::ValidateArtifact(artifact) => {
self.start_model_operation(artifact, ModelOperation::Validate)
}
Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact),
Message::ConfirmDeleteArtifact => {
if let Some(artifact) = self.pending_model_delete.take() {
match model::delete_managed_artifact(artifact, &models_path()) {
Ok(()) => {
self.model_download = ModelDownload::Idle;
self.error = None;
}
Err(error) => {
self.error = Some(format!("Could not delete {artifact}: {error}"))
}
}
}
}
Message::CancelDeleteArtifact => self.pending_model_delete = None,
Message::StopModelDownload => {
if let ModelDownload::Active(download) = &mut self.model_download {
download.stopping = true;
@@ -290,15 +335,105 @@ impl App {
}
pub(crate) fn subscription(&self) -> Subscription<Message> {
let shortcuts = keyboard::on_key_press(shortcut);
let mut subscriptions = vec![
keyboard::on_key_press(shortcut),
window::close_requests().map(Message::WindowClosed),
window::close_events().map(Message::WindowClosed),
];
#[cfg(target_os = "macos")]
subscriptions.push(iced::time::every(Duration::from_millis(50)).map(|_| {
match crate::native_menu::next_event() {
Some(crate::native_menu::NativeMenuEvent::Preferences) => Message::OpenPreferences,
Some(crate::native_menu::NativeMenuEvent::ModelManager) => {
Message::OpenModelManager
}
None => Message::Noop,
}
}));
if matches!(self.model_download, ModelDownload::Active(_)) {
Subscription::batch([
shortcuts,
Subscription::run(download_ticks).map(|_| Message::DownloadProgressTick),
])
} else {
shortcuts
subscriptions.push(
iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick),
);
}
Subscription::batch(subscriptions)
}
pub(crate) fn title(&self, id: window::Id) -> String {
if self.model_manager_window == Some(id) {
"Model Manager — DS4Server".to_owned()
} else {
"DS4Server".to_owned()
}
}
fn open_model_manager(&mut self) -> Task<Message> {
if let Some(id) = self.model_manager_window {
return window::gain_focus(id);
}
let (id, open) = window::open(window::Settings {
size: Size::new(760.0, 560.0),
min_size: Some(Size::new(620.0, 420.0)),
icon: Some(app_icon()),
..Default::default()
});
self.model_manager_window = Some(id);
open.map(Message::ModelManagerOpened)
}
fn start_model_operation(&mut self, artifact: ManagedArtifactId, operation: ModelOperation) {
if matches!(self.model_download, ModelDownload::Active(_)) {
return;
}
let models_path = models_path();
let progress = match operation {
ModelOperation::Download => model::artifact_download_progress(artifact, &models_path),
ModelOperation::Validate => model::artifact_verification_progress(artifact, 0),
};
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let verified_bytes = Arc::new(AtomicU64::new(0));
let worker_verified_bytes = Arc::clone(&verified_bytes);
let (result_sender, result_receiver) = mpsc::channel();
let thread_name = match operation {
ModelOperation::Download => "model-download",
ModelOperation::Validate => "model-validation",
};
if let Err(error) = thread::Builder::new()
.name(thread_name.to_owned())
.spawn(move || {
let result = match operation {
ModelOperation::Download => model::download_managed_artifact(
artifact,
&models_path,
&worker_cancel,
&worker_verified_bytes,
),
ModelOperation::Validate => model::validate_managed_artifact(
artifact,
&models_path,
&worker_cancel,
&worker_verified_bytes,
),
};
let _ = result_sender.send(result);
})
{
self.error = Some(format!("Could not start {thread_name}: {error}"));
return;
}
self.pending_model_delete = None;
self.model_download = ModelDownload::Active(ActiveDownload {
artifact,
operation,
sampled_at: Instant::now(),
sampled_bytes: progress.completed(),
progress,
bytes_per_second: 0.0,
verified_bytes,
cancel,
result: result_receiver,
stopping: false,
});
}
fn open_preferences(&mut self) {
@@ -443,11 +578,27 @@ impl App {
return;
};
let now = Instant::now();
let progress =
model::download_progress(download.model, download.dspark_enabled, &models_path());
let verified = download.verified_bytes.load(Ordering::Relaxed);
let mut progress = match download.operation {
ModelOperation::Download => {
model::artifact_download_progress(download.artifact, &models_path())
}
ModelOperation::Validate => {
model::artifact_verification_progress(download.artifact, verified)
}
};
if download.operation == ModelOperation::Download
&& let Some(verification) = &mut progress.verification
{
verification.verified = verified.min(verification.total);
}
let elapsed = now.duration_since(download.sampled_at).as_secs_f64();
let transferred = progress.downloaded.saturating_sub(download.sampled_bytes);
if transferred > 0 && elapsed > 0.0 {
let completed = progress.completed();
let phase_changed = progress.phase != download.progress.phase;
let transferred = completed.saturating_sub(download.sampled_bytes);
if phase_changed {
download.bytes_per_second = 0.0;
} else if transferred > 0 && elapsed > 0.0 {
let current = transferred as f64 / elapsed;
download.bytes_per_second = if download.bytes_per_second == 0.0 {
current
@@ -457,7 +608,7 @@ impl App {
}
download.progress = progress;
download.sampled_at = now;
download.sampled_bytes = download.progress.downloaded;
download.sampled_bytes = completed;
let result = match download.result.try_recv() {
Ok(result) => Some(result),
@@ -466,12 +617,14 @@ impl App {
Some(Err("Download worker stopped unexpectedly.".into()))
}
};
let model = download.model;
let artifact = download.artifact;
let operation = download.operation;
let progress = download.progress.clone();
if let Some(result) = result {
match result {
Ok(DownloadOutcome::Complete) => {
self.model_download = ModelDownload::Complete(model, progress);
let progress = model::artifact_download_progress(artifact, &models_path());
self.model_download = ModelDownload::Complete(artifact, operation, progress);
self.error = None;
}
Ok(DownloadOutcome::Stopped) => {
@@ -480,7 +633,7 @@ impl App {
}
Err(error) => {
self.error = Some(error.clone());
self.model_download = ModelDownload::Failed(model, error, progress);
self.model_download = ModelDownload::Failed(artifact, error, progress);
}
}
}
@@ -495,20 +648,12 @@ impl Drop for App {
}
}
fn download_ticks() -> impl Stream<Item = ()> {
iced::stream::channel(1, |mut output| async move {
loop {
std::thread::sleep(Duration::from_secs(1));
if output.send(()).await.is_err() {
break;
}
}
})
}
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
match key.as_ref() {
keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences),
keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => {
Some(Message::OpenModelManager)
}
keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
_ => None,
}
@@ -527,6 +672,24 @@ fn models_path() -> PathBuf {
application_support_path().join("models")
}
pub(crate) fn app_icon() -> window::Icon {
let decoder = png::Decoder::new(std::io::Cursor::new(include_bytes!(
"../assets/app-icon.png"
)));
let mut reader = decoder
.read_info()
.expect("bundled application icon must be valid PNG");
let mut rgba = vec![0; reader.output_buffer_size()];
let info = reader
.next_frame(&mut rgba)
.expect("bundled application icon must decode");
assert_eq!(info.color_type, png::ColorType::Rgba);
assert_eq!(info.bit_depth, png::BitDepth::Eight);
rgba.truncate(info.buffer_size());
window::icon::from_rgba(rgba, info.width, info.height)
.expect("bundled application icon dimensions must be valid")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -538,6 +701,11 @@ mod tests {
keyboard::Modifiers::COMMAND,
);
assert!(matches!(message, Some(Message::OpenPreferences)));
let message = shortcut(
keyboard::Key::Character("m".into()),
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
);
assert!(matches!(message, Some(Message::OpenModelManager)));
assert!(ModelChoice::DeepSeekV4Flash.supports_dspark());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
assert!(!ModelChoice::Glm52.supports_dspark());

View File

@@ -1,12 +1,14 @@
use super::{ActiveDownload, App, Message, ModelDownload, models_path};
use super::{ActiveDownload, App, Message, ModelDownload, ModelOperation, models_path};
use crate::database::{ProjectWithSessions, Session};
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
use crate::model::{
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
};
use iced::theme::{Palette, palette};
use iced::widget::{
Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
scrollable, stack, svg, text, text_input,
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, opaque, pick_list,
progress_bar, row, scrollable, stack, svg, text, text_input,
};
use iced::{Alignment, Color, Element, Length, Theme};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
use std::path::Path;
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
@@ -22,7 +24,15 @@ const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
impl App {
pub(crate) fn view(&self) -> Element<'_, Message> {
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
if self.model_manager_window == Some(id) {
self.model_manager()
} else {
self.main_view()
}
}
fn main_view(&self) -> Element<'_, Message> {
let mut shell = column![
row![self.sidebar(), self.detail()]
.width(Length::Fill)
@@ -178,11 +188,9 @@ impl App {
.spacing(8)
.align_y(Alignment::Center);
let open_project = if self.database.is_some() && !self.choosing_folder {
button(open_project_content)
.on_press(Message::ChooseProjectFolder)
.style(button::primary)
action_button(open_project_content).on_press(Message::ChooseProjectFolder)
} else {
button(open_project_content)
action_button(open_project_content)
};
return container(
column![
@@ -237,7 +245,7 @@ impl App {
.to_string(),
)
.size(12),
button(icon(ICON_SEND, 18)),
action_button(icon(ICON_SEND, 18)).padding(8),
]
.align_y(Alignment::Center),
]
@@ -245,7 +253,7 @@ impl App {
)
.padding(16)
.width(Length::Fill)
.style(container::rounded_box),
.style(overview_style),
]
.height(Length::Fill)
.spacing(8);
@@ -291,33 +299,6 @@ impl App {
self.preference_draft.dspark_enabled,
)
.on_toggle_maybe(dspark_toggle);
let model = self.preference_draft.model;
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let selected_progress = model::download_progress(model, dspark_enabled, &models_path());
let installed = selected_progress.phase == DownloadPhase::Complete;
let download = if let ModelDownload::Active(download) = &self.model_download {
if download.stopping {
button("Stopping…")
} else {
button("Stop download")
.on_press(Message::StopModelDownload)
.style(button::danger)
}
} else if installed {
button("Downloaded")
} else {
let action = if selected_progress.downloaded > 0 {
"Resume"
} else {
"Download"
};
button(text(format!(
"{action} {} ({})",
model,
format_bytes(selected_progress.total)
)))
.on_press(Message::DownloadSelectedModel)
};
let mut content = column![
row![
@@ -338,13 +319,11 @@ impl App {
.width(Length::Fill),
dspark,
text(if self.preference_draft.model.supports_dspark() {
"DSpark support is downloaded and used only when enabled."
"DSpark support is used only when enabled. Downloads are managed in Model Manager."
} else {
"DSpark is not available for the selected model."
})
.size(12),
download.style(button::secondary),
model_download_status(&self.model_download, &selected_progress),
Space::with_height(8),
text("INACTIVITY").size(11),
row![
@@ -366,12 +345,8 @@ impl App {
content = content.push(
row![
Space::with_width(Length::Fill),
button("Cancel")
.on_press(Message::DismissPanel)
.style(button::secondary),
button("Save")
.on_press(Message::SavePreferences)
.style(button::primary),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Save").on_press(Message::SavePreferences),
]
.spacing(8),
);
@@ -379,7 +354,7 @@ impl App {
let panel = container(content)
.padding(24)
.width(520)
.style(container::rounded_box);
.style(overview_style);
opaque(
container(panel)
.center_x(Length::Fill)
@@ -390,6 +365,79 @@ impl App {
)
}
fn model_manager(&self) -> Element<'_, Message> {
let busy = matches!(self.model_download, ModelDownload::Active(_));
let mut artifacts = column![];
for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() {
if index > 0 {
artifacts = artifacts.push(horizontal_rule(1));
}
artifacts = artifacts.push(model_artifact_row(artifact, busy));
}
let mut content = column![
text("Model Manager").size(26),
text("Download, verify, or remove locally stored model files.")
.size(14)
.color(muted_text()),
]
.spacing(8);
if !matches!(self.model_download, ModelDownload::Idle) {
content = content.push(
container(model_download_status(&self.model_download))
.padding(16)
.style(overview_style),
);
}
if let Some(error) = &self.error {
content = content.push(text(error).style(iced::widget::text::danger));
}
content = content.push(Space::with_height(8)).push(
container(scrollable(artifacts).height(Length::Fill))
.height(Length::Fill)
.style(overview_style),
);
let base: Element<'_, Message> = container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding(28)
.into();
let Some(artifact) = self.pending_model_delete else {
return base;
};
let confirmation = container(
column![
text("Delete model file?").size(22),
text(format!(
"Delete {artifact}, including any resumable partial download?"
))
.size(13),
row![
Space::with_width(Length::Fill),
action_button("Cancel").on_press(Message::CancelDeleteArtifact),
danger_button("Delete").on_press(Message::ConfirmDeleteArtifact),
]
.spacing(8),
]
.spacing(14),
)
.padding(22)
.width(460)
.style(overview_style);
stack![
base,
opaque(
container(confirmation)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| container::Style::default()
.background(Color::from_rgba8(0, 0, 0, 0.68)))
)
]
.into()
}
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
let dialog = container(
column![
@@ -402,12 +450,8 @@ impl App {
.padding(10),
row![
Space::with_width(Length::Fill),
button("Cancel")
.on_press(Message::CancelProject)
.style(button::secondary),
button("Add project")
.on_press(Message::ConfirmProject)
.style(button::primary),
action_button("Cancel").on_press(Message::CancelProject),
action_button("Add project").on_press(Message::ConfirmProject),
]
.spacing(8),
]
@@ -415,7 +459,7 @@ impl App {
)
.padding(22)
.width(440)
.style(container::rounded_box);
.style(overview_style);
opaque(
container(dialog)
@@ -441,9 +485,78 @@ impl App {
}
}
fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static, Message> {
let status = match artifact.state {
ManagedArtifactState::Missing => "Not downloaded",
ManagedArtifactState::Partial => "Partial download",
ManagedArtifactState::NeedsVerification => "Downloaded; verification required",
ManagedArtifactState::Ready => "Ready and verified",
};
let download_label = match artifact.state {
ManagedArtifactState::Ready => "Downloaded",
_ if artifact.stored > 0 => "Resume",
_ => "Download",
};
let download = if artifact.state == ManagedArtifactState::Ready || busy {
action_button(download_label)
} else {
action_button(download_label).on_press(Message::DownloadArtifact(artifact.id))
};
let validate = if artifact.can_validate() && !busy {
action_button("Validate").on_press(Message::ValidateArtifact(artifact.id))
} else {
action_button("Validate")
};
let delete = if artifact.stored > 0 && !busy {
danger_button("Delete").on_press(Message::DeleteArtifact(artifact.id))
} else {
danger_button("Delete")
};
container(
row![
icon(ICON_MODEL, 28),
column![
text(artifact.id.to_string()).size(16),
text(status).size(13).color(muted_text()),
text(format!(
"{} on disk • {} expected",
format_bytes(artifact.stored),
format_bytes(artifact.expected),
))
.size(12)
.color(muted_text()),
]
.spacing(5),
Space::with_width(Length::Fill),
row![download, validate, delete]
.spacing(8)
.align_y(Alignment::Center),
]
.spacing(16)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([18, 20])
.into()
}
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress;
let percent = progress.fraction() * 100.0;
let measurement = if progress.verification.is_some() {
format!(
"{} verified of {} ({percent:.1}%)",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
)
} else {
format!(
"{} of {} ({percent:.1}%)",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
)
};
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
format!(
"{}/s • about {} remaining",
@@ -454,11 +567,9 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
"Calculating time remaining…".to_owned()
};
let stop = if download.stopping {
button("Stopping…")
danger_button("Stopping…")
} else {
button("Stop")
.on_press(Message::StopModelDownload)
.style(button::danger)
danger_button("Stop").on_press(Message::StopModelDownload)
};
container(
@@ -467,12 +578,7 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
progress_bar(0.0..=1.0, progress.fraction())
.width(180)
.height(7),
text(format!(
"{} of {} ({percent:.1}%)",
format_bytes(progress.downloaded),
format_bytes(progress.total),
))
.size(12),
text(measurement).size(12),
text(transfer).size(12),
Space::with_width(Length::Fill),
stop,
@@ -486,60 +592,71 @@ fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
.into()
}
fn model_download_status<'a>(
download: &ModelDownload,
selected: &DownloadProgress,
) -> Element<'a, Message> {
fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
let (progress, heading, speed, failed) = match download {
ModelDownload::Idle => (
selected,
if selected.downloaded > 0 {
"Paused — the next download will resume from this point.".to_owned()
} else {
phase_text(selected.phase)
},
0.0,
false,
),
ModelDownload::Idle => unreachable!("idle operations are not displayed"),
ModelDownload::Active(active) => (
&active.progress,
if active.stopping {
format!("Stopping {}", active.model)
format!("Stopping {}", active.artifact)
} else {
phase_text(active.progress.phase)
},
active.bytes_per_second,
false,
),
ModelDownload::Complete(model, progress) => (
ModelDownload::Complete(artifact, operation, progress) => (
progress,
format!("{model} is downloaded and verified."),
match operation {
ModelOperation::Download => format!("{artifact} is downloaded and verified."),
ModelOperation::Validate => format!("{artifact} passed validation."),
},
0.0,
false,
),
ModelDownload::Failed(model, error, progress) => (
ModelDownload::Failed(artifact, error, progress) => (
progress,
format!("{model} download failed: {error}"),
format!("{artifact} operation failed: {error}"),
0.0,
true,
),
};
let percent = progress.fraction() * 100.0;
let mut status = column![
if failed {
text(heading).size(12).style(iced::widget::text::danger)
} else {
text(heading).size(12)
},
progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(format!(
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.downloaded),
format_bytes(progress.total),
let measurement = if progress.verification.is_some() {
format!(
"{} verified of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
format_bytes(progress.remaining()),
))
.size(12),
)
} else {
format!(
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.completed()),
format_bytes(progress.active_total()),
format_bytes(progress.remaining()),
)
};
let heading = if failed {
text(heading).size(12).style(iced::widget::text::danger)
} else {
text(heading).size(12)
};
let mut heading_row = row![heading, Space::with_width(Length::Fill)]
.align_y(Alignment::Center)
.spacing(8);
if let ModelDownload::Active(active) = download {
heading_row = heading_row.push(if active.stopping {
danger_button("Stopping…")
} else {
danger_button("Stop").on_press(Message::StopModelDownload)
});
}
let mut status = column![
heading_row,
progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(measurement).size(12),
]
.spacing(6);
if speed > 0.0 && progress.remaining() > 0 {
@@ -606,6 +723,58 @@ pub(crate) fn app_theme() -> Theme {
})
}
fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
button(content).padding([8, 14]).style(action_button_style)
}
fn danger_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
button(content).padding([8, 14]).style(danger_button_style)
}
fn action_button_style(_: &Theme, status: button::Status) -> button::Style {
let (background, text_color) = match status {
button::Status::Active | button::Status::Pressed => {
(Color::from_rgb8(45, 45, 47), Color::WHITE)
}
button::Status::Hovered => (Color::from_rgb8(56, 56, 59), Color::WHITE),
button::Status::Disabled => (Color::from_rgb8(35, 35, 37), muted_text().scale_alpha(0.55)),
};
button::Style {
background: Some(Background::Color(background)),
text_color,
border: Border {
radius: 12.0.into(),
..Border::default()
},
..button::Style::default()
}
}
fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style {
let mut style = action_button_style(theme, status);
style.text_color = match status {
button::Status::Disabled => theme.palette().danger.scale_alpha(0.45),
_ => theme.palette().danger,
};
style
}
fn overview_style(_: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb8(31, 31, 33))),
border: Border {
color: Color::from_rgb8(61, 61, 64),
width: 1.0,
radius: 14.0.into(),
},
..container::Style::default()
}
}
fn muted_text() -> Color {
Color::from_rgb8(174, 174, 178)
}
fn sidebar_style(_: &Theme) -> container::Style {
container::Style::default().background(Color::from_rgb8(23, 23, 25))
}
@@ -631,6 +800,15 @@ mod tests {
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
}
#[test]
fn action_buttons_share_geometry() {
let theme = app_theme();
let action = action_button_style(&theme, button::Status::Active);
let danger = danger_button_style(&theme, button::Status::Active);
assert_eq!(action.background, danger.background);
assert_eq!(action.border, danger.border);
}
#[test]
fn download_measurements_are_readable() {
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");

View File

@@ -1,19 +1,24 @@
mod app;
mod database;
mod model;
#[cfg(target_os = "macos")]
mod native_menu;
mod schema;
use app::{App, app_theme};
use iced::{Size, Task};
use app::{App, Message, app_icon, app_theme};
use iced::{Size, window};
fn main() -> iced::Result {
iced::application("DS4Server", App::update, App::view)
iced::daemon(App::title, App::update, App::view)
.subscription(App::subscription)
.theme(|_| app_theme())
.window(iced::window::Settings {
size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)),
..Default::default()
.theme(|_, _| app_theme())
.run_with(|| {
let (main_window, open) = window::open(window::Settings {
size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)),
icon: Some(app_icon()),
..Default::default()
});
(App::load(main_window), open.map(Message::WindowOpened))
})
.run_with(|| (App::load(), Task::none()))
}

View File

@@ -2,7 +2,7 @@ use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use sha2::{Digest, Sha256};
@@ -11,6 +11,12 @@ pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
pub(crate) const MANAGED_ARTIFACTS: [ManagedArtifactId; 4] = [
ManagedArtifactId::DeepSeekV4Flash,
ManagedArtifactId::DeepSeekV4FlashDspark,
ManagedArtifactId::DeepSeekV4Pro,
ManagedArtifactId::Glm52,
];
const DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf";
const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf";
@@ -69,6 +75,7 @@ impl ModelChoice {
self == Self::DeepSeekV4Flash
}
#[cfg(test)]
fn main_artifact(self) -> &'static Artifact {
match self {
Self::DeepSeekV4Flash => &FLASH,
@@ -77,6 +84,7 @@ impl ModelChoice {
}
}
#[cfg(test)]
fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> {
[
Some(self.main_artifact()),
@@ -85,11 +93,63 @@ impl ModelChoice {
.into_iter()
.flatten()
}
}
pub(crate) fn download_size(self, dspark_enabled: bool) -> u64 {
self.artifacts(dspark_enabled)
.map(|artifact| artifact.size)
.sum()
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedArtifactId {
DeepSeekV4Flash,
DeepSeekV4FlashDspark,
DeepSeekV4Pro,
Glm52,
}
impl ManagedArtifactId {
pub(crate) fn model(self) -> ModelChoice {
match self {
Self::DeepSeekV4Flash | Self::DeepSeekV4FlashDspark => ModelChoice::DeepSeekV4Flash,
Self::DeepSeekV4Pro => ModelChoice::DeepSeekV4Pro,
Self::Glm52 => ModelChoice::Glm52,
}
}
fn artifact(self) -> &'static Artifact {
match self {
Self::DeepSeekV4Flash => &FLASH,
Self::DeepSeekV4FlashDspark => &FLASH_DSPARK,
Self::DeepSeekV4Pro => &PRO,
Self::Glm52 => &GLM,
}
}
}
impl fmt::Display for ManagedArtifactId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.artifact().label)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedArtifactState {
Missing,
Partial,
NeedsVerification,
Ready,
}
#[derive(Clone, Debug)]
pub(crate) struct ManagedArtifact {
pub(crate) id: ManagedArtifactId,
pub(crate) stored: u64,
pub(crate) expected: u64,
pub(crate) state: ManagedArtifactState,
}
impl ManagedArtifact {
pub(crate) fn can_validate(&self) -> bool {
matches!(
self.state,
ManagedArtifactState::NeedsVerification | ManagedArtifactState::Ready
)
}
}
@@ -106,20 +166,38 @@ pub(crate) struct DownloadProgress {
pub(crate) downloaded: u64,
pub(crate) total: u64,
pub(crate) phase: DownloadPhase,
pub(crate) verification: Option<VerificationProgress>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct VerificationProgress {
pub(crate) verified: u64,
pub(crate) total: u64,
}
impl DownloadProgress {
pub(crate) fn remaining(&self) -> u64 {
self.total.saturating_sub(self.downloaded)
self.active_total().saturating_sub(self.completed())
}
pub(crate) fn fraction(&self) -> f32 {
if self.total == 0 {
let total = self.active_total();
if total == 0 {
0.0
} else {
self.downloaded as f32 / self.total as f32
self.completed() as f32 / total as f32
}
}
pub(crate) fn completed(&self) -> u64 {
self.verification
.map_or(self.downloaded, |progress| progress.verified)
}
pub(crate) fn active_total(&self) -> u64 {
self.verification
.map_or(self.total, |progress| progress.total)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -183,58 +261,184 @@ impl Artifact {
.or_else(|_| self.partial_path(model, models_path).metadata())
.map_or(0, |metadata| metadata.len().min(self.size))
}
fn stored_bytes(&self, model: ModelChoice, models_path: &Path) -> u64 {
[
&self.path(model, models_path),
&self.partial_path(model, models_path),
]
.into_iter()
.filter_map(|path| path.metadata().ok())
.map(|metadata| metadata.len())
.sum()
}
}
pub(crate) fn download_progress(
model: ModelChoice,
dspark_enabled: bool,
pub(crate) fn managed_artifacts(models_path: &Path) -> Vec<ManagedArtifact> {
MANAGED_ARTIFACTS
.into_iter()
.map(|id| {
let model = id.model();
let artifact = id.artifact();
let stored = artifact.stored_bytes(model, models_path);
let complete_file = artifact
.path(model, models_path)
.metadata()
.or_else(|_| artifact.partial_path(model, models_path).metadata())
.is_ok_and(|metadata| metadata.len() == artifact.size);
let state = if artifact.is_installed(model, models_path) {
ManagedArtifactState::Ready
} else if complete_file {
ManagedArtifactState::NeedsVerification
} else if stored > 0 {
ManagedArtifactState::Partial
} else {
ManagedArtifactState::Missing
};
ManagedArtifact {
id,
stored,
expected: artifact.size,
state,
}
})
.collect()
}
pub(crate) fn artifact_download_progress(
id: ManagedArtifactId,
models_path: &Path,
) -> DownloadProgress {
let mut downloaded = 0;
let mut phase = DownloadPhase::Complete;
for artifact in model.artifacts(dspark_enabled) {
let bytes = artifact.downloaded_bytes(model, models_path);
downloaded += bytes;
if phase == DownloadPhase::Complete && !artifact.is_installed(model, models_path) {
phase = if bytes >= artifact.size {
DownloadPhase::Verifying(artifact.label)
} else if bytes > 0 {
DownloadPhase::Downloading(artifact.label)
} else {
DownloadPhase::Pending(artifact.label)
};
}
}
let model = id.model();
let artifact = id.artifact();
let downloaded = artifact.downloaded_bytes(model, models_path);
let installed = artifact.is_installed(model, models_path);
let verification =
(!installed && downloaded >= artifact.size).then_some(VerificationProgress {
verified: 0,
total: artifact.size,
});
let phase = if installed {
DownloadPhase::Complete
} else if verification.is_some() {
DownloadPhase::Verifying(artifact.label)
} else if downloaded > 0 {
DownloadPhase::Downloading(artifact.label)
} else {
DownloadPhase::Pending(artifact.label)
};
DownloadProgress {
downloaded,
total: model.download_size(dspark_enabled),
total: artifact.size,
phase,
verification,
}
}
pub(crate) fn download(
model: ModelChoice,
dspark_enabled: bool,
pub(crate) fn artifact_verification_progress(
id: ManagedArtifactId,
verified: u64,
) -> DownloadProgress {
let artifact = id.artifact();
DownloadProgress {
downloaded: artifact.size,
total: artifact.size,
phase: DownloadPhase::Verifying(artifact.label),
verification: Some(VerificationProgress {
verified: verified.min(artifact.size),
total: artifact.size,
}),
}
}
pub(crate) fn download_managed_artifact(
id: ManagedArtifactId,
models_path: &Path,
cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> {
for artifact in model.artifacts(dspark_enabled) {
if download_artifact_with_cancel(model, artifact, models_path, cancel)?
== DownloadOutcome::Stopped
{
return Ok(DownloadOutcome::Stopped);
download_artifact_with_cancel(
id.model(),
id.artifact(),
models_path,
cancel,
verified_bytes,
)
}
pub(crate) fn validate_managed_artifact(
id: ManagedArtifactId,
models_path: &Path,
cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> {
let model = id.model();
let artifact = id.artifact();
let destination = artifact.path(model, models_path);
let partial = artifact.partial_path(model, models_path);
let (path, promote) = if destination.exists() {
(destination.clone(), false)
} else if partial.exists() {
(partial.clone(), true)
} else {
return Err(format!("{} is not downloaded", artifact.label));
};
match verify(&path, artifact, cancel, verified_bytes) {
Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped),
Ok(DownloadOutcome::Complete) => {}
Err(error) => {
let marker = artifact.verification_path(model, models_path);
if let Err(remove_error) = fs::remove_file(marker)
&& remove_error.kind() != std::io::ErrorKind::NotFound
{
return Err(format!(
"{error}; could not remove checksum marker: {remove_error}"
));
}
return Err(error);
}
}
if promote {
fs::rename(partial, destination).map_err(|error| error.to_string())?;
}
mark_verified(model, artifact, models_path)?;
Ok(DownloadOutcome::Complete)
}
pub(crate) fn delete_managed_artifact(
id: ManagedArtifactId,
models_path: &Path,
) -> Result<(), String> {
let model = id.model();
let artifact = id.artifact();
for path in [
artifact.path(model, models_path),
artifact.partial_path(model, models_path),
artifact.verification_path(model, models_path),
] {
match fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.to_string()),
}
}
Ok(())
}
#[cfg(test)]
fn download_artifact(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
) -> Result<DownloadOutcome, String> {
download_artifact_with_cancel(model, artifact, models_path, &AtomicBool::new(false))
download_artifact_with_cancel(
model,
artifact,
models_path,
&AtomicBool::new(false),
&AtomicU64::new(0),
)
}
fn download_artifact_with_cancel(
@@ -242,6 +446,7 @@ fn download_artifact_with_cancel(
artifact: &Artifact,
models_path: &Path,
cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
@@ -251,7 +456,7 @@ fn download_artifact_with_cancel(
return Ok(DownloadOutcome::Complete);
}
if destination.exists() {
if verify(&destination, artifact, cancel)? == DownloadOutcome::Stopped {
if verify(&destination, artifact, cancel, verified_bytes)? == DownloadOutcome::Stopped {
return Ok(DownloadOutcome::Stopped);
}
mark_verified(model, artifact, models_path)?;
@@ -276,7 +481,7 @@ fn download_artifact_with_cancel(
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
match verify(&partial, artifact, cancel) {
match verify(&partial, artifact, cancel, verified_bytes) {
Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped),
Ok(DownloadOutcome::Complete) => {}
Err(error) => {
@@ -307,7 +512,9 @@ fn verify(
path: &Path,
artifact: &Artifact,
cancel: &AtomicBool,
verified_bytes: &AtomicU64,
) -> Result<DownloadOutcome, String> {
verified_bytes.store(0, Ordering::Relaxed);
let size = path.metadata().map_err(|error| error.to_string())?.len();
if size != artifact.size {
return Err(format!(
@@ -329,6 +536,7 @@ fn verify(
break;
}
hasher.update(&buffer[..count]);
verified_bytes.fetch_add(count as u64, Ordering::Relaxed);
}
let actual = hex(&hasher.finalize());
if actual != artifact.sha256 {
@@ -476,6 +684,59 @@ mod tests {
fs::remove_dir_all(models_path).unwrap();
}
#[test]
fn verification_reports_bytes_read() {
let path = std::env::temp_dir().join(format!(
"ds4-server-verify-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::write(&path, b"abc").unwrap();
let artifact = Artifact {
label: "test model",
file_name: "unused",
repository: "unused",
size: 3,
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
};
let verified_bytes = AtomicU64::new(999);
assert_eq!(
verify(&path, &artifact, &AtomicBool::new(false), &verified_bytes,).unwrap(),
DownloadOutcome::Complete
);
assert_eq!(verified_bytes.load(Ordering::Relaxed), 3);
fs::remove_file(path).unwrap();
}
#[test]
fn managed_artifact_inventory_and_delete_include_partial_files() {
let models_path = std::env::temp_dir().join(format!(
"ds4-server-manager-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let id = ManagedArtifactId::DeepSeekV4Flash;
let partial = id.artifact().partial_path(id.model(), &models_path);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
fs::write(&partial, b"part").unwrap();
let managed = managed_artifacts(&models_path)
.into_iter()
.find(|artifact| artifact.id == id)
.unwrap();
assert_eq!(managed.stored, 4);
assert_eq!(managed.state, ManagedArtifactState::Partial);
delete_managed_artifact(id, &models_path).unwrap();
assert!(!partial.exists());
fs::remove_dir_all(models_path).unwrap();
}
#[test]
fn restart_resumes_at_the_existing_partial_byte() {
let content = b"restart-resume works";
@@ -557,6 +818,7 @@ mod tests {
&artifact,
&directory,
&cancel,
&AtomicU64::new(0),
)
.unwrap(),
DownloadOutcome::Stopped

90
src/native_menu.rs Normal file
View File

@@ -0,0 +1,90 @@
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
const PREFERENCES: &str = "preferences";
const MODEL_MANAGER: &str = "model-manager";
pub(crate) struct NativeMenu {
_menu: Menu,
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum NativeMenuEvent {
Preferences,
ModelManager,
}
pub(crate) fn install() -> Result<NativeMenu, String> {
let menu = Menu::new();
let application = Submenu::new("DS4Server", true);
let edit = Submenu::new("Edit", true);
let window = Submenu::new("Window", true);
let preferences = MenuItem::with_id(
PREFERENCES,
"Preferences…",
true,
Some(Accelerator::new(Some(Modifiers::SUPER), Code::Comma)),
);
let model_manager = MenuItem::with_id(
MODEL_MANAGER,
"Model Manager…",
true,
Some(Accelerator::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyM,
)),
);
application
.append_items(&[
&PredefinedMenuItem::about(None, None),
&PredefinedMenuItem::separator(),
&preferences,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
])
.map_err(|error| error.to_string())?;
edit.append_items(&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
])
.map_err(|error| error.to_string())?;
window
.append_items(&[
&model_manager,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::close_window(None),
&PredefinedMenuItem::bring_all_to_front(None),
])
.map_err(|error| error.to_string())?;
menu.append_items(&[&application, &edit, &window])
.map_err(|error| error.to_string())?;
menu.init_for_nsapp();
window.set_as_windows_menu_for_nsapp();
Ok(NativeMenu { _menu: menu })
}
pub(crate) fn next_event() -> Option<NativeMenuEvent> {
while let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == PREFERENCES {
return Some(NativeMenuEvent::Preferences);
}
if event.id == MODEL_MANAGER {
return Some(NativeMenuEvent::ModelManager);
}
}
None
}