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
10 changes: 9 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,17 @@ jobs:
# No Apple Developer ID yet; ad-hoc (`--sign -`) is enough to make
# Gatekeeper let users open the app via right-click → Open. Once a
# cert is available, swap in the real identity and add notarization.
#
# The entitlements file is required even for ad-hoc signing because
# we enable hardened runtime (--options runtime). Without
# `com.apple.security.device.audio-input`, AVCaptureDevice and
# AVAudioApplication silently return "denied" for the mic
# permission request — no OS prompt, no TCC entry.
run: |
set -euo pipefail
codesign --force --deep --options runtime --sign - Wisp.app
codesign --force --deep --options runtime \
--entitlements apps/wisp-desktop/wisp-desktop.entitlements \
--sign - Wisp.app
codesign --verify --verbose Wisp.app

- name: Create DMG
Expand Down
46 changes: 45 additions & 1 deletion apps/wisp-desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
use std::collections::VecDeque;
use std::time::Instant;

use wisp_audiokit::{Event, SessionError, SessionResult, SourceLabel};
use wisp_audiokit::{
Event, Permission, PermissionStatus, SessionError, SessionResult, SourceLabel,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState {
Expand All @@ -22,6 +24,46 @@ pub enum SessionState {
Failed,
}

/// Snapshot of every permission Wisp gates Record on.
///
/// The UI is allowed to enter the main transcript view once both fields
/// are `Granted`. While `pending` is `Some(p)`, a previous
/// `request_permission(p)` call is still waiting on the OS dialog — the
/// onboarding row for that permission shows a spinner instead of a button.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Permissions {
pub microphone: PermissionStatus,
pub speech: PermissionStatus,
pub pending: Option<Permission>,
}

impl Permissions {
pub fn unknown() -> Self {
Self {
microphone: PermissionStatus::Undetermined,
speech: PermissionStatus::Undetermined,
pending: None,
}
}

/// True when both required permissions are granted; the UI can show
/// the normal Record screen.
pub fn all_granted(self) -> bool {
self.microphone.is_granted() && self.speech.is_granted()
}

pub fn set_status(
&mut self,
perm: Permission,
status: PermissionStatus,
) {
match perm {
Permission::Microphone => self.microphone = status,
Permission::SpeechRecognition => self.speech = status,
}
}
}

#[derive(Debug, Clone)]
pub struct Segment {
pub source: SourceLabel,
Expand All @@ -43,6 +85,7 @@ pub struct AppModel {
pub segments: Vec<Segment>,
pub recent_log: VecDeque<String>,
pub last_error: Option<SessionError>,
pub permissions: Permissions,
}

impl AppModel {
Expand All @@ -52,6 +95,7 @@ impl AppModel {
segments: Vec::new(),
recent_log: VecDeque::new(),
last_error: None,
permissions: Permissions::unknown(),
}
}

Expand Down
221 changes: 141 additions & 80 deletions apps/wisp-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,29 @@

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

use gpui::{
AppContext, Application, AsyncApp, Bounds, Timer, TitlebarOptions, WindowBounds, WindowOptions,
px, size,
App, AppContext, Application, AsyncApp, Bounds, Entity, Timer, TitlebarOptions, WindowBounds,
WindowHandle, WindowOptions, px, size,
};

mod app;
mod permissions;
mod session_runner;
mod transcript_view;

use app::{AppModel, SessionState};
use session_runner::{SessionRunner, Update};
use transcript_view::{TranscriptView, cursor_blink_period, now, 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
/// Settings; without periodic re-checks we'd stay stuck on "Denied" until
/// they manually re-focus our window. 1.5s is unhurried but still feels
/// responsive when they come back.
const PERMISSION_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);

fn main() {
Application::new().run(|cx| {
cx.activate(true);
Expand All @@ -49,90 +58,142 @@ fn main() {
..Default::default()
};

let window = {
let runner = runner.clone();
let model_ref = model.clone();
let output_dir = output_dir.clone();
cx.open_window(window_options, move |_, cx| {
cx.new(|cx| {
let runner = runner.clone();
let model_ref = model_ref.clone();
let output_dir = output_dir.clone();
let view = TranscriptView {
app: model_ref.clone(),
cursor_visible: true,
scroll_handle: gpui::ScrollHandle::new(),
last_signature: (0, 0),
on_toggle_record: Arc::new(move |_window, cx| {
toggle_recording(&runner, &model_ref, &output_dir, cx);
}),
};
// Re-render whenever the underlying model changes.
cx.observe(&view.app, |_, _, cx| cx.notify()).detach();
view
})
})
.expect("failed to open Wisp window")
};
// Populate the model with the initial permission state so the
// window opens straight onto onboarding or the record screen,
// without a flash of the wrong content.
permissions::refresh(&model, cx);

// 1) Pump session-runner updates into the model.
{
let runner = runner.clone();
let model = model.clone();
cx.spawn(async move |cx: &mut AsyncApp| {
loop {
Timer::after(std::time::Duration::from_millis(33)).await;
let updates = runner.drain_updates();
if updates.is_empty() {
continue;
}
let result = model.update(cx, |model, cx| {
for u in updates {
match u {
Update::Started => {
model.set_state(SessionState::Recording { started_at: now() });
},
Update::Event(e) => model.ingest(e),
Update::Stopped => {
// Lock in whatever the analyzer last
// had — without this the trailing
// partial stays grey forever.
model.finalize_all_segments();
model.set_state(SessionState::Idle);
},
Update::Error(msg) => model.fail(msg),
}
}
cx.notify();
});
if result.is_err() {
// Window / app gone; stop pumping.
break;
let window = open_main_window(
cx,
window_options,
runner.clone(),
model.clone(),
output_dir,
);

spawn_session_update_pump(cx, runner, model.clone());
spawn_cursor_blink(cx, window);
spawn_permission_refresh(cx, model);
});
}

fn open_main_window(
cx: &mut App,
window_options: WindowOptions,
runner: Arc<SessionRunner>,
model: Entity<AppModel>,
output_dir: PathBuf,
) -> WindowHandle<TranscriptView> {
cx.open_window(window_options, move |_, cx| {
cx.new(|cx| {
let model_for_toggle = model.clone();
let model_for_request = model.clone();
let view = TranscriptView {
app: model.clone(),
cursor_visible: true,
scroll_handle: gpui::ScrollHandle::new(),
last_signature: (0, 0),
on_toggle_record: Arc::new(move |_window, cx| {
toggle_recording(&runner, &model_for_toggle, &output_dir, cx);
}),
on_request_permission: Arc::new(move |perm, _window, cx| {
permissions::request(perm, model_for_request.clone(), cx);
}),
on_open_settings: Arc::new(move |perm, _window, _cx| {
permissions::open_settings(perm);
// The next periodic permission refresh picks up the
// toggle once the user flips it in System Settings.
}),
};
// Re-render whenever the underlying model changes.
cx.observe(&view.app, |_, _, cx| cx.notify()).detach();
view
})
})
.expect("failed to open Wisp window")
}

/// Drain `SessionRunner` updates into the model every ~33ms.
fn spawn_session_update_pump(
cx: &mut App,
runner: Arc<SessionRunner>,
model: Entity<AppModel>,
) {
cx.spawn(async move |cx: &mut AsyncApp| {
loop {
Timer::after(Duration::from_millis(33)).await;
let updates = runner.drain_updates();
if updates.is_empty() {
continue;
}
let result = model.update(cx, |model, cx| {
for u in updates {
match u {
Update::Started => {
model.set_state(SessionState::Recording { started_at: now() });
},
Update::Event(e) => model.ingest(e),
Update::Stopped => {
// Lock in whatever the analyzer last had — without
// this the trailing partial stays grey forever.
model.finalize_all_segments();
model.set_state(SessionState::Idle);
},
Update::Error(msg) => model.fail(msg),
}
}
})
.detach();
cx.notify();
});
if result.is_err() {
break;
}
}
})
.detach();
}

// 2) Cursor blink + status-bar tick.
cx.spawn(async move |cx: &mut AsyncApp| {
let mut elapsed = std::time::Duration::ZERO;
loop {
Timer::after(ui_tick_period()).await;
elapsed += ui_tick_period();
let ticks = elapsed.as_millis() / cursor_blink_period().as_millis();
let blink = ticks.is_multiple_of(2);
let result = window.update(cx, |view, _, cx| {
view.cursor_visible = blink;
cx.notify();
});
if result.is_err() {
break;
}
/// Toggle the ghost-text cursor and refresh the status-bar elapsed counter.
fn spawn_cursor_blink(
cx: &mut App,
window: WindowHandle<TranscriptView>,
) {
cx.spawn(async move |cx: &mut AsyncApp| {
let mut elapsed = Duration::ZERO;
loop {
Timer::after(ui_tick_period()).await;
elapsed += ui_tick_period();
let ticks = elapsed.as_millis() / cursor_blink_period().as_millis();
let blink = ticks.is_multiple_of(2);
let result = window.update(cx, |view, _, cx| {
view.cursor_visible = blink;
cx.notify();
});
if result.is_err() {
break;
}
})
.detach();
});
}
})
.detach();
}

/// Re-read permission state from the OS on a fixed interval. The user may
/// have flipped a toggle in System Settings; we have no event-driven way
/// to learn about that, so we poll. Cheap (two
/// `AVAudioApplication`/`SFSpeechRecognizer` getters).
fn spawn_permission_refresh(
cx: &mut App,
model: Entity<AppModel>,
) {
cx.spawn(async move |cx: &mut AsyncApp| {
loop {
Timer::after(PERMISSION_REFRESH_INTERVAL).await;
let result = cx.update(|cx| permissions::refresh(&model, cx));
if result.is_err() {
break;
}
}
})
.detach();
}

fn toggle_recording(
Expand Down
Loading