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
91 changes: 91 additions & 0 deletions apps/wisp-desktop/src/about_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Small "About Wisp" window opened from the application menu.

use gpui::{
App, Bounds, Context, IntoElement, ParentElement, Render, Styled, TitlebarOptions, Window,
WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size,
};

actions!(wisp_desktop, [CloseAbout]);

pub struct AboutView;

impl Render for AboutView {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
let app_version = env!("CARGO_PKG_VERSION");
let audiokit_version = wisp_audiokit::version();

div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0x0b_0e13))
.text_color(rgb(0xe8_eaed))
.p_6()
.gap_3()
.child(
div()
.text_xl()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child("Wisp"),
)
.child(
div()
.text_sm()
.text_color(rgb(0x8a_8f98))
.child(format!("Version {app_version}")),
)
.child(
div()
.text_sm()
.text_color(rgb(0x8a_8f98))
.child(format!("WispAudioKit {audiokit_version}")),
)
.child(
div()
.text_sm()
.text_color(rgb(0x5c_606b))
.child("Fully offline meeting transcription for macOS."),
)
.child(div().flex_grow())
.child(
div().flex().justify_end().child(
div()
.id("about-ok")
.px_3()
.py_1p5()
.bg(rgb(0x13_171f))
.border_1()
.border_color(rgb(0x1f_242e))
.rounded_md()
.cursor_pointer()
.child("OK")
.on_click(|_, window, _| {
window.remove_window();
}),
),
)
.on_action(|_: &CloseAbout, window, _| {
window.remove_window();
})
}
}

pub fn open(cx: &mut App) {
let bounds = Bounds::centered(None, size(px(360.0), px(240.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some("About Wisp".into()),
..Default::default()
}),
..Default::default()
},
|_, cx| cx.new(|_| AboutView),
)
.expect("failed to open About window");
}
97 changes: 97 additions & 0 deletions apps/wisp-desktop/src/app_menu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! macOS menu bar: application menu (About, Quit) and Cmd+Q.

use std::sync::Arc;
use std::time::Duration;

use gpui::{App, Entity, KeyBinding, Menu, MenuItem, actions};

use crate::about_view;
use crate::app::{AppModel, SessionState};
use crate::library::SharedStorage;
use crate::session_runner::SessionRunner;
use crate::session_updates::apply_update;

actions!(wisp_desktop, [Quit, About]);

/// Wire up the menu bar, keyboard shortcuts, and quit handlers.
pub fn configure(
cx: &mut App,
runner: Arc<SessionRunner>,
storage: SharedStorage,
model: Entity<AppModel>,
) {
let runner_for_quit = runner.clone();
let model_for_quit = model.clone();
let storage_for_quit = storage.clone();

cx.on_action(move |_: &Quit, cx| {
graceful_stop_session(&runner_for_quit, &model_for_quit, &storage_for_quit, cx);
cx.quit();
});

cx.on_action(|_: &About, cx| {
about_view::open(cx);
});

cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);

cx.set_menus(vec![Menu {
name: "Wisp".into(),
items: vec![
MenuItem::action("About Wisp", About),
MenuItem::separator(),
MenuItem::action("Quit Wisp", Quit),
],
}]);

let runner_for_shutdown = runner;
let model_for_shutdown = model;
let storage_for_shutdown = storage;
let _ = cx.on_app_quit(move |cx| {
graceful_stop_session(
&runner_for_shutdown,
&model_for_shutdown,
&storage_for_shutdown,
cx,
);
async move {}
});
}

