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: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to Ulpaso will be documented in this file. The format follow

## [Unreleased]

## [0.5.3] - 2026-08-15

### Fixed

- Trust the bounded final diarization pass when it reduces noisy four-slot live output to two or more stable speakers, and compact sparse speaker numbers before writing Markdown.

### Added

- Added a two-step Settings action that removes downloaded transcription models, runtime files, tools, caches, and retained recovery audio before uninstalling Ulpaso.

## [0.5.2] - 2026-08-15

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Ulpaso currently requires:

Download the DMG, move Ulpaso to Applications, and open it normally. Current releases include checksums and a signed, notarized, stapled macOS build.

Before moving Ulpaso to the Trash, open **Settings → Local transcription data** and choose **Remove** twice. This deletes the downloaded runtime, speech and speaker models, and retained recovery audio; Markdown documents remain untouched in the folders where you saved them.

The editor UI can run in a browser on other platforms, but native saving, system-audio capture, meeting detection, and transcription require the macOS desktop app.

<a id="build-from-source"></a>
Expand Down
4 changes: 3 additions & 1 deletion docs/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ Ulpaso also contacts GitHub Releases to check for signed application updates. Th

## Removing local data

Quit Ulpaso, then remove its application data directory if you want to delete downloaded models, runtime files, and retained meeting recovery audio. Saving or moving Markdown documents is independent of this directory because the documents remain wherever you chose to store them.
Before moving Ulpaso to the Trash, open **Settings → Local transcription data** and confirm **Remove models & audio**. Ulpaso then deletes the downloaded models, local runtime and tools, model caches, and retained meeting recovery audio from its application data directory. This action is unavailable while a meeting is active.

