Add .textream file support, in the macOS app's own format

Open and Save as… in the editor's status row, backed by the native file
dialog rather than a custom one. The file format is kept exactly as the
macOS app writes it — a JSON array of page strings, nothing else — so a
script saved on one platform opens cleanly on the other. There was no reason
to invent a Windows-specific format for something this simple, and doing so
would have cost the one genuinely useful property a shared format has.

Multi-page scripts stay out of scope here, so opening a file with more than
one page flattens them into this app's single continuous script, each former
page separated by a blank line. Nothing is dropped — a script written on a
Mac with several pages still opens in full, just without the page boundaries.

The dialog itself lives in the webview, through @tauri-apps/plugin-dialog;
the file format lives in Rust, in document.rs, so the format has its own
unit tests that don't need a window to run. Opening a file confirms first
when the editor already holds a script, since there is no undo for the
replacement — the current script is only ever autosaved as part of settings,
not tracked as a document with its own save state.

The `.textream` extension is exposed from Rust through a dedicated command
rather than duplicated as a string literal on both sides of the IPC boundary.
This commit is contained in:
CRTkafa 2026-08-26 19:47:48 +07:00
parent 81ec9ce219
commit 271d83a430
11 changed files with 400 additions and 5 deletions

67
Cargo.lock generated
View file

@ -3265,6 +3265,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -4111,6 +4135,48 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.20",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.20",
"toml 1.1.4+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-global-shortcut" name = "tauri-plugin-global-shortcut"
version = "2.3.2" version = "2.3.2"
@ -4281,6 +4347,7 @@ dependencies = [
"sherpa-rs", "sherpa-rs",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-opener", "tauri-plugin-opener",
"ureq", "ureq",

View file

@ -88,8 +88,10 @@ src-tauri/ Windows shell
├── audio.rs Microphone capture and the speech worker ├── audio.rs Microphone capture and the speech worker
├── speech.rs Streaming recogniser over the sherpa-onnx C API ├── speech.rs Streaming recogniser over the sherpa-onnx C API
├── model.rs Model registry and first-run download ├── model.rs Model registry and first-run download
├── document.rs .textream file format — read/write, macOS-compatible
├── backdrop.rs Mica or blur behind the frameless window ├── backdrop.rs Mica or blur behind the frameless window
├── settings.rs Persisted preferences ├── settings.rs Persisted preferences
├── shortcuts.rs Global shortcuts for hands-free control
├── session.rs Live session state and the webview-facing DTOs ├── session.rs Live session state and the webview-facing DTOs
└── lib.rs Commands, events, tray icon └── lib.rs Commands, events, tray icon
@ -98,7 +100,7 @@ src/ Svelte UI
├── Chrome.svelte Title bar and resize grips for the frameless window ├── Chrome.svelte Title bar and resize grips for the frameless window
├── LiquidStart.svelte The voice-reactive transport control ├── LiquidStart.svelte The voice-reactive transport control
├── Overlay.svelte The prompter pill ├── Overlay.svelte The prompter pill
└── lib/ Typed command wrappers, spring integrator └── lib/ Typed command wrappers, spring integrator, file dialogs
``` ```
The engine deliberately knows nothing about Windows. That keeps it testable The engine deliberately knows nothing about Windows. That keeps it testable
@ -164,6 +166,23 @@ job; the app already handles everything after it.
Adding a language is a few lines in [`model.rs`](src-tauri/src/model.rs) and a Adding a language is a few lines in [`model.rs`](src-tauri/src/model.rs) and a
published model to point at. published model to point at.
## Script files
`.textream` files are the same format the macOS app writes: a JSON array of
page strings, nothing else. A script saved on one platform opens cleanly on
the other — the format was kept exactly as-is rather than inventing a Windows
one, since there was no reason to diverge from something this simple.
Multi-page scripts are out of scope here (see the roadmap), so opening a file
with more than one page flattens them into the single script this app edits,
each former page separated by a blank line. Nothing is dropped — a script
written on a Mac with several pages still opens here in full, just without the
page boundaries.
The script itself is always autosaved as part of your settings, so `.textream`
files are for moving a script between machines or sharing it with someone
else, not the only copy of anything.
## Building ## Building
Requires: Requires:
@ -218,8 +237,9 @@ cargo test --workspace
- [x] Font, size, colour and opacity - [x] Font, size, colour and opacity
- [x] Pause, hold, and mute from the transport dock - [x] Pause, hold, and mute from the transport dock
- [x] Tap a word to jump; scroll to catch up - [x] Tap a word to jump; scroll to catch up
- [ ] `.textream` files - [x] `.textream` files — the same format the macOS app writes
- [x] Global shortcuts — start, hold and mute without leaving the camera - [x] Global shortcuts — start, hold and mute without leaving the camera
- [x] First-run welcome, and error messages that say what to do
- [ ] Update check - [ ] Update check
### Deliberately out of scope ### Deliberately out of scope

12
package-lock.json generated
View file

@ -8,7 +8,8 @@
"name": "textream-windows", "name": "textream-windows",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1" "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
@ -642,6 +643,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",

View file

@ -13,7 +13,8 @@
"app:build": "tauri build" "app:build": "tauri build"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1" "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",

View file

@ -21,6 +21,7 @@ prompt-core.workspace = true
tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -22,6 +22,8 @@
"core:window:allow-set-always-on-top", "core:window:allow-set-always-on-top",
"core:window:allow-set-ignore-cursor-events", "core:window:allow-set-ignore-cursor-events",
"core:webview:default", "core:webview:default",
"opener:default" "opener:default",
"dialog:default",
"dialog:allow-confirm"
] ]
} }

