add preferences and macOS packaging

This commit is contained in:
Georg Bauer
2026-07-24 12:23:50 +02:00
parent 2e1de825ab
commit 579e1030b2
13 changed files with 591 additions and 79 deletions

View File

@@ -11,7 +11,7 @@ Run before every commit:
```sh
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --all-targets --all-features
make bundle
cargo test --all-features
```

View File

@@ -11,7 +11,12 @@ diesel_migrations = "2.3.2"
iced = { version = "0.13.1", features = ["svg"] }
rfd = "0.15.4"
[package.metadata.bundle]
name = "DS4Server"
[package.metadata.packager]
product-name = "DS4Server"
identifier = "DS4Server.rfc1437.de"
osx_minimum_system_version = "13.0"
description = "A native macOS coding-agent GUI for DwarfStar"
binaries = [{ path = "ds4-server", main = true }]
icons = ["assets/DS4Server.icns", "assets/app-icon.png"]
[package.metadata.packager.macos]
minimum-system-version = "13.0"

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
.PHONY: bundle
bundle:
cargo build --release --all-targets --all-features
cargo packager --release --formats app
codesign --force --deep --sign - --timestamp=none target/release/DS4Server.app

120
PLAN.md
View File

@@ -8,11 +8,30 @@ reference: `ds4.c`/`ds4.h` define model and session behavior,
`ds4_server.c` defines the compatible API, and `ds4_agent.c` defines agent
sessions, tools, and turn orchestration.
## Phase 0 — project and session shell (implemented)
## Current status
| Area | Status | Available now | Still open |
| --- | --- | --- | --- |
| App shell | Implemented | Native Iced window, Codex-inspired two-pane layout, dark theme, embedded SVG icons, `Command-,` Preferences panel | Functional chat content and native app packaging |
| Projects and sessions | Implemented for metadata | Native folder picker, project-name dialog, create/select/delete projects and sessions | Transcripts, KV payloads, rename/archive, and model binding |
| Persistence | Implemented for metadata and preferences | SQLite in Application Support, Diesel ORM, embedded migrations, constrained defaults and CRUD tests | Messages, downloaded-artifact metadata, and session-runtime migrations |
| Model runtime | Preferences only | Persisted model choice, DFlash toggle, and idle timeout | Downloads, loading, inference, idle unloading, and Metal integration |
| Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces |
| Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation |
| Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization |
The application currently manages durable project/session metadata and model
preferences. It does not yet download or load a model, serve HTTP, save chat
messages, or run an agent.
## Phase 0 — project and session shell
Status: **implemented**.
- Show projects in a sidebar with their sessions nested underneath.
- Create projects with a native macOS folder picker and a project-name dialog.
- Create, select, and delete projects and sessions.
- Use a Codex-inspired two-pane layout with embedded, action-specific SVG icons.
- Persist projects and sessions in SQLite under
`~/Library/Application Support/DS4Server.rfc1437.de/`, using Diesel ORM
queries and embedded Diesel migrations.
@@ -22,6 +41,9 @@ Exit criterion: restart the app and see the same project/session tree.
## Phase 1 — model library and on-demand loading
Status: **partially implemented**. Preferences and their schema are complete;
model downloads, loading, inference, and idle unloading remain open.
1. Port the narrow GGUF loader, tokenizer, prompt renderer, and Metal session
API behind a small Rust `Engine` surface modeled on `ds4.h`:
`open`, `close`, `create_session`, `sync`, `sample`, and KV save/load.
@@ -30,31 +52,76 @@ Exit criterion: restart the app and see the same project/session tree.
3. Reuse the existing `.metal` kernels and preserve mmap-backed resident
loading plus the explicit SSD-streaming path. Keep Objective-C only at the
Metal interop boundary.
4. Add a model library screen that records GGUF paths and loading options.
Load only after the user chooses a model. Expose
`Unloaded → Loading → Ready/Failed → Unloading` state and progress in Iced.
5. Keep one engine resident initially, matching DwarfStar's instance-lock and
memory assumptions. Switching models unloads the current engine first.
6. Move engine work off the UI thread and support cooperative cancellation at
4. Keep one engine resident at most, matching DwarfStar's instance-lock and
memory assumptions.
5. Move engine work off the UI thread and support cooperative cancellation at
the safe session boundaries already defined by `ds4_session_sync`.
Persist model references and tuning options in Application Support. For a
sandboxed distribution, store security-scoped bookmarks rather than assuming
raw paths remain accessible.
### Lazy lifecycle
- Start the application and the local HTTP endpoint with no model loaded.
- Acquire the configured model only when generation starts, whether initiated
by a local chat or an OpenAI-compatible endpoint request. Concurrent first
requests share the same load instead of opening the model more than once.
- Expose `Unloaded → Downloading → Loading → Ready → Unloading` plus failure
states and progress in the GUI. A request that triggered loading waits for
the shared load and receives a clear error if download or initialization
fails.
- Treat active local generations, endpoint requests, prefills, and tool turns
as model activity. Start the idle timer only when all activity has stopped.
- Unload the engine after the configured idle timeout. New work cancels a
pending unload; work arriving after unload transparently loads the model
again. Persisted transcripts and KV files remain available when model memory
is released.
- Changing the selected model prevents new work from using the old model and
unloads it as soon as current work reaches a safe boundary.
### Preferences (implemented)
- Provide a Preferences panel from the sidebar and the standard
macOS `Command-,` shortcut.
- Let the user choose the active model. Default to `deepseek-v4-flash`.
- Let the user enable or disable DFlash for the selected model. Disable the
control for models that do not support DFlash.
- Let the user configure the inactivity timeout; default to 10 minutes.
- Persist preferences in the application SQLite database. Changing preferences
never loads a model by itself.
### Targeted downloads and storage
- Store managed model artifacts under
`~/Library/Application Support/DS4Server.rfc1437.de/models/<model-id>/` and
mmap/load them from there. Do not place managed models in the app bundle or a
project directory.
- Download only the selected model's main artifact. Download its DFlash
artifact only when DFlash is enabled; never prefetch other catalog models or
optional artifacts.
- Verify size/checksum before making a download usable, resume partial
downloads, and remove failed temporary files without touching a valid model.
- When the selected model changes or DFlash is disabled, offer removal of the
now-unused artifact; do not delete large existing downloads without explicit
confirmation.
Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without
blocking the UI, run a deterministic prompt, unload it, and pass the matching
`../ds4` token-output fixture.
blocking the UI only when a chat begins, run a deterministic prompt, unload it
after the configured idle timeout, and pass the matching `../ds4` token-output
fixture. Repeat through the local endpoint and verify both paths share the same
engine lifecycle.
## Phase 2 — OpenAI-compatible local endpoint
1. Add a localhost-only HTTP service owned by the loaded engine. Start and stop
it from the GUI; never expose a LAN listener without an explicit setting.
Status: **open; not started**.
1. Add a localhost-only HTTP service whose lifecycle is independent of the
engine. It can listen while the model is unloaded and acquires the configured
model only for inference. Start and stop it from the GUI; never expose a LAN
listener without an explicit setting.
2. Implement the DwarfStar compatibility surface in this order:
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/responses`
- `POST /v1/completions`
`GET /v1/models` reports the configured model without forcing it to load.
3. Support streaming SSE, reasoning output, usage accounting, cancellation,
sampling parameters, tool schemas, and tool choice.
4. Port exact sampled tool-call replay and deterministic canonicalization from
@@ -72,11 +139,17 @@ multi-turn tool call.
## Phase 3 — durable agent sessions
Status: **partially open**. Project/session metadata rows exist; transcripts,
model binding, and KV payload persistence are not implemented.
Replace each metadata-only session with a directory:
```text
Application Support/DS4Server.rfc1437.de/
data.sqlite3
models/<model-id>/
main.gguf
dflash.gguf # only when enabled
sessions/<session-id>/
kv.bin
```
@@ -97,6 +170,9 @@ the same conversation without prefill when its KV payload is compatible.
## Phase 4 — agent chat and tools
Status: **UI shell only**. The conversation area, composer, and icons are
visual placeholders with no message or agent runtime behind them.
1. Build the classic chat surface: transcript, reasoning disclosure, tool-call
cards, composer, queued input, stop button, prefill progress, and generation
statistics.
@@ -123,6 +199,11 @@ continue from the persisted session.
## Verification and packaging
Status: **partially implemented**. Formatting, Clippy, build, and unit-test
commit gates are documented in `AGENTS.md`; database migration/CRUD constraints
and the preferences shortcut have unit coverage. Model fixtures, integration
tests, and release packaging remain open.
- Keep unit coverage narrow: persistence round trips, format compatibility,
request parsing, and tool-boundary checks.
- Reuse `../ds4` fixtures for prompt rendering, sampling, server streaming, and
@@ -131,3 +212,14 @@ continue from the persisted session.
reused or adapted source and kernels.
- Produce a signed `.app` with the exact identifier above, then add hardened
runtime, entitlements, notarization, and update delivery as release work.
## Recommended next milestones
1. Add the targeted download manager and model catalog, storing only selected
artifacts under Application Support with resumable, verified downloads.
2. Implement the single-engine lifecycle and local generation path: lazy load,
shared concurrent acquisition, activity tracking, and idle unload.
3. Put the OpenAI-compatible endpoint on that same engine lifecycle so local
chats and HTTP requests cannot load duplicate engines.
4. Add transcript/KV persistence, then connect the existing conversation UI and
finally port the agent tools.

View File

@@ -6,10 +6,14 @@ OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app.
The current milestone provides a Codex-inspired project/session layout. A native
macOS folder picker selects each workspace, then the app asks for its display
name. Projects and sessions are persisted through Diesel in SQLite.
name. Projects, sessions, and model preferences are persisted through Diesel in
SQLite. Open Preferences with `Command-,` to select the model, toggle DFlash,
and configure the model idle timeout.
```sh
cargo run
cargo install cargo-packager --locked --version 0.11.8
make bundle
open target/release/DS4Server.app
```
State is stored at:

BIN
assets/DS4Server.icns Normal file

Binary file not shown.

BIN
assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.4 2.75h3.2l.55 2a6.3 6.3 0 0 1 1.2.7l2-.55 1.6 2.75-1.45 1.45q.1.9 0 1.8l1.45 1.45-1.6 2.75-2-.55a6.3 6.3 0 0 1-1.2.7l-.55 2H8.4l-.55-2a6.3 6.3 0 0 1-1.2-.7l-2 .55-1.6-2.75L4.5 10.9q-.1-.9 0-1.8L3.05 7.65l1.6-2.75 2 .55a6.3 6.3 0 0 1 1.2-.7z"/>
<circle cx="10" cy="10" r="2.25"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -0,0 +1 @@
DROP TABLE preferences;

View File

@@ -0,0 +1,12 @@
CREATE TABLE preferences (
id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1),
selected_model TEXT NOT NULL DEFAULT 'deepseek-v4-flash'
CHECK (selected_model IN ('deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.2')),
dflash_enabled BOOLEAN NOT NULL DEFAULT 0
CHECK (dflash_enabled IN (0, 1)),
idle_timeout_minutes INTEGER NOT NULL DEFAULT 10
CHECK (idle_timeout_minutes BETWEEN 1 AND 1440),
CHECK (dflash_enabled = 0 OR selected_model = 'deepseek-v4-flash')
);
INSERT INTO preferences (id) VALUES (1);

View File

@@ -4,10 +4,39 @@ use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::schema::{projects, sessions};
use crate::schema::{preferences, projects, sessions};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = preferences)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct AppPreferences {
pub id: i32,
pub selected_model: String,
pub dflash_enabled: bool,
pub idle_timeout_minutes: i32,
}
impl Default for AppPreferences {
fn default() -> Self {
Self {
id: 1,
selected_model: "deepseek-v4-flash".into(),
dflash_enabled: false,
idle_timeout_minutes: 10,
}
}
}
#[derive(AsChangeset)]
#[diesel(table_name = preferences)]
struct PreferenceChanges<'a> {
selected_model: &'a str,
dflash_enabled: bool,
idle_timeout_minutes: i32,
}
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = projects)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
@@ -95,6 +124,31 @@ impl Database {
.collect())
}
pub fn load_preferences(&mut self) -> Result<AppPreferences, String> {
preferences::table
.find(1)
.select(AppPreferences::as_select())
.first(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn update_preferences(
&mut self,
selected_model: &str,
dflash_enabled: bool,
idle_timeout_minutes: i32,
) -> Result<AppPreferences, String> {
diesel::update(preferences::table.find(1))
.set(PreferenceChanges {
selected_model,
dflash_enabled,
idle_timeout_minutes,
})
.returning(AppPreferences::as_returning())
.get_result(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> {
diesel::insert_into(projects::table)
.values(NewProject { name, path })
@@ -144,6 +198,18 @@ mod tests {
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
let mut database = Database::open(&path).unwrap();
let preferences = database.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
assert!(!preferences.dflash_enabled);
assert_eq!(preferences.idle_timeout_minutes, 10);
assert!(database.update_preferences("glm-5.2", true, 30).is_err());
assert!(
database
.update_preferences("deepseek-v4-flash", false, 0)
.is_err()
);
database.update_preferences("glm-5.2", false, 30).unwrap();
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
let first = database
.create_session(project.id, "First session")
@@ -158,6 +224,12 @@ mod tests {
database.delete_project(project.id).unwrap();
assert!(database.load_projects().unwrap().is_empty());
drop(database);
let mut reopened = Database::open(&path).unwrap();
let preferences = reopened.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "glm-5.2");
assert_eq!(preferences.idle_timeout_minutes, 30);
drop(reopened);
fs::remove_file(path).unwrap();
}
}

View File

@@ -1,13 +1,15 @@
mod database;
mod schema;
use database::{Database, ProjectWithSessions, Session};
use iced::theme::Palette;
use database::{AppPreferences, Database, ProjectWithSessions, Session};
use iced::theme::{Palette, palette};
use iced::widget::{
Space, Svg, button, column, container, opaque, row, scrollable, stack, svg, text, text_input,
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
svg, text, text_input,
};
use iced::{Alignment, Color, Element, Length, Size, Task, Theme};
use iced::{Alignment, Color, Element, Length, Size, Subscription, Task, Theme, keyboard};
use rfd::AsyncFileDialog;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
@@ -16,7 +18,7 @@ const ICON_FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg");
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg");
const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg");
const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg");
const ICON_SEARCH: &[u8] = include_bytes!("../assets/icons/search.svg");
const ICON_SETTINGS: &[u8] = include_bytes!("../assets/icons/settings.svg");
const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg");
const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg");
const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg");
@@ -24,20 +26,71 @@ const ICON_SEND: &[u8] = include_bytes!("../assets/icons/send.svg");
const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg");
const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg");
const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
fn supports_dflash(self) -> bool {
self == Self::DeepSeekV4Flash
}
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
#[derive(Clone)]
struct PreferenceDraft {
model: ModelChoice,
dflash_enabled: bool,
idle_timeout_minutes: String,
}
impl PreferenceDraft {
fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
let model = ModelChoice::from_id(&preferences.selected_model)
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
Ok(Self {
model,
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
})
}
}
fn main() -> iced::Result {
iced::application("DS4Server", App::update, App::view)
.theme(|_| {
Theme::custom(
"DS4Server".into(),
Palette {
background: Color::from_rgb8(29, 29, 31),
text: Color::from_rgb8(235, 235, 235),
primary: Color::from_rgb8(85, 118, 255),
success: Color::from_rgb8(72, 176, 112),
danger: Color::from_rgb8(220, 80, 86),
},
)
})
.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)),
@@ -49,17 +102,26 @@ fn main() -> iced::Result {
struct App {
database: Option<Database>,
projects: Vec<ProjectWithSessions>,
preferences: AppPreferences,
preference_draft: PreferenceDraft,
preferences_open: bool,
preference_error: Option<String>,
selected_project: Option<i32>,
selected_session: Option<i32>,
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
session_title_input: String,
error: Option<String>,
}
#[derive(Debug, Clone)]
enum Message {
OpenPreferences,
DismissPanel,
PreferenceModelChanged(ModelChoice),
PreferenceDflashChanged(bool),
PreferenceTimeoutChanged(String),
SavePreferences,
ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String),
@@ -67,7 +129,6 @@ enum Message {
CancelProject,
SelectProject(i32),
DeleteProject(i32),
SessionTitleChanged(String),
CreateSession,
SelectSession(i32, i32),
DeleteSession(i32),
@@ -77,40 +138,83 @@ impl App {
fn load() -> Self {
let path = application_support_path().join("data.sqlite3");
match Database::open(&path) {
Ok(mut database) => match database.load_projects() {
Ok(projects) => Self {
database: Some(database),
projects,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
session_title_input: String::new(),
error: None,
},
Err(error) => Self::failed(error),
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),
};
Self {
database: Some(database),
projects,
preferences,
preference_draft,
preferences_open: false,
preference_error: None,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
error: None,
}
}
(Err(error), _) | (_, Err(error)) => Self::failed(error),
},
Err(error) => Self::failed(error),
}
}
fn failed(error: String) -> Self {
let preferences = AppPreferences::default();
let preference_draft = PreferenceDraft::from_saved(&preferences)
.expect("default preferences must use a supported model");
Self {
database: None,
projects: Vec::new(),
preferences,
preference_draft,
preferences_open: false,
preference_error: None,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
session_title_input: String::new(),
error: Some(format!("Could not open the project database: {error}")),
}
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::OpenPreferences => self.open_preferences(),
Message::DismissPanel => {
if self.preferences_open {
self.preferences_open = false;
self.preference_error = None;
} else if self.pending_project_path.is_some() {
self.pending_project_path = None;
self.project_name_input.clear();
self.error = None;
}
}
Message::PreferenceModelChanged(model) => {
self.preference_draft.model = model;
if !model.supports_dflash() {
self.preference_draft.dflash_enabled = false;
}
self.preference_error = None;
}
Message::PreferenceDflashChanged(enabled) => {
self.preference_draft.dflash_enabled =
self.preference_draft.model.supports_dflash() && enabled;
self.preference_error = None;
}
Message::PreferenceTimeoutChanged(value) => {
self.preference_draft.idle_timeout_minutes = value;
self.preference_error = None;
}
Message::SavePreferences => self.save_preferences(),
Message::ChooseProjectFolder => {
self.choosing_folder = true;
return Task::perform(
@@ -156,7 +260,6 @@ impl App {
}
}
}
Message::SessionTitleChanged(value) => self.session_title_input = value,
Message::CreateSession => self.create_session(),
Message::SelectSession(project_id, session_id) => {
self.selected_project = Some(project_id);
@@ -180,6 +283,57 @@ impl App {
Task::none()
}
fn subscription(&self) -> Subscription<Message> {
keyboard::on_key_press(shortcut)
}
fn open_preferences(&mut self) {
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return;
}
match PreferenceDraft::from_saved(&self.preferences) {
Ok(draft) => {
self.preference_draft = draft;
self.preference_error = None;
self.preferences_open = true;
}
Err(error) => self.error = Some(error),
}
}
fn save_preferences(&mut self) {
let Ok(idle_timeout_minutes) = self
.preference_draft
.idle_timeout_minutes
.trim()
.parse::<i32>()
else {
self.preference_error = Some("Idle timeout must be a whole number.".into());
return;
};
if !(1..=1440).contains(&idle_timeout_minutes) {
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
return;
}
let model = self.preference_draft.model;
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled;
let Some(database) = &mut self.database else {
return;
};
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) {
Ok(preferences) => {
self.preferences = preferences;
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
.expect("the saved model was selected from the supported catalog");
self.preferences_open = false;
self.preference_error = None;
self.error = None;
}
Err(error) => self.preference_error = Some(error),
}
}
fn prepare_project(&mut self, path: PathBuf) {
let Ok(path) = fs::canonicalize(path) else {
self.error = Some("The selected folder is no longer available.".into());
@@ -246,10 +400,7 @@ impl App {
.selected_project()
.map(|project| project.sessions.len() + 1)
.unwrap_or(1);
let title = match self.session_title_input.trim() {
"" => format!("Session {default_number}"),
title => title.to_owned(),
};
let title = format!("Session {default_number}");
let Some(database) = &mut self.database else {
return;
};
@@ -257,7 +408,6 @@ impl App {
match database.create_session(project_id, &title) {
Ok(session) => {
self.selected_session = Some(session.id);
self.session_title_input.clear();
self.error = None;
self.reload_projects();
}
@@ -290,7 +440,9 @@ impl App {
let content: Element<'_, Message> = shell.into();
let mut layers = vec![content];
if let Some(path) = &self.pending_project_path {
if self.preferences_open {
layers.push(self.preferences_panel());
} else if let Some(path) = &self.pending_project_path {
layers.push(self.project_dialog(path));
}
@@ -311,6 +463,21 @@ impl App {
} else {
button(new_session_content).width(Length::Fill)
};
let preference_content = row![
icon(ICON_SETTINGS, 17),
text("Preferences").size(14),
Space::with_width(Length::Fill),
text("⌘,").size(12),
]
.spacing(9)
.align_y(Alignment::Center);
let preferences = if self.database.is_some() {
button(preference_content)
.width(Length::Fill)
.on_press(Message::OpenPreferences)
} else {
button(preference_content).width(Length::Fill)
};
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
.spacing(9)
.align_y(Alignment::Center);
@@ -321,14 +488,17 @@ impl App {
.width(Length::Fill)
.on_press(Message::ChooseProjectFolder)
};
let mut projects = column![
let header = column![
row![
text("DS4Server").size(20),
Space::with_width(Length::Fill),
icon(ICON_SEARCH, 18),
text("Local").size(12),
]
.align_y(Alignment::Center),
new_session.style(button::text),
]
.spacing(10);
let mut projects = column![
row![
text("PROJECTS").size(11),
Space::with_width(Length::Fill),
@@ -386,12 +556,19 @@ impl App {
}
}
container(scrollable(projects).height(Length::Fill))
.width(276)
.height(Length::Fill)
.padding(16)
.style(sidebar_style)
.into()
container(
column![
header,
scrollable(projects).height(Length::Fill),
preferences.style(button::text),
]
.spacing(10),
)
.width(276)
.height(Length::Fill)
.padding(16)
.style(sidebar_style)
.into()
}
fn detail(&self) -> Element<'_, Message> {
@@ -432,14 +609,6 @@ impl App {
text(selected_title).size(18),
icon(ICON_MORE, 18),
Space::with_width(Length::Fill),
text_input("Session title", &self.session_title_input)
.on_input(Message::SessionTitleChanged)
.on_submit(Message::CreateSession)
.width(190)
.padding(8),
button("New session")
.on_press(Message::CreateSession)
.style(button::primary),
]
.spacing(10)
.align_y(Alignment::Center);
@@ -461,7 +630,12 @@ impl App {
icon(ICON_PAPERCLIP, 19),
Space::with_width(Length::Fill),
icon(ICON_MODEL, 16),
text("Local model").size(12),
text(
ModelChoice::from_id(&self.preferences.selected_model)
.unwrap_or_default()
.to_string(),
)
.size(12),
button(icon(ICON_SEND, 18)),
]
.align_y(Alignment::Center),
@@ -505,6 +679,87 @@ impl App {
.into()
}
fn preferences_panel(&self) -> Element<'_, Message> {
let dflash_toggle: Option<fn(bool) -> Message> = self
.preference_draft
.model
.supports_dflash()
.then_some(Message::PreferenceDflashChanged);
let dflash = checkbox(
"Enable DFlash for this model",
self.preference_draft.dflash_enabled,
)
.on_toggle_maybe(dflash_toggle);
let mut content = column![
row![
icon(ICON_SETTINGS, 22),
text("Preferences").size(24),
Space::with_width(Length::Fill),
text("⌘,").size(12),
]
.spacing(10)
.align_y(Alignment::Center),
Space::with_height(6),
text("MODEL").size(11),
pick_list(
&MODEL_CHOICES[..],
Some(self.preference_draft.model),
Message::PreferenceModelChanged,
)
.width(Length::Fill),
dflash,
text(if self.preference_draft.model.supports_dflash() {
"DFlash is downloaded and used only when enabled."
} else {
"DFlash is not available for the selected model."
})
.size(12),
Space::with_height(8),
text("INACTIVITY").size(11),
row![
text_input("10", &self.preference_draft.idle_timeout_minutes)
.on_input(Message::PreferenceTimeoutChanged)
.width(90)
.padding(9),
text("minutes before unloading the model").size(13),
]
.spacing(10)
.align_y(Alignment::Center),
text("Enter a whole number from 1 to 1440.").size(12),
]
.spacing(10);
if let Some(error) = &self.preference_error {
content = content.push(text(error).style(iced::widget::text::danger));
}
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),
]
.spacing(8),
);
let panel = container(content)
.padding(24)
.width(520)
.style(container::rounded_box);
opaque(
container(panel)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_| {
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
}),
)
}
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
let dialog = container(
column![
@@ -556,6 +811,33 @@ impl App {
}
}
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::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
_ => None,
}
}
fn app_theme() -> Theme {
let palette = Palette {
background: Color::from_rgb8(29, 29, 31),
text: Color::from_rgb8(235, 235, 235),
primary: Color::from_rgb8(85, 118, 255),
success: Color::from_rgb8(72, 176, 112),
danger: Color::from_rgb8(220, 80, 86),
};
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
let mut extended = palette::Extended::generate(palette);
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
extended
})
}
fn sidebar_style(_: &Theme) -> container::Style {
container::Style::default().background(Color::from_rgb8(23, 23, 25))
}
@@ -577,3 +859,28 @@ fn application_support_path() -> PathBuf {
.join("Application Support")
.join(APP_ID)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferences_shortcut_and_dflash_support_are_explicit() {
let message = shortcut(
keyboard::Key::Character(",".into()),
keyboard::Modifiers::COMMAND,
);
assert!(matches!(message, Some(Message::OpenPreferences)));
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash());
assert!(!ModelChoice::Glm52.supports_dflash());
}
#[test]
fn raised_surfaces_stay_dark() {
let theme = app_theme();
let palette = theme.extended_palette();
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
}
}

View File

@@ -6,6 +6,15 @@ diesel::table! {
}
}
diesel::table! {
preferences (id) {
id -> Integer,
selected_model -> Text,
dflash_enabled -> Bool,
idle_timeout_minutes -> Integer,
}
}
diesel::table! {
sessions (id) {
id -> Integer,
@@ -15,4 +24,4 @@ diesel::table! {
}
diesel::joinable!(sessions -> projects (project_id));
diesel::allow_tables_to_appear_in_same_query!(projects, sessions);
diesel::allow_tables_to_appear_in_same_query!(preferences, projects, sessions);