macOS does not launch an application after it has been moved to the Trash, so the cleanup must be requested inside Ulpaso before removal. Saving or moving Markdown documents is independent of this action because the documents remain wherever you chose to store them.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ulpaso",
"version": "0.5.2",
"version": "0.5.3",
"private": true,
"description": "A local Markdown editor with on-device meeting transcription for macOS",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ulpaso"
version = "0.5.2"
version = "0.5.3"
description = "A quiet, focused Markdown editor"
authors = ["Ulpaso contributors"]
edition = "2021"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ pub fn run() {
updater::update_install,
meeting::meeting_status,
meeting::meeting_resources,
meeting::meeting_remove_local_data,
meeting::meeting_prepare,
meeting::meeting_start,
meeting::meeting_stop,
Expand Down
27 changes: 26 additions & 1 deletion src-tauri/src/meeting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use audio::{
};
use chrono::Utc;
use recovery::repair_recovery_wav_headers;
use resources::MeetingResourceStatus;
use resources::{MeetingResourceRemoval, MeetingResourceStatus};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
Expand Down Expand Up @@ -188,6 +188,24 @@ impl MeetingController {
Ok(resources::inspect(&resource_dir, &app_data))
}

pub fn remove_local_data(&self) -> Result<MeetingResourceRemoval, String> {
let active = self
.inner
.lock()
.map_err(|_| "Could not inspect the meeting state".to_string())?
.active
.is_some();
if active {
return Err("Finish the current meeting before removing transcription data".into());
}
let app_data = self
.app
.path()
.app_data_dir()
.map_err(|error| format!("Could not locate the app data folder: {error}"))?;
resources::remove_downloaded(&app_data)
}

pub fn start(
&self,
microphone_only: bool,
Expand Down Expand Up @@ -1382,6 +1400,13 @@ pub fn meeting_resources(
controller.resources()
}

#[tauri::command]
pub fn meeting_remove_local_data(
controller: tauri::State<'_, MeetingController>,
) -> Result<MeetingResourceRemoval, String> {
controller.remove_local_data()
}

#[tauri::command]
pub fn meeting_prepare(
controller: tauri::State<'_, MeetingController>,
Expand Down
76 changes: 74 additions & 2 deletions src-tauri/src/meeting/resources.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! Read-only inspection of local transcription resources.
//! Inspection and explicit removal of local transcription resources.
//!
//! The UI uses this before the first meeting so it can disclose the real
//! download and disk cost instead of beginning a multi-gigabyte setup without
//! context.

use serde::Serialize;
use std::path::{Path, PathBuf};
use std::{
fs,
path::{Path, PathBuf},
};

const RUNTIME_BYTES: u64 = 500_000_000;
const ASR_MODEL_BYTES: u64 = 1_020_000_000;
Expand All @@ -25,6 +28,12 @@ pub struct MeetingResourceStatus {
pub disk_space_sufficient: bool,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeetingResourceRemoval {
pub removed_bytes: u64,
}

pub(crate) fn inspect(resource_dir: &Path, app_data: &Path) -> MeetingResourceStatus {
let runtime_ready = runtime_candidates(resource_dir, app_data)
.iter()
Expand Down Expand Up @@ -89,6 +98,44 @@ fn model_ready(path: &Path) -> bool {
})
}

pub(crate) fn remove_downloaded(app_data: &Path) -> Result<MeetingResourceRemoval, String> {
let targets = [
app_data.join("Models"),
app_data.join("ASR Runtime"),
app_data.join("ASR Tools"),
app_data.join("Meeting Recovery"),
];
let removed_bytes = targets.iter().map(|path| directory_bytes(path)).sum();
for target in targets {
if !target.exists() {
continue;
}
if target.is_dir() {
fs::remove_dir_all(&target)
} else {
fs::remove_file(&target)
}
.map_err(|error| format!("Could not remove {}: {error}", target.display()))?;
}
Ok(MeetingResourceRemoval { removed_bytes })
}

fn directory_bytes(path: &Path) -> u64 {
let Ok(metadata) = fs::symlink_metadata(path) else {
return 0;
};
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return metadata.len();
}
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|entry| directory_bytes(&entry.path()))
.sum()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -132,4 +179,29 @@ mod tests {
assert_eq!(status.estimated_download_bytes, 0);
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn removes_only_downloaded_transcription_data() {
let root = test_root();
let data = root.join("data");
std::fs::create_dir_all(data.join("Models/model")).expect("create models");
std::fs::create_dir_all(data.join("ASR Runtime/bin")).expect("create runtime");
std::fs::create_dir_all(data.join("ASR Tools")).expect("create tools");
std::fs::create_dir_all(data.join("Meeting Recovery")).expect("create recovery");
std::fs::write(data.join("Models/model/weights"), b"1234").expect("write model");
std::fs::write(data.join("ASR Runtime/bin/python3"), b"12").expect("write runtime");
std::fs::write(data.join("ASR Tools/uv"), b"1").expect("write tool");
std::fs::write(data.join("Meeting Recovery/session.wav"), b"123").expect("write audio");
std::fs::write(data.join("keep.txt"), b"document state").expect("write unrelated data");

let removal = remove_downloaded(&data).expect("remove downloaded data");

assert_eq!(removal.removed_bytes, 10);
assert!(!data.join("Models").exists());
assert!(!data.join("ASR Runtime").exists());
assert!(!data.join("ASR Tools").exists());
assert!(!data.join("Meeting Recovery").exists());
assert!(data.join("keep.txt").exists());
let _ = std::fs::remove_dir_all(root);
}
}
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Ulpaso",
"version": "0.5.2",
"version": "0.5.3",
"identifier": "app.ulpaso.editor",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
21 changes: 21 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type RecentDocument,
} from "./document/storage";
import {
MEETING_RESOURCE_CONSENT_KEY,
formatBytes,
hasMeetingResourceConsent,
saveMeetingResourceConsent,
Expand Down Expand Up @@ -71,6 +72,7 @@ type PendingMeetingStart = {
type AppUpdate = { version: string };
type UpdateProgress = { downloaded: number; total: number | null };
type MicrophonePermission = "not-determined" | "authorized" | "denied" | "restricted" | "unavailable";
type MeetingResourceRemoval = { removedBytes: number };

const IDLE_MEETING: MeetingState = {
phase: "idle", sessionId: null, progress: null, message: null,
Expand Down Expand Up @@ -162,6 +164,7 @@ export default function App() {
const [settingsOpen, setSettingsOpen] = createSignal(false);
const [microphonePermission, setMicrophonePermission] = createSignal<MicrophonePermission>("unavailable");
const [microphonePermissionBusy, setMicrophonePermissionBusy] = createSignal(false);
const [localDataRemovalBusy, setLocalDataRemovalBusy] = createSignal(false);
const [pendingDocumentAction, setPendingDocumentAction] = createSignal<PendingDocumentAction | null>(null);
const [appUpdate, setAppUpdate] = createSignal<AppUpdate | null>(null);
const [updatePhase, setUpdatePhase] = createSignal<UpdateNoticePhase>("available");
Expand Down Expand Up @@ -527,6 +530,21 @@ export default function App() {
return t("settings.meetingDownload", { size: formatBytes(bytes) });
}

async function removeLocalMeetingData() {
if (!isNative || localDataRemovalBusy() || meeting().phase !== "idle") return;
setLocalDataRemovalBusy(true);
try {
const result = await invoke<MeetingResourceRemoval>("meeting_remove_local_data");
try { localStorage.removeItem(MEETING_RESOURCE_CONSENT_KEY); } catch { /* storage can be disabled */ }
await refreshMeetingResources();
showToast(t("settings.localDataRemoved", { size: formatBytes(result.removedBytes) }), "success", 4200);
} catch (error) {
showToast(readableError(error, t("settings.localDataRemoveFailed")), "error", 4200);
} finally {
setLocalDataRemovalBusy(false);
}
}

async function retryMeeting(microphoneOnly = false, systemOnly = false) {
try { await invoke("meeting_cancel"); } catch { /* already reset */ }
setMeetingErrorOpen(false);
Expand Down Expand Up @@ -884,11 +902,14 @@ export default function App() {
editorFullWidth={editorFullWidth()}
microphonePermission={microphonePermission()}
microphonePermissionBusy={microphonePermissionBusy()}
localDataRemovalBusy={localDataRemovalBusy()}
localDataRemovalDisabled={!isNative || meeting().phase !== "idle"}
onClose={() => setSettingsOpen(false)}
onToggleTheme={toggleTheme}
onToggleMeetingDetection={toggleMeetingDetection}
onToggleEditorFullWidth={toggleEditorFullWidth}
onManageMicrophonePermission={() => void manageMicrophonePermission()}
onRemoveLocalMeetingData={() => void removeLocalMeetingData()}
/>
</Show>

Expand Down
42 changes: 42 additions & 0 deletions src/components/SettingsPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { render } from "solid-js/web";
import { setLocale } from "../i18n";
import SettingsPopover from "./SettingsPopover";

const localDataProps = {
localDataRemovalBusy: false,
localDataRemovalDisabled: false,
onRemoveLocalMeetingData: vi.fn(),
};

afterEach(() => {
setLocale("en");
document.body.replaceChildren();
vi.clearAllMocks();
});

describe("SettingsPopover shortcut guide", () => {
Expand All @@ -16,6 +23,7 @@ describe("SettingsPopover shortcut guide", () => {
document.body.append(root);
const dispose = render(() => (
<SettingsPopover
{...localDataProps}
theme="light"
editorFullWidth={false}
meetingDescription="Local models ready"
Expand Down Expand Up @@ -51,6 +59,7 @@ describe("SettingsPopover shortcut guide", () => {
document.body.append(root);
const dispose = render(() => (
<SettingsPopover
{...localDataProps}
theme="dark"
editorFullWidth={false}
meetingDescription="로컬 모델 준비됨"
Expand Down Expand Up @@ -79,6 +88,7 @@ describe("SettingsPopover shortcut guide", () => {
const onToggleMeetingDetection = vi.fn();
const dispose = render(() => (
<SettingsPopover
{...localDataProps}
theme="light"
editorFullWidth={false}
meetingDescription="Local models ready"
Expand Down Expand Up @@ -108,6 +118,7 @@ describe("SettingsPopover shortcut guide", () => {
const onToggleEditorFullWidth = vi.fn();
const dispose = render(() => (
<SettingsPopover
{...localDataProps}
theme="light"
editorFullWidth={false}
meetingDescription="Local models ready"
Expand All @@ -131,4 +142,35 @@ describe("SettingsPopover shortcut guide", () => {
dispose();
});

it("requires a second explicit click before deleting models and recovery audio", () => {
const root = document.createElement("div");
document.body.append(root);
const onRemoveLocalMeetingData = vi.fn();
const dispose = render(() => (
<SettingsPopover
{...localDataProps}
onRemoveLocalMeetingData={onRemoveLocalMeetingData}
theme="light"
editorFullWidth={false}
meetingDescription="Local models ready"
meetingDetectionEnabled={true}
onClose={vi.fn()}
onToggleTheme={vi.fn()}
onToggleEditorFullWidth={vi.fn()}
onToggleMeetingDetection={vi.fn()}
microphonePermission="authorized"
microphonePermissionBusy={false}
onManageMicrophonePermission={vi.fn()}
/>
), root);

const button = root.querySelector<HTMLButtonElement>(".settings-link-danger")!;
button.click();
expect(onRemoveLocalMeetingData).not.toHaveBeenCalled();
expect(button.textContent).toContain("Remove models & audio");

button.click();
expect(onRemoveLocalMeetingData).toHaveBeenCalledOnce();
dispose();
});
});
Loading
Loading