122
src-tauri/src/document.rs Normal file
View file

@ -0,0 +1,122 @@
//! `.textream` files.
//!
//! The macOS app stores a script as a plain JSON array of page strings —
//! `["page one", "page two"]`, nothing else. That format is deliberately kept
//! here rather than inventing a Windows-specific one: a script saved on one
//! platform opens cleanly on the other, and the format is simple enough that
//! there is no reason to diverge from it.
//!
//! Multi-page scripts are out of scope for this port (see the README), so
//! multiple pages are flattened into the one continuous script this app
//! edits, separated by a blank line. Nothing is dropped — a script written on
//! a Mac with several pages still opens here in full, just without the page
//! boundaries.
use std::fs;
use std::path::Path;
/// Extension `.textream` files use, without the leading dot.
pub const EXTENSION: &str = "textream";
/// Serialises a script into the `.textream` file format.
///
/// The whole script becomes the file's one page. Pretty-printed rather than
/// compact: these are small text files a person may reasonably open outside
/// the app, and pretty-printing costs nothing a script-sized file would
/// notice.
pub fn encode(script: &str) -> Result<String, String> {
serde_json::to_string_pretty(&[script]).map_err(|error| error.to_string())
}
/// Parses a `.textream` file's pages back into one script.
///
/// Rejects a file with no pages at all — that is not a script the editor can
/// do anything useful with — but a file with only blank pages is valid; it
/// simply produces an empty script.
pub fn decode(contents: &str) -> Result<String, String> {
let pages: Vec<String> = serde_json::from_str(contents)
.map_err(|_| "This file is not a valid .textream script.".to_string())?;
if pages.is_empty() {
return Err("This .textream file has no pages to read.".to_string());
}
Ok(pages.join("\n\n"))
}
/// Writes `script` to `path` as a `.textream` file.
pub fn save(path: &Path, script: &str) -> Result<(), String> {
let encoded = encode(script)?;
fs::write(path, encoded).map_err(|error| error.to_string())
}
/// Reads a `.textream` file at `path`, flattened to one script.
pub fn load(path: &Path) -> Result<String, String> {
let contents = fs::read_to_string(path).map_err(|error| error.to_string())?;
decode(&contents)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_script_round_trips_through_encode_and_decode() {
let script = "Welcome back. [smile]\n\nToday we ship.";
let encoded = encode(script).unwrap();
assert_eq!(decode(&encoded).unwrap(), script);
}
#[test]
fn encoding_produces_a_single_element_array() {
let encoded = encode("hello").unwrap();
let pages: Vec<String> = serde_json::from_str(&encoded).unwrap();
assert_eq!(pages, vec!["hello".to_string()]);
}
#[test]
fn multiple_pages_from_the_macos_app_are_flattened_in_order() {
let file = r#"["Page one.", "Page two.", "Page three."]"#;
assert_eq!(
decode(file).unwrap(),
"Page one.\n\nPage two.\n\nPage three."
);
}
#[test]
fn an_empty_page_array_is_rejected() {
assert!(decode("[]").is_err());
}
#[test]
fn a_single_blank_page_decodes_to_an_empty_script() {
assert_eq!(decode(r#"[""]"#).unwrap(), "");
}
#[test]
fn malformed_json_is_rejected_with_a_plain_message() {
let error = decode("not json at all").unwrap_err();
assert!(!error.contains("serde"), "leaked a parser's own vocabulary");
}
#[test]
fn a_plain_string_instead_of_an_array_is_rejected() {
assert!(decode(r#""just a string"#).is_err());
}
#[test]
fn save_then_load_preserves_the_script_on_disk() {
let dir = std::env::temp_dir().join("textream-document-test");
let _ = fs::create_dir_all(&dir);
let path = dir.join("sample.textream");
save(&path, "alpha\n\nbeta").unwrap();
assert_eq!(load(&path).unwrap(), "alpha\n\nbeta");
let _ = fs::remove_file(&path);
}
#[test]
fn loading_a_missing_file_fails_without_panicking() {
let missing = Path::new("C:/definitely/not/here.textream");
assert!(load(missing).is_err());
}
}

View file

@ -8,6 +8,7 @@
mod audio; mod audio;
mod backdrop; mod backdrop;
mod document;
mod model; mod model;
mod overlay; mod overlay;
mod session; mod session;
@ -230,6 +231,32 @@ fn shortcut_bindings() -> Vec<(String, String)> {
.collect() .collect()
} }
/// The file extension `.textream` files use, without the leading dot.
///
/// Read by the frontend when it builds the save/open dialog's filter, so the
/// extension is written in exactly one place rather than duplicated as a
/// string literal on both sides of the IPC boundary.
#[tauri::command]
fn script_file_extension() -> &'static str {
document::EXTENSION
}
/// Writes the script to a `.textream` file at `path`.
///
/// The dialog itself is chosen in the frontend, through `@tauri-apps/plugin-dialog`
/// — this command only owns the file format, matching the split the macOS app
/// makes between its `NSSavePanel` and `saveToURL`.
#[tauri::command]
fn save_script_file(path: String, text: String) -> Result<(), String> {
document::save(std::path::Path::new(&path), &text)
}
/// Reads a `.textream` file at `path`, flattened to one script.
#[tauri::command]
fn open_script_file(path: String) -> Result<String, String> {
document::load(std::path::Path::new(&path))
}
/// Which backdrop the compositor gave the main window. /// Which backdrop the compositor gave the main window.
/// ///
/// The UI needs this because a frameless transparent window with no effect /// The UI needs this because a frameless transparent window with no effect
@ -389,6 +416,7 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> {
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(shortcuts::plugin()) .plugin(shortcuts::plugin())
.manage(SessionState::new()) .manage(SessionState::new())
.manage(AudioState::default()) .manage(AudioState::default())
@ -432,6 +460,9 @@ pub fn run() {
jump_to_offset, jump_to_offset,
window_backdrop, window_backdrop,
shortcut_bindings, shortcut_bindings,
script_file_extension,
save_script_file,
open_script_file,
speech_diagnostics, speech_diagnostics,
is_first_run, is_first_run,
load_settings, load_settings,

View file

@ -2,6 +2,7 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import * as api from "./lib/api"; import * as api from "./lib/api";
import * as files from "./lib/files";
import type { import type {
ColorPreset, ColorPreset,
DownloadProgress, DownloadProgress,
@ -247,6 +248,27 @@ Today we are shipping something I have wanted for a long time.
} }
} }
async function saveScriptToFile() {
try {
const saved = await files.saveScript(settings.script);
if (saved) say("Script saved.");
} catch (error) {
say(`Could not save the script: ${error}`, "warn");
}
}
async function openScriptFromFile() {
try {
const loaded = await files.openScript(settings.script);
if (loaded === null) return;
settings.script = loaded;
persist();
say("Script loaded.");
} catch (error) {
say(`Could not open that file: ${error}`, "warn");
}
}
async function toggleRun() { async function toggleRun() {
// start() and stop() both open or release a microphone and a model, which // start() and stop() both open or release a microphone and a model, which
// take real time to settle. Without this guard, a second click before the // take real time to settle. Without this guard, a second click before the
@ -428,6 +450,10 @@ Today we are shipping something I have wanted for a long time.
<span class="sep"></span> <span class="sep"></span>
<span class="status" class:warn={statusKind === "warn"}>{status}</span> <span class="status" class:warn={statusKind === "warn"}>{status}</span>
{/if} {/if}
<div class="file-actions">
<button onclick={openScriptFromFile}>Open…</button>
<button onclick={saveScriptToFile}>Save as…</button>
</div>
</div> </div>
{#if diagnostics} {#if diagnostics}
@ -888,6 +914,25 @@ Today we are shipping something I have wanted for a long time.
color: #ff9e0a; color: #ff9e0a;
} }
.file-actions {
display: flex;
gap: 14px;
margin-left: auto;
}
.file-actions button {
border: 0;
padding: 0;
background: none;
color: var(--ink-faint);
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.file-actions button:hover {
color: var(--ink);
}
.heard { .heard {
display: flex; display: flex;
align-items: baseline; align-items: baseline;

View file

@ -232,3 +232,27 @@ export interface SpeechDiagnostics {
export const speechDiagnostics = () => export const speechDiagnostics = () =>
invoke<SpeechDiagnostics>("speech_diagnostics"); invoke<SpeechDiagnostics>("speech_diagnostics");
/**
* The `.textream` extension, without the leading dot.
*
* Read from Rust rather than hardcoded here, so the string exists in exactly
* one place instead of duplicated across the IPC boundary.
*/
export const scriptFileExtension = () =>
invoke<string>("script_file_extension");
/**
* Writes `text` as a `.textream` file at `path`.
*
* `path` comes from the native save dialog (see `lib/files.ts`), not from
* this module — the dialog is a webview concern, the file format is a Rust
* one, and keeping that split means the format can be unit-tested without a
* window.
*/
export const saveScriptFile = (path: string, text: string) =>
invoke<void>("save_script_file", { path, text });
/** Reads a `.textream` file at `path`, flattened to one script. */
export const openScriptFile = (path: string) =>
invoke<string>("open_script_file", { path });

72
src/lib/files.ts Normal file
View file

@ -0,0 +1,72 @@
/**
* `.textream` file dialogs.
*
* The native picker lives here, in the webview — it is the one piece of this
* feature that is a UI concern rather than a file-format one. Everything about
* what a `.textream` file actually contains stays in Rust (`document.rs`),
* reachable through `saveScriptFile`/`openScriptFile` in `./api`.
*/
import { save, open, confirm } from "@tauri-apps/plugin-dialog";
import * as api from "./api";
let cachedExtension: string | null = null;
async function extension(): Promise<string> {
cachedExtension ??= await api.scriptFileExtension();
return cachedExtension;
}
async function filter() {
const ext = await extension();
return [{ name: "Textream Script", extensions: [ext] }];
}
/**
* Prompts for a destination and writes the script there.
*
* @returns `true` if the file was written, `false` if the user cancelled.
*/
export async function saveScript(script: string): Promise<boolean> {
const ext = await extension();
const path = await save({
title: "Save script",
defaultPath: `Untitled.${ext}`,
filters: await filter(),
});
if (!path) return false;
await api.saveScriptFile(path, script);
return true;
}
/**
* Prompts for a `.textream` file and returns its script, flattened to one
* continuous page.
*
* Asks for confirmation first when `currentScript` is non-empty, since opening
* replaces whatever is in the editor and there is no undo for that here — the
* script is autosaved continuously, so the one being replaced is not
* recoverable once this returns.
*
* @returns The loaded script, or `null` if the user cancelled at either step.
*/
export async function openScript(
currentScript: string,
): Promise<string | null> {
if (currentScript.trim() !== "") {
const proceed = await confirm(
"Opening a script replaces the one you're editing now.",
{ title: "Replace current script?", kind: "warning" },
);
if (!proceed) return null;
}
const path = await open({
title: "Open script",
filters: await filter(),
multiple: false,
directory: false,
});
if (!path || Array.isArray(path)) return null;
return api.openScriptFile(path);
}