/// If a recording is active (or stopping), request stop and wait for the
/// worker to finish so segments can be persisted before exit.
fn graceful_stop_session(
runner: &SessionRunner,
model: &Entity<AppModel>,
storage: &SharedStorage,
cx: &mut App,
) {
let needs_stop = model.read(cx).state;
let needs_stop = matches!(
needs_stop,
SessionState::Recording { .. } | SessionState::Starting
);
if needs_stop {
runner.stop();
model.update(cx, |m, cx| {
m.set_state(SessionState::Stopping);
cx.notify();
});
}

let should_wait = matches!(
model.read(cx).state,
SessionState::Recording { .. } | SessionState::Starting | SessionState::Stopping
);
if !should_wait {
return;
}

let updates = runner.wait_for_idle(Duration::from_secs(5));
let _ = model.update(cx, |m, cx| {
for update in updates {
apply_update(update, m, storage);
}
cx.notify();
});
}
65 changes: 9 additions & 56 deletions apps/wisp-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,21 @@ use gpui::{
use wisp_core::SessionId;
use wisp_storage::Storage;

mod about_view;
mod app;
mod app_menu;
mod library;
mod permissions;
mod session_runner;
mod session_updates;
mod transcript_view;

use app::{AppModel, SessionState};
use app_menu::configure as configure_app_menu;
use library::SharedStorage;
use session_runner::{SessionRunner, Update};
use transcript_view::{TranscriptView, cursor_blink_period, now, ui_tick_period};
use session_runner::SessionRunner;
use session_updates::apply_update;
use transcript_view::{TranscriptView, cursor_blink_period, ui_tick_period};

/// How often we re-poll permission status from the OS while the
/// onboarding screen is up. The user might flip the toggle in System
Expand Down Expand Up @@ -87,6 +92,8 @@ fn main() {
recordings_dir,
);

configure_app_menu(cx, runner.clone(), storage.clone(), model.clone());

spawn_session_update_pump(cx, runner, storage, model.clone());
spawn_cursor_blink(cx, window);
spawn_permission_refresh(cx, model);
Expand Down Expand Up @@ -194,60 +201,6 @@ fn spawn_session_update_pump(
.detach();
}

fn apply_update(
update: Update,
model: &mut AppModel,
storage: &SharedStorage,
) {
match update {
Update::Started => {
let started_at = Utc::now();
model.set_state(SessionState::Recording { started_at: now() });
// Best-effort: if the DB write fails we still let the user
// record; we just won't persist this session.
if let Ok(store) = storage.lock() {
let dir_name = library::session_dir_name(started_at);
if let Ok(session_id) = library::create_session(&store, started_at, &dir_name) {
model.current_session_id = Some(session_id);
}
}
},
Update::Event(e) => model.ingest(e),
Update::Stopped => {
model.finalize_all_segments();
model.set_state(SessionState::Idle);
persist_finished_session(model, storage);
},
Update::Error(msg) => {
// Clear the in-flight DB row — without this it dangles in the
// library forever with ended_at = NULL.
if let Some(id) = model.current_session_id.take()
&& let Ok(store) = storage.lock()
{
let _ = store.sessions().delete(id);
}
model.fail(msg);
},
}
}

fn persist_finished_session(
model: &mut AppModel,
storage: &SharedStorage,
) {
let Some(session_id) = model.current_session_id.take() else {
return;
};
let Ok(store) = storage.lock() else {
return;
};
let ended_at = Utc::now();
let _ = library::finalise_session(&store, session_id, &model.segments, ended_at);
if let Ok(list) = store.sessions().list() {
model.set_library(list);
}
}

/// Toggle the ghost-text cursor and refresh the status-bar elapsed counter.
fn spawn_cursor_blink(
cx: &mut App,
Expand Down
24 changes: 24 additions & 0 deletions apps/wisp-desktop/src/session_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ impl SessionRunner {
}
out
}

/// Block until the worker reports `Stopped`/`Error`, or `timeout` elapses.
/// Used when quitting so in-flight recordings can be finalised.
pub fn wait_for_idle(
&self,
timeout: Duration,
) -> Vec<Update> {
let deadline = std::time::Instant::now() + timeout;
let mut collected = Vec::new();
loop {
collected.extend(self.drain_updates());
if collected
.iter()
.any(|u| matches!(u, Update::Stopped | Update::Error(_)))
{
break;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(CMD_POLL_INTERVAL);
}
collected
}
}

impl Drop for SessionRunner {
Expand Down
60 changes: 60 additions & 0 deletions apps/wisp-desktop/src/session_updates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Apply `SessionRunner` updates to `AppModel` and persist at session boundaries.

use chrono::Utc;

use crate::app::{AppModel, SessionState};
use crate::library;
use crate::library::SharedStorage;
use crate::session_runner::Update;
use crate::session_runner::Update::{Error, Event, Started, Stopped};
use crate::transcript_view::now;

pub fn apply_update(
update: Update,
model: &mut AppModel,
storage: &SharedStorage,
) {
match update {
Started => {
let started_at = Utc::now();
model.set_state(SessionState::Recording { started_at: now() });
if let Ok(store) = storage.lock() {
let dir_name = library::session_dir_name(started_at);
if let Ok(session_id) = library::create_session(&store, started_at, &dir_name) {
model.current_session_id = Some(session_id);
}
}
},
Event(e) => model.ingest(e),
Stopped => {
model.finalize_all_segments();
model.set_state(SessionState::Idle);
persist_finished_session(model, storage);
},
Error(msg) => {
if let Some(id) = model.current_session_id.take()
&& let Ok(store) = storage.lock()
{
let _ = store.sessions().delete(id);
}
model.fail(msg);
},
}
}

fn persist_finished_session(
model: &mut AppModel,
storage: &SharedStorage,
) {
let Some(session_id) = model.current_session_id.take() else {
return;
};
let Ok(store) = storage.lock() else {
return;
};
let ended_at = Utc::now();
let _ = library::finalise_session(&store, session_id, &model.segments, ended_at);
if let Ok(list) = store.sessions().list() {
model.set_library(list);
}
}