Add phone handoff: record on a phone, transcribe on the desktop (#1411)

Pairs a phone to the desktop by QR code, then sends recordings over an
iroh connection for Sona to transcribe, streaming the transcript back.

The phone side is a PWA deployed alongside the website at /vibe/phone/.
Browsers cannot hole-punch, so its traffic is relayed — end-to-end
encrypted throughout, so the relay only ever sees ciphertext.

Desktop:
- iroh endpoint behind the `vibe/handoff/0` ALPN, off by default and
  restored on startup only when the user previously enabled it
- pairing token compared in constant time; identity persisted so the QR
  survives restarts
- incoming audio saved to ~/Documents/Vibe like any other recording, and
  written to the transcripts store so it lands in Recents
- Settings -> Phone section with a self-contained QR encoder, reachable
  from a phone icon in the sidebar footer
- launch-at-startup setting via tauri-plugin-autostart, default off

Phone PWA:
- React + Vite on the desktop app's UI stack
- language list and model come from the desktop over the wire, never
  hardcoded, so the picker always matches the loaded model
- MediaRecorder mime negotiation for Safari, screen wake lock, and an
  install-to-home-screen hint

Analytics cover adoption and success rate without sending transcripts,
filenames, paths, or the chosen language.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thewh1teagle
2026-08-22 18:47:57 +03:00
committed by GitHub
parent ea76840998
commit 18160c22f7
82 changed files with 18989 additions and 59 deletions
+34
View File
@@ -8,6 +8,8 @@ on:
- "website/**"
- "i18n/**"
- "scripts/website_links.py"
- "pwa/**"
- "handoff-wasm/**"
release:
types: [published]
workflow_dispatch:
@@ -56,6 +58,38 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
# The phone PWA ships inside the same Pages artifact, at /vibe/phone/.
# Its iroh client is Rust compiled to wasm, so it needs a toolchain.
- name: setup Rust for the wasm client
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: handoff-wasm
- name: install wasm-bindgen-cli
uses: taiki-e/install-action@v2
with:
# Must match the pinned `wasm-bindgen` dependency in handoff-wasm/Cargo.toml
# exactly, or the CLI refuses to process the module.
tool: wasm-bindgen-cli@0.2.122
- name: install binaryen
run: brew install binaryen
- name: Build phone PWA
run: |
./handoff-wasm/build.sh
pnpm --dir pwa install
pnpm --dir pwa build
mkdir -p website/dist/phone
cp -R pwa/dist/. website/dist/phone/
env:
PWA_BASE: /vibe/phone/
- name: Setup Pages
uses: actions/configure-pages@v6
Generated
+1771 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -32,6 +32,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tauri-apps/api": "~2.10.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
"@tauri-apps/plugin-deep-link": "~2.4.7",
"@tauri-apps/plugin-dialog": "~2.6.0",
+10
View File
@@ -62,6 +62,9 @@ importers:
'@tauri-apps/api':
specifier: ~2.10.1
version: 2.10.1
'@tauri-apps/plugin-autostart':
specifier: ^2.5.1
version: 2.5.1
'@tauri-apps/plugin-clipboard-manager':
specifier: ~2.3.2
version: 2.3.2
@@ -1510,6 +1513,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.5.1':
resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==}
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
@@ -4737,6 +4743,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/plugin-autostart@2.5.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.10.1
+6
View File
@@ -31,6 +31,7 @@ tauri-plugin-aptabase = { git = "https://github.com/thewh1teagle/tauri-plugin-ap
# Unsafe headers required for Ollama (to set Origin)
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
serde_json = { workspace = true }
eyre = { workspace = true }
@@ -66,6 +67,11 @@ bytemuck = "1.24.0"
which = "8"
enigo = "0.3"
# Phone handoff (iroh p2p)
iroh = "1"
subtle = "2"
hex = "0.4"
# Linux
[target.'cfg(target_os = "linux")'.dependencies]
openssl = { version = "0.10.75", features = ["vendored"] }
+3
View File
@@ -70,6 +70,9 @@
"notification:allow-request-permission",
"notification:allow-permission-state",
"process:allow-exit",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
{
"identifier": "fs:scope",
"allow": [
+8
View File
@@ -18,6 +18,14 @@ pub mod events {
pub const APP_STARTED: &str = "app_started";
pub const CLI_STARTED: &str = "cli_started";
pub const SONA_SPAWN_FAILED: &str = "sona_spawn_failed";
// Phone handoff. Props are technical facts only: never a transcript, filename,
// saved path, endpoint id, pairing token, model path, or chosen language.
pub const HANDOFF_ENABLED: &str = "handoff_enabled";
pub const HANDOFF_DISABLED: &str = "handoff_disabled";
pub const HANDOFF_TRANSCRIBE: &str = "handoff_transcribe";
pub const HANDOFF_CAPABILITIES: &str = "handoff_capabilities";
pub const HANDOFF_PAIRING_REGENERATED: &str = "handoff_pairing_regenerated";
}
fn is_analytics_enabled(app_handle: &AppHandle) -> bool {
+120
View File
@@ -0,0 +1,120 @@
//! Tauri commands controlling the phone handoff endpoint.
//!
//! The endpoint is off by default; nothing binds until the user calls
//! `handoff_start`.
use serde::Serialize;
use tauri::State;
use tokio::sync::Mutex;
use crate::handoff::{self, HandoffState};
use super::CommandError;
/// What the UI needs to render the handoff panel and its QR code.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HandoffStatus {
pub enabled: bool,
pub endpoint_id: Option<String>,
pub pairing_url: Option<String>,
}
impl HandoffStatus {
fn disabled() -> Self {
Self {
enabled: false,
endpoint_id: None,
pairing_url: None,
}
}
fn from_state(state: &HandoffState) -> Self {
Self {
enabled: true,
endpoint_id: Some(state.endpoint_id()),
pairing_url: Some(state.pairing_url(&handoff::pwa_origin())),
}
}
}
/// Managed state holding the running endpoint, if any.
pub type HandoffRuntime = Mutex<Option<HandoffState>>;
#[tauri::command]
pub async fn handoff_status(runtime: State<'_, HandoffRuntime>) -> Result<HandoffStatus, CommandError> {
let guard = runtime.lock().await;
Ok(match guard.as_ref() {
Some(state) => HandoffStatus::from_state(state),
None => HandoffStatus::disabled(),
})
}
/// Idempotent: returns the existing endpoint if one is already running.
#[tauri::command]
pub async fn handoff_start(
app_handle: tauri::AppHandle,
runtime: State<'_, HandoffRuntime>,
) -> Result<HandoffStatus, CommandError> {
let mut guard = runtime.lock().await;
if let Some(state) = guard.as_ref() {
return Ok(HandoffStatus::from_state(state));
}
let state = handoff::spawn(app_handle.clone()).await?;
tracing::info!("handoff started: {}", state.endpoint_id());
// Only on a real off -> on transition, so this counts adoption rather than
// how often the settings page was opened. Never carries the endpoint id.
crate::analytics::track_event_handle(&app_handle, crate::analytics::events::HANDOFF_ENABLED);
// Persist the intent so enabling handoff survives a restart.
handoff::set_enabled(&app_handle, true);
let status = HandoffStatus::from_state(&state);
*guard = Some(state);
Ok(status)
}
#[tauri::command]
pub async fn handoff_stop(app_handle: tauri::AppHandle, runtime: State<'_, HandoffRuntime>) -> Result<(), CommandError> {
let taken = { runtime.lock().await.take() };
// Recorded even when nothing was running, so a restore that failed at startup
// cannot leave the stored preference stuck on.
handoff::set_enabled(&app_handle, false);
if let Some(state) = taken {
state.shutdown().await;
tracing::info!("handoff stopped");
// Likewise only on a real on -> off transition.
crate::analytics::track_event_handle(&app_handle, crate::analytics::events::HANDOFF_DISABLED);
}
Ok(())
}
/// Issues a new pairing token, invalidating any QR code already handed out.
/// If the endpoint is running it is restarted so the new token takes effect.
#[tauri::command]
pub async fn handoff_regenerate_token(
app_handle: tauri::AppHandle,
runtime: State<'_, HandoffRuntime>,
) -> Result<HandoffStatus, CommandError> {
let mut guard = runtime.lock().await;
handoff::regenerate_token(&app_handle)?;
let was_running = guard.is_some();
// Rare, and a signal that someone is fighting with pairing. No token or
// endpoint id goes with it.
crate::analytics::track_event_handle_with_props(
&app_handle,
crate::analytics::events::HANDOFF_PAIRING_REGENERATED,
Some(serde_json::json!({ "was_running": was_running })),
);
if let Some(state) = guard.take() {
state.shutdown().await;
}
if !was_running {
return Ok(HandoffStatus::disabled());
}
let state = handoff::spawn(app_handle).await?;
let status = HandoffStatus::from_state(&state);
*guard = Some(state);
Ok(status)
}
+1
View File
@@ -4,6 +4,7 @@ pub mod audio;
pub mod config;
pub mod download;
pub mod files;
pub mod handoff_cmd;
pub mod permissions;
pub mod sona_cmd;
pub mod transcribe;
+955
View File
@@ -0,0 +1,955 @@
//! Phone handoff: a phone records audio, sends it over iroh, this desktop
//! transcribes it with Sona and streams the transcript back.
//!
//! The endpoint is *not* spawned at startup — the user opts in from the UI,
//! which calls the `handoff_start` command.
pub mod protocol;
use std::path::PathBuf;
use std::sync::Arc;
use eyre::{bail, Context, Result};
use futures_util::StreamExt;
use iroh::endpoint::{presets, Connection};
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
use iroh::{Endpoint, SecretKey};
use subtle::ConstantTimeEq;
use tauri::{Emitter, Manager};
use tokio::io::AsyncWriteExt;
use crate::error::LogError;
use crate::sona::SonaEvent;
use protocol::{HandoffActivity, HandoffEvent, HandoffHeader, ALPN, MAX_AUDIO_BYTES, MAX_HEADER_LEN};
/// Keys in `app_config.json` holding the user's model settings (`lib/config-keys.ts`).
const CONFIG_KEY_MODEL_PATH: &str = "model.path";
const CONFIG_KEY_GPU_DEVICE: &str = "model.gpuDevice";
const CONFIG_KEY_UNLOAD_TIMEOUT_MINUTES: &str = "model.unloadTimeoutMinutes";
/// Whether the user turned handoff on. Namespaced like the other feature keys in
/// `lib/config-keys.ts` (`model.path`, `transcription.saveTranscripts`).
pub const CONFIG_KEY_HANDOFF_ENABLED: &str = "handoff.enabled";
/// Display name for a phone transcription in Recents.
const PHONE_TRANSCRIPT_NAME: &str = "Phone recording";
/// Matches the frontend default in `providers/preference.tsx`.
const DEFAULT_UNLOAD_TIMEOUT_MINUTES: u32 = 5;
/// Where the phone PWA is deployed: it ships inside the website's GitHub Pages
/// artifact. This is a public URL, not a secret, so it lives in committed source
/// rather than `.env` (which is gitignored and holds signing credentials).
///
/// Resolution order, widest to narrowest:
/// 1. `VIBE_PWA_ORIGIN` in the environment at run time — for `just dev` and for
/// pointing a real phone at a tunnel.
/// 2. `VIBE_PWA_ORIGIN` at compile time — lets a release build bake a different
/// origin, the same way `APTABASE_APP_KEY` is baked in `analytics.rs`.
/// 3. This constant.
pub const DEFAULT_PWA_ORIGIN: &str = match option_env!("VIBE_PWA_ORIGIN") {
Some(value) => value,
None => "https://thewh1teagle.github.io/vibe/phone",
};
/// A running handoff endpoint. Dropping this aborts the accept loop; prefer
/// [`HandoffState::shutdown`] for a clean close.
pub struct HandoffState {
router: Router,
endpoint_id: String,
token: String,
}
impl HandoffState {
/// 64 lowercase hex chars identifying this desktop on the iroh network.
pub fn endpoint_id(&self) -> String {
self.endpoint_id.clone()
}
/// The 32-hex-char pairing secret the phone must present.
#[allow(dead_code)]
pub fn token(&self) -> String {
self.token.clone()
}
/// The exact URL encoded into the pairing QR code.
pub fn pairing_url(&self, pwa_origin: &str) -> String {
format_pairing_url(pwa_origin, &self.endpoint_id, &self.token)
}
pub async fn shutdown(self) {
if let Err(error) = self.router.shutdown().await {
tracing::warn!("handoff router shutdown failed: {:?}", error);
} else {
tracing::debug!("handoff router shut down");
}
}
}
/// `<pwa_origin>/#<endpoint_id>:<token>` — the exact string the QR encodes.
fn format_pairing_url(pwa_origin: &str, endpoint_id: &str, token: &str) -> String {
format!("{}/#{}:{}", pwa_origin.trim_end_matches('/'), endpoint_id, token)
}
/// The origin the pairing QR should point at.
pub fn pwa_origin() -> String {
std::env::var("VIBE_PWA_ORIGIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_PWA_ORIGIN.to_string())
}
fn handoff_dir(app_handle: &tauri::AppHandle) -> Result<PathBuf> {
let dir = app_handle
.path()
.app_data_dir()
.context("failed to resolve app data dir")?
.join("handoff");
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
Ok(dir)
}
#[cfg(unix)]
fn restrict_permissions(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to chmod {}", path.display()))
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &std::path::Path) -> Result<()> {
Ok(())
}
/// Load the persisted iroh identity, generating it on first use so pairing QR
/// codes keep working across restarts.
fn load_or_create_secret_key(app_handle: &tauri::AppHandle) -> Result<SecretKey> {
let path = handoff_dir(app_handle)?.join("endpoint.key");
if path.exists() {
let bytes = std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
if bytes.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
return Ok(SecretKey::from_bytes(&key));
}
tracing::warn!("handoff secret key at {} is malformed, regenerating", path.display());
}
let secret_key = SecretKey::generate();
std::fs::write(&path, secret_key.to_bytes()).with_context(|| format!("failed to write {}", path.display()))?;
restrict_permissions(&path).log_error();
Ok(secret_key)
}
fn generate_token() -> String {
let bytes: [u8; 16] = rand::random();
hex::encode(bytes)
}
/// Load the persisted pairing token, generating it on first use.
fn load_or_create_token(app_handle: &tauri::AppHandle) -> Result<String> {
let path = handoff_dir(app_handle)?.join("token");
if let Ok(existing) = std::fs::read_to_string(&path) {
let existing = existing.trim().to_string();
if existing.len() == 32 && existing.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(existing);
}
tracing::warn!("handoff token at {} is malformed, regenerating", path.display());
}
let token = generate_token();
std::fs::write(&path, &token).with_context(|| format!("failed to write {}", path.display()))?;
restrict_permissions(&path).log_error();
Ok(token)
}
/// Replace the pairing token, invalidating any QR code handed out earlier.
pub fn regenerate_token(app_handle: &tauri::AppHandle) -> Result<String> {
let path = handoff_dir(app_handle)?.join("token");
let token = generate_token();
std::fs::write(&path, &token).with_context(|| format!("failed to write {}", path.display()))?;
restrict_permissions(&path).log_error();
Ok(token)
}
/// Remember whether the user wants handoff on, so enabling it survives a restart.
///
/// Enablement has to persist alongside the identity: the secret key and token are
/// already on disk, so the pairing QR stays valid across restarts. If the endpoint
/// did not come back with it, the phone would keep believing it is paired and dial
/// an endpoint that no longer exists.
pub fn set_enabled(app_handle: &tauri::AppHandle, enabled: bool) {
use tauri_plugin_store::StoreExt;
match app_handle.store(crate::config::STORE_FILENAME) {
Ok(store) => store.set(CONFIG_KEY_HANDOFF_ENABLED, serde_json::Value::Bool(enabled)),
Err(error) => tracing::warn!("handoff could not persist the enabled flag: {:?}", error),
}
}
/// Whether handoff was on when the app last closed. Defaults to off: this opens a
/// network listener, so only a user who explicitly turned it on gets it back.
pub fn is_enabled(app_handle: &tauri::AppHandle) -> bool {
use tauri_plugin_store::StoreExt;
app_handle
.store(crate::config::STORE_FILENAME)
.ok()
.and_then(|store| store.get(CONFIG_KEY_HANDOFF_ENABLED))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
/// Bind the iroh endpoint and start accepting handoff connections.
pub async fn spawn(app_handle: tauri::AppHandle) -> Result<HandoffState> {
let secret_key = load_or_create_secret_key(&app_handle)?;
let token = load_or_create_token(&app_handle)?;
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.alpns(vec![ALPN.to_vec()])
.bind()
.await
.map_err(|error| eyre::eyre!("failed to bind handoff endpoint: {error}"))?;
let endpoint_id = endpoint.id().to_string();
tracing::info!("handoff endpoint bound: {}", endpoint_id);
let handler = HandoffHandler {
app_handle,
token: Arc::new(token.clone()),
};
let router = Router::builder(endpoint).accept(ALPN, handler).spawn();
Ok(HandoffState {
router,
endpoint_id,
token,
})
}
/// Bring handoff back if the user had it on, without making the app wait.
///
/// Binding an iroh endpoint reaches the network, so this returns immediately and
/// finishes in the background; `handoff_status` reports the real state once it
/// settles. A brand-new user gets nothing: the flag defaults to off, and silently
/// opening a network listener on upgrade would be wrong.
pub fn restore_on_startup(app_handle: &tauri::AppHandle) {
if !is_enabled(app_handle) {
tracing::debug!("handoff is disabled; not restoring");
return;
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("restoring handoff endpoint in the background");
match spawn(app_handle.clone()).await {
Ok(state) => {
let runtime = app_handle.state::<tokio::sync::Mutex<Option<HandoffState>>>();
let mut guard = runtime.lock().await;
if guard.is_some() {
// The user toggled it on while we were still binding; keep
// theirs rather than leaking a second router.
tracing::debug!("handoff was started manually during restore; dropping the restored one");
drop(guard);
state.shutdown().await;
} else {
tracing::info!("handoff restored: {}", state.endpoint_id());
*guard = Some(state);
drop(guard);
app_handle
.emit_to("main", "handoff_activity", HandoffActivity::new("ready", None))
.log_error();
}
}
Err(error) => {
// Never leave the UI claiming handoff is on when it is not. The
// saved preference stays true so the next launch retries, but
// `handoff_status` reports the truth: not running.
let message = format!("{error:#}");
tracing::error!("failed to restore handoff endpoint: {}", message);
app_handle
.emit_to("main", "handoff_activity", HandoffActivity::new("error", Some(message)))
.log_error();
}
}
});
}
#[derive(Debug, Clone)]
struct HandoffHandler {
app_handle: tauri::AppHandle,
token: Arc<String>,
}
impl ProtocolHandler for HandoffHandler {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
// Every failure path still owes the phone a terminal `error` line; only a
// broken stream (which we cannot report on anyway) escapes as an error.
let outcome = self.handle_transfer(&mut send, &mut recv).await;
if let Err(failure) = outcome {
tracing::error!("handoff transfer failed: [{}] {}", failure.code, failure.message);
self.emit_activity("error", Some(failure.message.clone()));
let line = HandoffEvent::Error {
code: failure.code,
message: failure.message,
}
.to_line();
let _ = send.write_all(line.as_bytes()).await;
}
let _ = send.finish();
connection.closed().await;
Ok(())
}
}
/// A failure that must be reported to the phone as a terminal `error` line.
struct TransferError {
code: String,
message: String,
}
impl TransferError {
fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
}
}
}
impl From<eyre::Error> for TransferError {
fn from(error: eyre::Error) -> Self {
Self::new("internal_error", error.to_string())
}
}
impl HandoffHandler {
fn emit_activity(&self, state: &'static str, message: Option<String>) {
self.app_handle
.emit_to("main", "handoff_activity", HandoffActivity::new(state, message))
.log_error();
}
fn emit_done(&self, completion: protocol::HandoffCompletion) {
self.app_handle
.emit_to("main", "handoff_activity", HandoffActivity::done(completion))
.log_error();
}
async fn handle_transfer(
&self,
send: &mut iroh::endpoint::SendStream,
recv: &mut iroh::endpoint::RecvStream,
) -> Result<(), TransferError> {
let header = read_header(recv).await?;
// Constant-time comparison so a wrong token leaks nothing about the right one.
let expected = self.token.as_bytes();
let provided = header.token.as_bytes();
let authorized = expected.len() == provided.len() && bool::from(expected.ct_eq(provided));
if !authorized {
tracing::warn!("handoff connection rejected: invalid pairing token");
return Err(TransferError::new("unauthorized", "Invalid pairing token"));
}
// Dispatch happens only after the token check, and each branch is a
// separate function, so a capabilities request can never fall through into
// the audio-reading loop and block on bytes that will never arrive.
match header.op.as_deref() {
None | Some(protocol::OP_TRANSCRIBE) => self.handle_transcribe(send, recv, header).await,
Some(protocol::OP_CAPABILITIES) => {
let event = self.capabilities().await;
// Counts PWA page loads rather than people — see the note on
// `HANDOFF_CAPABILITIES`. `model_loaded` is the useful part: it
// shows how often someone opens the phone app with nothing ready.
let model_loaded = matches!(event, HandoffEvent::Capabilities { model_loaded: true, .. });
crate::analytics::track_event_handle_with_props(
&self.app_handle,
crate::analytics::events::HANDOFF_CAPABILITIES,
Some(serde_json::json!({ "model_loaded": model_loaded })),
);
// Exactly one line, then the caller finishes the stream.
write_event(send, &event).await
}
Some(other) => Err(TransferError::new("invalid_request", format!("Unknown op '{other}'"))),
}
}
/// Report what the currently loaded model can do. Never fails for the
/// ordinary "nothing loaded yet" case — that is a normal state the phone
/// renders, not an error.
async fn capabilities(&self) -> HandoffEvent {
match self.read_capabilities().await {
Ok(event) => event,
Err(error) => {
// Degrade honestly: if we cannot confirm what is loaded, we say
// nothing is, rather than advertising languages we may not have.
tracing::warn!("handoff could not read model capabilities: {:?}", error);
HandoffEvent::no_capabilities()
}
}
}
async fn read_capabilities(&self) -> Result<HandoffEvent> {
let sona_state = self.app_handle.state::<tokio::sync::Mutex<crate::setup::SonaState>>();
let endpoint = {
let state = sona_state.lock().await;
state.process.as_ref().map(|process| (process.client(), process.base_url()))
}; // lock released here, before any I/O
let Some((client, base_url)) = endpoint else {
tracing::debug!("handoff capabilities: sona is not running");
return Ok(HandoffEvent::no_capabilities());
};
// The same selection the transcribe path will load on demand, so a
// `modelLoaded: true` here is a promise the transcribe path can keep.
let Some(model_path) = model_settings(&self.app_handle).map(|settings| settings.path) else {
tracing::debug!("handoff capabilities: no model selected in {}", crate::config::STORE_FILENAME);
return Ok(HandoffEvent::no_capabilities());
};
if !std::path::Path::new(&model_path).is_file() {
tracing::warn!("handoff capabilities: selected model {} does not exist", model_path);
return Ok(HandoffEvent::no_capabilities());
}
let metadata = crate::sona::SonaProcess::model_metadata_with(&client, &base_url, &model_path).await?;
let model_name = std::path::Path::new(&model_path)
.file_name()
.map(|name| name.to_string_lossy().to_string());
Ok(HandoffEvent::Capabilities {
model_loaded: true,
model_name,
languages: metadata.capabilities.languages,
language_detection: metadata.capabilities.language_detection,
translation: metadata.capabilities.translation,
max_audio_bytes: MAX_AUDIO_BYTES,
})
}
/// The transcribe op. Wraps [`HandoffHandler::run_transcribe`] so that every
/// outcome — including a transfer that dies partway — reports exactly one
/// `handoff_transcribe` event. Without this the dataset would only ever see
/// successes and the feature would look healthier than it is.
async fn handle_transcribe(
&self,
send: &mut iroh::endpoint::SendStream,
recv: &mut iroh::endpoint::RecvStream,
header: HandoffHeader,
) -> Result<(), TransferError> {
let started = std::time::Instant::now();
let mut stats = TransferStats::default();
let result = self.run_transcribe(send, recv, &header, &mut stats).await;
self.track_transcribe(&header, &stats, started.elapsed(), &result);
result
}
/// One `handoff_transcribe` per transfer. Technical facts only: no transcript,
/// no filename, no saved path, no model path, and the chosen language is
/// reduced to whether one was chosen at all.
fn track_transcribe(
&self,
header: &HandoffHeader,
stats: &TransferStats,
elapsed: std::time::Duration,
result: &Result<(), TransferError>,
) {
let mut props = serde_json::json!({
"success": result.is_ok(),
"audio_size_bucket": size_bucket(stats.audio_bytes),
// Whole operation: receive + model load + transcription.
"duration_sec": elapsed.as_secs(),
// Whether a language was picked, never which one.
"auto_detect": header.lang.is_none(),
"translate": header.translate.unwrap_or(false),
});
if let Some(seconds) = stats.transcribe_sec {
props["transcribe_duration_sec"] = seconds.into();
}
if let Some(ref model_name) = stats.model_name {
props["model_name"] = model_name.as_str().into();
}
if let Err(ref failure) = *result {
props["error_code"] = failure.code.as_str().into();
}
crate::analytics::track_event_handle_with_props(
&self.app_handle,
crate::analytics::events::HANDOFF_TRANSCRIBE,
Some(props),
);
}
async fn run_transcribe(
&self,
send: &mut iroh::endpoint::SendStream,
recv: &mut iroh::endpoint::RecvStream,
header: &HandoffHeader,
stats: &mut TransferStats,
) -> Result<(), TransferError> {
write_event(send, &HandoffEvent::Accepted).await?;
self.emit_activity("receiving", None);
// The recording exists nowhere but the phone until this point, so it is
// kept like any other Vibe recording rather than deleted after use.
let (audio_path, audio_bytes) = receive_audio(&self.app_handle, recv, header.filename.as_deref()).await?;
stats.audio_bytes = Some(audio_bytes);
let saved_path = audio_path.to_string_lossy().to_string();
tracing::info!("handoff saved phone recording to {}", saved_path);
// A failed transcription leaves the file in place on purpose: the audio is
// complete and is the only copy, so the user can retry from the desktop.
// Only a truncated or rejected transfer is deleted, inside `receive_audio`.
self.transcribe(send, &audio_path, header, saved_path.clone(), stats).await?;
// The frontend decides whether to keep it — it owns the
// `transcription.saveTranscripts` preference.
if let Some(completion) = stats.completion.take() {
self.emit_done(completion);
}
Ok(())
}
/// Mirrors `cmd::transcribe::transcribe`, but forwards each Sona event to the
/// phone instead of the webview.
async fn transcribe(
&self,
send: &mut iroh::endpoint::SendStream,
audio_path: &std::path::Path,
header: &HandoffHeader,
saved_path: String,
stats: &mut TransferStats,
) -> Result<(), TransferError> {
// The desktop UI calls `load_model` before every transcription; the phone
// has no way to do that, so the handoff path does it here. Without this,
// capabilities would promise a model that Sona was never told to load.
let Some(settings) = model_settings(&self.app_handle) else {
return Err(TransferError::new("no_model", "No model is selected in Vibe on the desktop"));
};
// Loading a large model takes real time, and the phone would otherwise sit
// at "transcribing 0%" for all of it. Non-terminal, so a client that does
// not know the `status` type can ignore it and keep reading.
write_event(send, &HandoffEvent::status(protocol::PHASE_LOADING_MODEL)).await?;
self.emit_activity("loading_model", None);
tracing::debug!("handoff loading model {}", settings.path);
// File name only, and only if it looks like a distributed model.
stats.model_name = Some(safe_model_name(&settings.path));
crate::cmd::sona_cmd::load_model(
self.app_handle.clone(),
settings.path.clone(),
settings.gpu_device,
settings.unload_timeout_minutes,
)
.await
// Surface why it failed — a missing model file and an unavailable GPU need
// different fixes, and only the desktop knows which one happened.
.map_err(|error| TransferError::new("model_load_failed", format!("{error:#}")))?;
write_event(send, &HandoffEvent::status(protocol::PHASE_TRANSCRIBING)).await?;
self.emit_activity("transcribing", None);
let sona_state = self.app_handle.state::<tokio::sync::Mutex<crate::setup::SonaState>>();
let (client, base_url) = {
let state = sona_state.lock().await;
let process = state
.process
.as_ref()
.ok_or_else(|| TransferError::new("no_model", "Please load model first"))?;
(process.client(), process.base_url())
}; // lock released here, before any I/O
let options = crate::cmd::TranscribeOptions {
path: audio_path.to_string_lossy().to_string(),
lang: header.lang.clone(),
verbose: None,
n_threads: None,
init_prompt: None,
temperature: None,
// Passed straight through; whether it is meaningful is the phone's
// call, made against the `translation` flag we reported.
translate: header.translate,
max_text_ctx: None,
word_timestamps: None,
max_sentence_len: None,
sampling_strategy: None,
best_of: None,
beam_size: None,
diarize_model: None,
stable_timestamps: None,
vad_model: None,
};
let start = std::time::Instant::now();
let stream = crate::sona::SonaProcess::transcribe_stream(&client, &base_url, &options)
.await
.map_err(|error| {
if let Some(api_error) = error.downcast_ref::<crate::sona::SonaApiError>() {
TransferError::new(&api_error.code, api_error.message.clone())
} else {
TransferError::from(error)
}
})?;
tokio::pin!(stream);
let mut full_text: Option<String> = None;
// Kept so the frontend can write the same transcript record a local
// transcription produces; the phone gets each segment streamed as it lands.
let mut segments: Vec<crate::transcript::Segment> = Vec::new();
while let Some(event_result) = stream.next().await {
match event_result {
Ok(SonaEvent::Progress { progress }) => {
write_event(send, &HandoffEvent::Progress { progress }).await?;
}
Ok(SonaEvent::Segment {
start,
end,
text,
speaker,
}) => {
// Sona reports seconds as f64; the wire format wants centiseconds.
let segment = crate::transcript::Segment {
start: (start * 100.0) as i64,
stop: (end * 100.0) as i64,
text,
speaker,
};
segments.push(segment.clone());
write_event(
send,
&HandoffEvent::Segment {
start: segment.start,
stop: segment.stop,
text: segment.text,
speaker: segment.speaker,
},
)
.await?;
}
Ok(SonaEvent::Result { text }) => {
full_text = Some(text);
}
Ok(SonaEvent::Error { code, message }) => {
return Err(TransferError::new(code.as_deref().unwrap_or("internal_error"), message));
}
Err(error) => {
tracing::error!("handoff sona stream error: {:?}", error);
return Err(TransferError::from(error));
}
}
}
let text = match full_text {
Some(text) => text,
None => {
return Err(TransferError::new(
"internal_error",
"Sona transcription stream ended before completion",
))
}
};
let processing_time_sec = start.elapsed().as_secs();
stats.transcribe_sec = Some(processing_time_sec);
stats.completion = Some(protocol::HandoffCompletion {
// The store appends its own `-<yyyyMMdd-HHmmss>` stamp, so a bare
// label reads better in Recents than a second embedded timestamp.
name: PHONE_TRANSCRIPT_NAME.to_string(),
saved_path: saved_path.clone(),
segments,
language: header.lang.clone(),
model_path: Some(settings.path.clone()),
});
write_event(
send,
&HandoffEvent::Done {
text,
processing_time_sec,
saved_path: Some(saved_path),
},
)
.await?;
Ok(())
}
}
/// What one transfer is worth reporting, gathered as it happens so the event
/// fires even when the transfer fails partway.
#[derive(Debug, Default)]
struct TransferStats {
audio_bytes: Option<u64>,
transcribe_sec: Option<u64>,
model_name: Option<String>,
/// The record the frontend needs to put this transcription into Recents.
completion: Option<protocol::HandoffCompletion>,
}
/// Bucket the audio size. An exact byte count is closer to a fingerprint than we
/// need; buckets answer "are people sending long recordings?" just as well.
fn size_bucket(bytes: Option<u64>) -> &'static str {
const MB: u64 = 1024 * 1024;
match bytes {
None => "unknown",
Some(bytes) if bytes < MB => "<1MB",
Some(bytes) if bytes < 10 * MB => "1-10MB",
Some(bytes) if bytes < 50 * MB => "10-50MB",
Some(_) => ">50MB",
}
}
/// The model's file name, never its path — a path would carry the user's home
/// directory. Anything that is not shaped like a distributed model file is
/// reported as `custom`, so a model someone renamed to something personal never
/// leaves the machine.
fn safe_model_name(model_path: &str) -> String {
let name = std::path::Path::new(model_path)
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let plausible = !name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if plausible {
name
} else {
"custom".to_string()
}
}
/// The model settings the desktop UI persists, as `load_model` wants them.
#[derive(Debug, Clone)]
struct ModelSettings {
path: String,
gpu_device: Option<i32>,
unload_timeout_minutes: u32,
}
/// Read the user's model selection out of `app_config.json`.
///
/// `SonaState` does not remember which model was last handed to `load_model` and
/// Sona exposes no "what is loaded" endpoint, so the persisted selection is the
/// source of truth — the same one the desktop UI passes to `load_model` before
/// every transcription. Reading all three keys here keeps the handoff path
/// honouring the user's GPU and unload-timeout choices too.
fn model_settings(app_handle: &tauri::AppHandle) -> Option<ModelSettings> {
use tauri_plugin_store::StoreExt;
let store = app_handle
.store(crate::config::STORE_FILENAME)
.map_err(|error| tracing::warn!("handoff could not open the config store: {:?}", error))
.ok()?;
let path = store.get(CONFIG_KEY_MODEL_PATH)?.as_str()?.trim().to_string();
if path.is_empty() {
return None;
}
// Defaults match `providers/preference.tsx`: no GPU override, 5 minute unload.
let gpu_device = store
.get(CONFIG_KEY_GPU_DEVICE)
.and_then(|value| value.as_i64())
.map(|value| value as i32);
let unload_timeout_minutes = store
.get(CONFIG_KEY_UNLOAD_TIMEOUT_MINUTES)
.and_then(|value| value.as_u64())
.map(|value| value as u32)
.unwrap_or(DEFAULT_UNLOAD_TIMEOUT_MINUTES);
Some(ModelSettings {
path,
gpu_device,
unload_timeout_minutes,
})
}
async fn write_event(send: &mut iroh::endpoint::SendStream, event: &HandoffEvent) -> Result<(), TransferError> {
send.write_all(event.to_line().as_bytes())
.await
.map_err(|error| TransferError::new("internal_error", format!("failed to write to phone: {error}")))
}
async fn read_header(recv: &mut iroh::endpoint::RecvStream) -> Result<HandoffHeader, TransferError> {
let mut len_bytes = [0u8; 4];
recv.read_exact(&mut len_bytes)
.await
.map_err(|error| TransferError::new("invalid_request", format!("failed to read header length: {error}")))?;
let header_len = u32::from_be_bytes(len_bytes);
if header_len == 0 || header_len > MAX_HEADER_LEN {
return Err(TransferError::new(
"invalid_request",
format!("header length {header_len} out of range"),
));
}
let mut buffer = vec![0u8; header_len as usize];
recv.read_exact(&mut buffer)
.await
.map_err(|error| TransferError::new("invalid_request", format!("failed to read header: {error}")))?;
serde_json::from_slice::<HandoffHeader>(&buffer)
.map_err(|error| TransferError::new("invalid_request", format!("malformed header: {error}")))
}
/// Stream the audio body to a temp file, enforcing the size cap as we go so a
/// hostile peer can never fill the disk.
async fn receive_audio(
app_handle: &tauri::AppHandle,
recv: &mut iroh::endpoint::RecvStream,
filename: Option<&str>,
) -> Result<(PathBuf, u64), TransferError> {
let path = recording_path(app_handle, filename)
.map_err(|error| TransferError::new("internal_error", format!("failed to pick a save path: {error:#}")))?;
let mut file = tokio::fs::File::create(&path)
.await
.map_err(|error| TransferError::new("internal_error", format!("failed to create recording file: {error}")))?;
let mut buffer = vec![0u8; 64 * 1024];
let mut total: u64 = 0;
loop {
// iroh's `RecvStream::read` returns `None` once the peer finished the stream.
let read = match recv
.read(&mut buffer)
.await
.map_err(|error| TransferError::new("invalid_request", format!("failed to read audio: {error}")))?
{
Some(read) if read > 0 => read,
_ => break,
};
total += read as u64;
if total > MAX_AUDIO_BYTES {
let _ = tokio::fs::remove_file(&path).await;
return Err(TransferError::new(
"payload_too_large",
format!("Audio exceeds the {} MiB limit", MAX_AUDIO_BYTES / (1024 * 1024)),
));
}
file.write_all(&buffer[..read])
.await
.map_err(|error| TransferError::new("internal_error", format!("failed to write recording: {error}")))?;
}
file.flush()
.await
.map_err(|error| TransferError::new("internal_error", format!("failed to flush recording: {error}")))?;
drop(file);
if total == 0 {
let _ = tokio::fs::remove_file(&path).await;
return Err(TransferError::new("invalid_request", "No audio received"));
}
Ok((path, total))
}
/// The extension to save under, taken from the phone-supplied file name but
/// never the name itself: only a short alphanumeric extension is trusted, so a
/// peer cannot steer the write out of the recordings folder.
fn audio_extension(filename: Option<&str>) -> String {
filename
.and_then(|name| std::path::Path::new(name).extension())
.and_then(|ext| ext.to_str())
.filter(|ext| !ext.is_empty() && ext.len() <= 8 && ext.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or("m4a")
.to_lowercase()
}
/// Where to save an incoming phone recording: `~/Documents/Vibe`, the same
/// folder `cmd::files::get_default_recording_path` hands the frontend.
///
/// The name is timestamped so recordings sort chronologically, and a numeric
/// suffix is added rather than overwriting an existing file.
fn recording_path(app_handle: &tauri::AppHandle, filename: Option<&str>) -> Result<PathBuf> {
let folder = app_handle
.path()
.document_dir()
.map_err(|error| eyre::eyre!("failed to resolve documents dir: {error:?}"))?
.join(crate::config::DOCUMENTS_SUBFOLDER);
std::fs::create_dir_all(&folder).with_context(|| format!("failed to create {}", folder.display()))?;
let extension = audio_extension(filename);
let stem = format!("phone-{}", chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"));
let candidate = folder.join(format!("{stem}.{extension}"));
if !candidate.exists() {
return Ok(candidate);
}
// Two recordings can land in the same second; never clobber the earlier one.
for suffix in 1..1000 {
let candidate = folder.join(format!("{stem}-{suffix}.{extension}"));
if !candidate.exists() {
return Ok(candidate);
}
}
bail!("could not find a free filename for {}", candidate.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extension_comes_from_the_phone_but_only_when_it_is_safe() {
assert_eq!(audio_extension(Some("recording.m4a")), "m4a");
assert_eq!(audio_extension(Some("Recording.WAV")), "wav");
// No name, no extension, or a hostile one all fall back to the default.
assert_eq!(audio_extension(None), "m4a");
assert_eq!(audio_extension(Some("")), "m4a");
assert_eq!(audio_extension(Some("recording")), "m4a");
assert_eq!(audio_extension(Some("../../etc/passwd")), "m4a");
assert_eq!(audio_extension(Some("x.verylongextension")), "m4a");
assert_eq!(audio_extension(Some("x.m4a/../..")), "m4a");
}
#[test]
fn audio_size_is_bucketed_never_exact() {
const MB: u64 = 1024 * 1024;
assert_eq!(size_bucket(None), "unknown");
assert_eq!(size_bucket(Some(0)), "<1MB");
assert_eq!(size_bucket(Some(MB - 1)), "<1MB");
assert_eq!(size_bucket(Some(MB)), "1-10MB");
assert_eq!(size_bucket(Some(10 * MB - 1)), "1-10MB");
assert_eq!(size_bucket(Some(10 * MB)), "10-50MB");
assert_eq!(size_bucket(Some(50 * MB - 1)), "10-50MB");
assert_eq!(size_bucket(Some(50 * MB)), ">50MB");
assert_eq!(size_bucket(Some(MAX_AUDIO_BYTES)), ">50MB");
}
#[test]
fn model_name_never_leaks_a_path_or_a_personal_filename() {
assert_eq!(
safe_model_name("/Users/alice/models/ggml-large-v3-turbo.bin"),
"ggml-large-v3-turbo.bin"
);
// A Windows path on a unix host is not split into components, so the
// whole string fails the shape check and degrades to "custom" — the
// failure direction we want.
assert!(!safe_model_name("C:\\Users\\alice\\ggml-medium.bin").contains("alice"));
// Anything not shaped like a distributed model is reported generically.
assert_eq!(safe_model_name("/models/alice's therapy notes model.bin"), "custom");
assert_eq!(safe_model_name("/models/модель.bin"), "custom");
assert_eq!(safe_model_name(&format!("/models/{}.bin", "x".repeat(80))), "custom");
assert_eq!(safe_model_name(""), "custom");
// Whatever happens, no directory component survives.
for path in ["/Users/alice/models/ggml-large-v3-turbo.bin", "/models/alice's model.bin"] {
assert!(!safe_model_name(path).contains("alice"), "{path} leaked a path component");
assert!(!safe_model_name(path).contains('/'));
}
}
#[test]
fn pairing_url_matches_the_contract_shape() {
let id = "a".repeat(64);
let token = "0123456789abcdef0123456789abcdef";
assert_eq!(
format_pairing_url("http://localhost:8088", &id, token),
format!("http://localhost:8088/#{id}:{token}")
);
// A trailing slash on the origin must not produce a double slash.
assert_eq!(
format_pairing_url("https://vibe.example/", &id, token),
format_pairing_url("https://vibe.example", &id, token)
);
}
}
+408
View File
@@ -0,0 +1,408 @@
//! Wire types for the phone handoff protocol (`vibe/handoff/0`).
//!
//! One bi-directional stream per transfer. The phone is the connecting side:
//! it writes a `u32` big-endian header length, that many bytes of UTF-8 JSON
//! ([`HandoffHeader`]), then raw audio bytes until it finishes its send stream.
//! The desktop answers with newline-delimited JSON ([`HandoffEvent`]) on the
//! same stream.
use serde::{Deserialize, Serialize};
/// ALPN negotiated by both sides of the handoff.
pub const ALPN: &[u8] = b"vibe/handoff/0";
/// Reject headers larger than this (bytes).
pub const MAX_HEADER_LEN: u32 = 8192;
/// Reject transfers whose audio body exceeds this (bytes).
pub const MAX_AUDIO_BYTES: u64 = 512 * 1024 * 1024;
/// What the phone is asking for. Absent means [`OP_TRANSCRIBE`].
pub const OP_TRANSCRIBE: &str = "transcribe";
/// Ask what the loaded model can do. No audio body follows a capabilities request.
pub const OP_CAPABILITIES: &str = "capabilities";
/// Phases reported by [`HandoffEvent::Status`].
pub const PHASE_LOADING_MODEL: &str = "loading_model";
pub const PHASE_TRANSCRIBING: &str = "transcribing";
/// The JSON header the phone sends before the audio body.
#[derive(Debug, Clone, Deserialize)]
pub struct HandoffHeader {
/// 32 hex chars, must match the desktop's persisted pairing token.
pub token: String,
/// Which operation this stream is. Absent or `"transcribe"` means a
/// transcription request with an audio body; `"capabilities"` means a
/// question with no body. Kept as a raw string so an unknown value is
/// rejected as `invalid_request` rather than as a malformed header.
#[serde(default)]
pub op: Option<String>,
/// Original file name, used only to pick a temp-file extension.
#[serde(default)]
pub filename: Option<String>,
/// Content type reported by the phone. Informational.
#[allow(dead_code)]
#[serde(default)]
pub mime: Option<String>,
/// Whisper language code, or `None` for auto-detect.
#[serde(default)]
pub lang: Option<String>,
/// Translate the transcript to English. Passed straight through to Sona; only
/// meaningful when the loaded model reported `translation: true`, but that is
/// the phone's call to make, not enforced here.
#[serde(default)]
pub translate: Option<bool>,
}
/// One newline-delimited JSON object sent back to the phone.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HandoffEvent {
/// Header parsed and token accepted; the desktop is reading the audio body.
Accepted,
/// A phase change, so the phone can say "Loading model…" instead of showing a
/// transcription stuck at 0% while a large model loads.
///
/// NOT terminal, and deliberately additive: a client that does not know this
/// variant must ignore it and keep reading. Only `done` and `error` end a
/// stream — a client tracking "did I see a terminal event" must not set that
/// flag here.
Status { phase: String },
/// Terminal answer to `op: "capabilities"`. The language list is whatever the
/// currently loaded model supports — the phone must never hardcode one.
#[serde(rename_all = "camelCase")]
Capabilities {
model_loaded: bool,
model_name: Option<String>,
languages: Vec<String>,
language_detection: bool,
translation: bool,
/// So the phone can refuse an oversized recording before spending the
/// user's cellular data pushing it through a relay.
max_audio_bytes: u64,
},
/// Transcription progress, 0-100.
Progress { progress: i32 },
/// A transcript segment. `start`/`stop` are centiseconds, matching
/// Vibe's `Segment` type.
Segment {
start: i64,
stop: i64,
text: String,
speaker: Option<i32>,
},
/// Terminal success. `saved_path` is where the desktop kept the recording:
/// the audio only ever existed on the phone until now, so it is saved like any
/// other Vibe recording rather than thrown away.
#[serde(rename_all = "camelCase")]
Done {
text: String,
processing_time_sec: u64,
saved_path: Option<String>,
},
/// Terminal failure. No `Done` follows it.
Error { code: String, message: String },
}
impl HandoffEvent {
/// A non-terminal phase change. See [`PHASE_LOADING_MODEL`] and
/// [`PHASE_TRANSCRIBING`].
pub fn status(phase: &str) -> Self {
Self::Status {
phase: phase.to_string(),
}
}
/// Whether this event ends the stream. Only `done` and `error` do.
///
/// Nothing on the desktop branches on this — the terminal set is pinned here
/// (and in the tests) so that adding a variant forces a decision about it,
/// rather than a client silently guessing which events end a stream.
#[allow(dead_code)]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Done { .. } | Self::Error { .. })
}
/// The honest answer whenever the desktop cannot confirm what is loaded: no
/// model, no language list. The phone renders this as "load a model on your
/// desktop first". This is a normal state, never an error.
pub fn no_capabilities() -> Self {
Self::Capabilities {
model_loaded: false,
model_name: None,
languages: Vec::new(),
language_detection: false,
translation: false,
max_audio_bytes: MAX_AUDIO_BYTES,
}
}
/// Serialize as a single line, newline included.
pub fn to_line(&self) -> String {
match serde_json::to_string(self) {
Ok(json) => format!("{json}\n"),
Err(error) => {
// Serializing these types cannot realistically fail, but the phone
// must never be left waiting on a dropped connection.
tracing::error!("failed to serialize handoff event: {:?}", error);
"{\"type\":\"error\",\"code\":\"internal_error\",\"message\":\"failed to serialize event\"}\n".to_string()
}
}
}
}
/// Everything the frontend needs to write a phone transcription into the
/// transcripts store, matching `SaveTranscriptInput` in `lib/transcripts-store.ts`.
///
/// A handoff transcription happens entirely in Rust, so the frontend's queue never
/// sees it and would otherwise have nothing to save — which is why a phone
/// transcript never reached Recents.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HandoffCompletion {
/// Display name for the Recents row. The store appends its own timestamp.
pub name: String,
/// `sourcePath`: where the recording itself was saved.
pub saved_path: String,
pub segments: Vec<crate::transcript::Segment>,
/// The language actually used, or `None` for auto-detect.
pub language: Option<String>,
pub model_path: Option<String>,
}
/// Payload of the `handoff_activity` Tauri event emitted to the main window.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HandoffActivity {
pub state: &'static str,
pub message: Option<String>,
/// Where the phone's recording was saved. Set on the `done` state so the UI
/// can offer "Show in Finder"; `None` otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_path: Option<String>,
/// Set only on `done`: hand the frontend a complete transcript record so it
/// can call `saveTranscript`. Whether it actually saves is the frontend's
/// call — it owns the `transcription.saveTranscripts` preference.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<crate::transcript::Segment>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_path: Option<String>,
}
impl HandoffActivity {
pub fn new(state: &'static str, message: Option<String>) -> Self {
Self {
state,
message,
saved_path: None,
name: None,
segments: None,
language: None,
model_path: None,
}
}
pub fn done(completion: HandoffCompletion) -> Self {
Self {
state: "done",
message: None,
saved_path: Some(completion.saved_path),
name: Some(completion.name),
segments: Some(completion.segments),
language: completion.language,
model_path: completion.model_path,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_line_uses_camel_case_field_names() {
let line = HandoffEvent::Capabilities {
model_loaded: true,
model_name: Some("ggml-large-v3-turbo.bin".to_string()),
languages: vec!["en".to_string(), "he".to_string()],
language_detection: true,
translation: true,
max_audio_bytes: MAX_AUDIO_BYTES,
}
.to_line();
assert!(line.ends_with('\n'));
let parsed: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(parsed["type"], "capabilities");
assert_eq!(parsed["modelLoaded"], true);
assert_eq!(parsed["modelName"], "ggml-large-v3-turbo.bin");
assert_eq!(parsed["languageDetection"], true);
assert_eq!(parsed["translation"], true);
assert_eq!(parsed["languages"][1], "he");
// The phone pre-checks recording size against this to avoid a wasted upload.
assert_eq!(parsed["maxAudioBytes"], 536_870_912u64);
assert_eq!(parsed["maxAudioBytes"], MAX_AUDIO_BYTES);
}
#[test]
fn no_model_is_reported_as_empty_capabilities_not_an_error() {
let parsed: serde_json::Value = serde_json::from_str(HandoffEvent::no_capabilities().to_line().trim()).unwrap();
assert_eq!(parsed["type"], "capabilities");
assert_eq!(parsed["modelLoaded"], false);
assert!(parsed["modelName"].is_null());
assert_eq!(parsed["languages"].as_array().unwrap().len(), 0);
assert_eq!(parsed["languageDetection"], false);
assert_eq!(parsed["translation"], false);
// Still advertised with no model: the phone needs the cap regardless.
assert_eq!(parsed["maxAudioBytes"], MAX_AUDIO_BYTES);
}
#[test]
fn done_line_keeps_camel_case_processing_time() {
let parsed: serde_json::Value = serde_json::from_str(
HandoffEvent::Done {
text: "hi".to_string(),
processing_time_sec: 12,
saved_path: Some("/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a".to_string()),
}
.to_line()
.trim(),
)
.unwrap();
assert_eq!(parsed["type"], "done");
assert_eq!(parsed["processingTimeSec"], 12);
assert_eq!(parsed["savedPath"], "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a");
}
#[test]
fn status_line_is_additive_and_never_terminal() {
for phase in [PHASE_LOADING_MODEL, PHASE_TRANSCRIBING] {
let event = HandoffEvent::status(phase);
// A client tracking "did I see a terminal event" must not set that flag here.
assert!(!event.is_terminal(), "{phase} must not end the stream");
let parsed: serde_json::Value = serde_json::from_str(event.to_line().trim()).unwrap();
assert_eq!(parsed["type"], "status");
assert_eq!(parsed["phase"], phase);
}
assert_eq!(
HandoffEvent::status(PHASE_LOADING_MODEL).to_line(),
"{\"type\":\"status\",\"phase\":\"loading_model\"}\n"
);
}
#[test]
fn only_done_and_error_are_terminal() {
assert!(HandoffEvent::Done {
text: String::new(),
processing_time_sec: 0,
saved_path: None,
}
.is_terminal());
assert!(HandoffEvent::Error {
code: "no_model".to_string(),
message: String::new(),
}
.is_terminal());
// Everything else leaves the stream open.
assert!(!HandoffEvent::Accepted.is_terminal());
assert!(!HandoffEvent::Progress { progress: 42 }.is_terminal());
assert!(!HandoffEvent::no_capabilities().is_terminal());
assert!(!HandoffEvent::Segment {
start: 0,
stop: 1,
text: String::new(),
speaker: None,
}
.is_terminal());
}
#[test]
fn transcribe_header_carries_lang_and_translate() {
let header: HandoffHeader = serde_json::from_str(
r#"{"token":"0123456789abcdef0123456789abcdef","filename":"recording.m4a","mime":"audio/mp4","lang":"he","translate":true}"#,
)
.unwrap();
assert!(header.op.is_none());
assert_eq!(header.lang.as_deref(), Some("he"));
assert_eq!(header.translate, Some(true));
}
#[test]
fn translate_defaults_to_absent_when_the_phone_omits_it() {
let header: HandoffHeader = serde_json::from_str(r#"{"token":"0123456789abcdef0123456789abcdef"}"#).unwrap();
assert!(header.translate.is_none());
}
#[test]
fn done_activity_carries_a_full_transcript_record_for_recents() {
let parsed: serde_json::Value = serde_json::to_value(HandoffActivity::done(HandoffCompletion {
name: "Phone recording".to_string(),
saved_path: "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a".to_string(),
segments: vec![crate::transcript::Segment {
start: 120,
stop: 350,
text: "hello there".to_string(),
speaker: None,
}],
language: Some("he".to_string()),
model_path: Some("/models/ggml-medium.bin".to_string()),
}))
.unwrap();
assert_eq!(parsed["state"], "done");
// Exactly the field names `saveTranscript` expects.
assert_eq!(parsed["name"], "Phone recording");
assert_eq!(parsed["savedPath"], "/Users/me/Documents/Vibe/phone-2026-08-22-14-30-05.m4a");
assert_eq!(parsed["language"], "he");
assert_eq!(parsed["modelPath"], "/models/ggml-medium.bin");
assert_eq!(parsed["segments"][0]["start"], 120);
assert_eq!(parsed["segments"][0]["stop"], 350);
assert_eq!(parsed["segments"][0]["text"], "hello there");
}
#[test]
fn non_done_activity_carries_no_transcript_fields() {
let receiving: serde_json::Value = serde_json::to_value(HandoffActivity::new("receiving", None)).unwrap();
assert_eq!(receiving["state"], "receiving");
for absent in ["savedPath", "name", "segments", "language", "modelPath"] {
assert!(receiving.get(absent).is_none(), "{absent} should be omitted");
}
}
#[test]
fn auto_detect_leaves_language_absent_rather_than_guessing() {
let parsed: serde_json::Value = serde_json::to_value(HandoffActivity::done(HandoffCompletion {
name: "Phone recording".to_string(),
saved_path: "/tmp/x.m4a".to_string(),
segments: Vec::new(),
language: None,
model_path: None,
}))
.unwrap();
assert!(parsed.get("language").is_none());
assert!(parsed.get("modelPath").is_none());
assert_eq!(parsed["segments"].as_array().unwrap().len(), 0);
}
#[test]
fn capabilities_header_with_empty_filename_and_mime_is_accepted() {
// Exactly what agent B's wasm client and the native test client emit.
let raw = r#"{"op":"capabilities","token":"0123456789abcdef0123456789abcdef","filename":"","mime":"","lang":null}"#;
let header: HandoffHeader = serde_json::from_str(raw).unwrap();
assert_eq!(header.op.as_deref(), Some(OP_CAPABILITIES));
assert_eq!(header.filename.as_deref(), Some(""));
assert_eq!(header.mime.as_deref(), Some(""));
assert!(header.lang.is_none());
}
#[test]
fn header_tolerates_missing_optional_fields() {
let header: HandoffHeader = serde_json::from_str(r#"{"token":"0123456789abcdef0123456789abcdef"}"#).unwrap();
assert!(header.op.is_none());
assert!(header.filename.is_none());
assert!(header.mime.is_none());
}
}
+16 -1
View File
@@ -11,6 +11,7 @@ mod diagnostics;
mod dictation_indicator;
mod error;
mod ffmpeg;
mod handoff;
mod logging;
mod setup;
mod sona;
@@ -39,6 +40,7 @@ async fn main() -> Result<()> {
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.manage(tray::TrayState::default())
.manage(tokio::sync::Mutex::<Option<handoff::HandoffState>>::new(None))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -66,7 +68,11 @@ async fn main() -> Result<()> {
.plugin(tauri_plugin_updater::Builder::default().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init());
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
));
if analytics::is_aptabase_configured() {
let options = tauri_plugin_aptabase::InitOptions {
@@ -88,6 +94,10 @@ async fn main() -> Result<()> {
let app = builder
.invoke_handler(tauri::generate_handler![
cmd::download::download_file,
cmd::handoff_cmd::handoff_status,
cmd::handoff_cmd::handoff_start,
cmd::handoff_cmd::handoff_stop,
cmd::handoff_cmd::handoff_regenerate_token,
cmd::app::get_cargo_features,
cmd::config::write_config_atomically,
cmd::config::get_config_path,
@@ -153,6 +163,11 @@ async fn main() -> Result<()> {
process.kill();
}
};
// Drop the handoff router so the iroh endpoint closes cleanly.
let handoff = app.state::<tokio::sync::Mutex<Option<handoff::HandoffState>>>();
if let Ok(mut guard) = handoff.try_lock() {
guard.take();
};
}
_ => {}
});
+3
View File
@@ -153,5 +153,8 @@ pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
}
crate::dictation_indicator::initialize(app.handle());
}
// Bring phone handoff back up if the user had it on. Returns immediately and binds
// in the background, so an offline or slow network never delays launch.
crate::handoff::restore_on_startup(app.handle());
Ok(())
}
+9 -3
View File
@@ -123,9 +123,15 @@ where
impl SonaProcess {
pub async fn model_metadata(&self, path: &str) -> Result<ModelMetadata> {
let response = self
.client
.post(format!("{}/v1/models/metadata", self.base_url()))
Self::model_metadata_with(&self.client, &self.base_url(), path).await
}
/// Same request as [`SonaProcess::model_metadata`], but taking a cloned client
/// and base url like [`SonaProcess::transcribe_stream`] does, so callers can
/// release the `SonaState` mutex before the round trip.
pub async fn model_metadata_with(client: &reqwest::Client, base_url: &str, path: &str) -> Result<ModelMetadata> {
let response = client
.post(format!("{}/v1/models/metadata", base_url))
.json(&serde_json::json!({ "path": path }))
.send()
.await
+3
View File
@@ -13,6 +13,7 @@ import { usePreferenceProvider } from './providers/preference'
import { ErrorBoundary } from 'react-error-boundary'
import { BoundaryFallback } from './components/boundary-fallback'
import ErrorModalWithContext from './components/error-modal-with-context'
import HandoffTranscriptSaver from './components/handoff-transcript-saver'
import { FilesProvider } from './providers/files-provider'
import { HotkeyProvider } from './providers/hotkey'
import { ToastProvider } from './providers/toast'
@@ -47,6 +48,8 @@ function AppContent() {
<HotkeyProvider>
<ErrorModalWithContext />
<UpdateProgress />
{/* Phone transcriptions arrive while the user is elsewhere, so this must outlive any page. */}
<HandoffTranscriptSaver />
<FilesProvider>
<Routes>
<Route path="/" element={<MainPage />} />
@@ -0,0 +1,109 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { m } from '~/paraglide/messages.js'
import type { Segment } from '~/lib/transcript'
import { notifyTranscriptsChanged, saveTranscript } from '~/lib/transcripts-store'
import { usePreferenceProvider } from '~/providers/preference'
/**
* Phone handoff transcripts land in Recents.
*
* A phone recording is transcribed entirely in Rust, so it never passes through the transcribe
* queue that normally persists a finished job. Without this listener the result exists only as a
* `handoff_activity` event and disappears the moment the app is closed.
*
* The Settings → Phone section listens to the same event, but it is mounted only while that modal
* is open — and a phone transcription is by definition something that arrives while the user is
* doing something else. This component is mounted once for the app's lifetime instead.
*/
interface HandoffActivity {
state: 'receiving' | 'loading_model' | 'transcribing' | 'done' | 'error'
message?: string | null
/** Absolute path of the saved phone audio. Only on `done`; either spelling is accepted. */
savedPath?: string | null
saved_path?: string | null
/** Everything below is only present on `done`, and only once the backend supplies it. */
segments?: Segment[] | null
language?: string | null
modelPath?: string | null
model_path?: string | null
name?: string | null
}
function isSegment(value: unknown): value is Segment {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<Segment>
return typeof candidate.text === 'string' && typeof candidate.start === 'number' && typeof candidate.stop === 'number'
}
/** Keep only well-formed segments; a payload without any is treated as "nothing to save". */
function usableSegments(payload: HandoffActivity): Segment[] {
return Array.isArray(payload.segments) ? payload.segments.filter(isSegment) : []
}
export default function HandoffTranscriptSaver() {
const preference = usePreferenceProvider()
// The listener is registered once; reading the preference through a ref keeps it current.
const preferenceRef = useRef(preference)
// Guards against saving the same recording twice (a re-emitted event, a remount in dev).
const savedRef = useRef(new Set<string>())
useEffect(() => {
preferenceRef.current = preference
}, [preference])
useEffect(() => {
let unlisten: UnlistenFn | undefined
let cancelled = false
const pending = listen<HandoffActivity>('handoff_activity', ({ payload }) => {
if (payload?.state !== 'done') return
const segments = usableSegments(payload)
// The backend may not carry the transcript yet; better nothing than an empty record.
if (segments.length === 0) return
const sourcePath = payload.savedPath ?? payload.saved_path ?? ''
const name = payload.name?.trim() || m.phoneRecording()
// Same rule as a local transcription: auto-save only when the user asked for it.
if (!preferenceRef.current.saveTranscripts) return
const key = sourcePath || `${name}:${segments.length}:${segments[0].start}`
if (savedRef.current.has(key)) return
savedRef.current.add(key)
// Fire-and-forget, like the queue's own persist: saving must never block the UI.
void saveTranscript({
name,
sourcePath,
segments,
language: payload.language ?? undefined,
modelPath: payload.modelPath ?? payload.model_path ?? null,
}).then((savedTranscriptPath) => {
if (!savedTranscriptPath) {
// Let it be retried if the same recording is announced again.
savedRef.current.delete(key)
return
}
notifyTranscriptsChanged()
// Quiet, non-modal: the transcription happened while the user was looking elsewhere,
// so a single line telling them where it went is worth more than silence.
toast.success(m.phoneTranscriptionSaved(), { description: name, position: 'bottom-right' })
})
})
void pending.then((fn) => {
if (cancelled) fn()
else unlisten = fn
})
return () => {
cancelled = true
unlisten?.()
}
}, [])
return null
}
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core'
import * as pathApi from '@tauri-apps/api/path'
import * as dialog from '@tauri-apps/plugin-dialog'
import * as fs from '@tauri-apps/plugin-fs'
import { Download, MoreHorizontal, Plus, Search, Settings } from 'lucide-react'
import { Download, MoreHorizontal, Plus, Search, Settings, Smartphone } from 'lucide-react'
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
@@ -428,6 +428,18 @@ export default function RecentsSidebar() {
<Settings className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
{m.settings()}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={m.phone()}
onClick={() => openSettingsSection('phone')}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-xl transition-colors duration-150 hover:bg-muted/60">
<Smartphone className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="top">{m.phone()}</TooltipContent>
</Tooltip>
{availableUpdate && (
<Tooltip>
<TooltipTrigger asChild>
+6 -2
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { Bot, Cpu, Globe, Mic, ShieldCheck, SlidersHorizontal, Sparkles, Terminal, Wrench, X } from 'lucide-react'
import { Bot, Cpu, Globe, Mic, ShieldCheck, SlidersHorizontal, Smartphone, Sparkles, Terminal, Wrench, X } from 'lucide-react'
import { ModifyState } from '~/lib/types'
import { viewModel } from './view-model'
import { Button } from '~/components/ui/button'
@@ -9,6 +9,7 @@ import { ApiSection } from './sections/api'
import { DictationSection } from './sections/dictation'
import { GeneralSection } from './sections/general'
import { ModelsSection } from './sections/models'
import { PhoneSection } from './sections/phone'
import { PrivacySection } from './sections/privacy'
import { SummarizeSection } from './sections/summarize'
import { TranscriptionSection } from './sections/transcription'
@@ -19,7 +20,7 @@ interface SettingsPageProps {
scrollTo?: string
}
type SectionId = 'general' | 'transcription' | 'models' | 'summarize' | 'tuning' | 'dictation' | 'api' | 'privacy' | 'advanced'
type SectionId = 'general' | 'transcription' | 'models' | 'summarize' | 'tuning' | 'dictation' | 'phone' | 'api' | 'privacy' | 'advanced'
interface SettingsSection {
id: SectionId
@@ -56,6 +57,7 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps
sections: [
{ id: 'dictation', label: m.globalDictation(), icon: <Mic className="h-4 w-4" /> },
{ id: 'summarize', label: m.processWithLlm(), icon: <Sparkles className="h-4 w-4" /> },
{ id: 'phone', label: m.phone(), icon: <Smartphone className="h-4 w-4" /> },
],
},
{
@@ -125,6 +127,8 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps
{activeSection === 'dictation' && <DictationSection />}
{activeSection === 'phone' && <PhoneSection vm={vm} />}
{activeSection === 'api' && <ApiSection vm={vm} />}
{activeSection === 'privacy' && <PrivacySection vm={vm} />}
@@ -1,5 +1,8 @@
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart'
import { openUrl } from '@tauri-apps/plugin-opener'
import { Moon, Sun } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { m } from '~/paraglide/messages.js'
import { ReactComponent as DiscordIcon } from '~/icons/discord.svg'
import { ReactComponent as GithubIcon } from '~/icons/github.svg'
@@ -11,6 +14,54 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~
import { Switch } from '~/components/ui/switch'
import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
/**
* The OS owns this setting, not our config: the user can remove the login item from
* system settings and a reinstall can clear it. So we read the real state on mount and
* re-read it after every write instead of persisting a key that would drift.
*/
function LaunchAtStartupSwitch() {
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
isEnabled()
.then((value) => {
if (!cancelled) {
setEnabled(value)
}
})
.catch((error) => {
console.error('failed to read autostart state', error)
})
return () => {
cancelled = true
}
}, [])
async function onCheckedChange(next: boolean) {
setBusy(true)
try {
if (next) {
await enable()
} else {
await disable()
}
} catch (error) {
const message = next ? m.couldNotEnableLaunchAtStartup : m.couldNotDisableLaunchAtStartup
toast.error(message({ error: String(error) }))
}
try {
setEnabled(await isEnabled())
} catch (error) {
console.error('failed to read autostart state', error)
}
setBusy(false)
}
return <Switch checked={enabled} disabled={busy} onCheckedChange={onCheckedChange} />
}
export function GeneralSection({ vm }: { vm: SettingsViewModel }) {
const themeLabels = { light: m.light, dark: m.dark } as const
const themeIcons = { light: Sun, dark: Moon } as const
@@ -24,6 +75,9 @@ export function GeneralSection({ vm }: { vm: SettingsViewModel }) {
<SettingsRow label={m.closeToTray()} description={m.closeToTrayInfo()}>
<Switch checked={vm.preference.closeToTray} onCheckedChange={vm.preference.setCloseToTray} />
</SettingsRow>
<SettingsRow label={m.launchAtStartup()} description={m.launchAtStartupInfo()}>
<LaunchAtStartupSwitch />
</SettingsRow>
<SettingsRow label={m.theme()}>
<Select value={vm.preference.theme} onValueChange={(value) => vm.preference.setTheme(value as 'light' | 'dark')}>
<SelectTrigger className={`w-36 ${rowControlClass}`}>
@@ -0,0 +1,670 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import * as clipboard from '@tauri-apps/plugin-clipboard-manager'
import { platform } from '@tauri-apps/plugin-os'
import { Check, Copy, FolderOpen, RefreshCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { m } from '~/paraglide/messages.js'
import { Spinner } from '~/components/ui/spinner'
import { Switch } from '~/components/ui/switch'
import { ActionRow, IconAction, SettingsField, SettingsGroup, SettingsNote, SettingsRow, type SettingsViewModel } from './shared'
/* -------------------------------------------------------------------------- */
/* QR encoding — byte mode, error-correction level L, automatic version. */
/* Self-contained on purpose: the payload is one short ASCII URL, which is */
/* not worth an npm dependency. Structure follows ISO/IEC 18004. */
/* -------------------------------------------------------------------------- */
const EC_CODEWORDS_PER_BLOCK = [
-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
30, 30, 30, 30,
]
const EC_BLOCKS = [
-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25,
]
/** Format-info value for error-correction level L. */
const EC_FORMAT_BITS = 1
const PENALTY_N1 = 3
const PENALTY_N2 = 3
const PENALTY_N3 = 40
const PENALTY_N4 = 10
function getBit(x: number, i: number): boolean {
return ((x >>> i) & 1) !== 0
}
/** Modules available for data and error correction, i.e. everything but the function patterns. */
function rawDataModules(version: number): number {
let result = (16 * version + 128) * version + 64
if (version >= 2) {
const numAlign = Math.floor(version / 7) + 2
result -= (25 * numAlign - 10) * numAlign - 55
if (version >= 7) result -= 36
}
return result
}
function rawCodewords(version: number): number {
return Math.floor(rawDataModules(version) / 8)
}
function dataCodewords(version: number): number {
return rawCodewords(version) - EC_CODEWORDS_PER_BLOCK[version] * EC_BLOCKS[version]
}
function alignmentPatternPositions(version: number): number[] {
if (version === 1) return []
const numAlign = Math.floor(version / 7) + 2
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2) / 2) * 2
const result = [6]
for (let pos = version * 4 + 17 - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos)
return result
}
/* ---- GF(2^8) arithmetic for ReedSolomon ---- */
function gfMultiply(x: number, y: number): number {
let z = 0
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d)
z ^= ((y >>> i) & 1) * x
}
return z & 0xff
}
function rsDivisor(degree: number): number[] {
const result: number[] = []
for (let i = 0; i < degree - 1; i++) result.push(0)
result.push(1)
let root = 1
for (let i = 0; i < degree; i++) {
for (let j = 0; j < result.length; j++) {
result[j] = gfMultiply(result[j], root)
if (j + 1 < result.length) result[j] ^= result[j + 1]
}
root = gfMultiply(root, 0x02)
}
return result
}
function rsRemainder(data: number[], divisor: number[]): number[] {
const result = divisor.map(() => 0)
for (const b of data) {
const factor = b ^ (result.shift() as number)
result.push(0)
divisor.forEach((coef, i) => (result[i] ^= gfMultiply(coef, factor)))
}
return result
}
/* ---- Encoding ---- */
function appendBits(value: number, len: number, bits: number[]): void {
for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1)
}
function toCodewords(text: string): { version: number; codewords: number[] } {
const data = Array.from(new TextEncoder().encode(text))
let version = 1
for (; version <= 40; version++) {
const charCountBits = version <= 9 ? 8 : 16
if (4 + charCountBits + data.length * 8 <= dataCodewords(version) * 8) break
}
if (version > 40) throw new Error('QR payload too long')
const bits: number[] = []
appendBits(0b0100, 4, bits) // byte mode
appendBits(data.length, version <= 9 ? 8 : 16, bits)
for (const b of data) appendBits(b, 8, bits)
const capacityBits = dataCodewords(version) * 8
appendBits(0, Math.min(4, capacityBits - bits.length), bits) // terminator
appendBits(0, (8 - (bits.length % 8)) % 8, bits) // pad to a byte boundary
for (let pad = 0xec; bits.length < capacityBits; pad ^= 0xec ^ 0x11) appendBits(pad, 8, bits)
const codewords: number[] = []
for (let i = 0; i < bits.length; i += 8) {
let byte = 0
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j]
codewords.push(byte)
}
return { version, codewords }
}
/** Split into blocks, append error-correction codewords to each, then interleave as the spec requires. */
function addEccAndInterleave(version: number, data: number[]): number[] {
const numBlocks = EC_BLOCKS[version]
const blockEccLen = EC_CODEWORDS_PER_BLOCK[version]
const raw = rawCodewords(version)
const numShortBlocks = numBlocks - (raw % numBlocks)
const shortBlockLen = Math.floor(raw / numBlocks)
const blocks: number[][] = []
const divisor = rsDivisor(blockEccLen)
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1))
k += dat.length
const ecc = rsRemainder(dat, divisor)
if (i < numShortBlocks) dat.push(0) // placeholder, skipped while interleaving
blocks.push(dat.concat(ecc))
}
const result: number[] = []
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i])
})
}
return result
}
function maskAt(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0:
return (x + y) % 2 === 0
case 1:
return y % 2 === 0
case 2:
return x % 3 === 0
case 3:
return (x + y) % 3 === 0
case 4:
return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0
case 5:
return ((x * y) % 2) + ((x * y) % 3) === 0
case 6:
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
default:
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
}
}
function applyMask(modules: boolean[][], isFunction: boolean[][], mask: number): void {
const size = modules.length
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!isFunction[y][x] && maskAt(mask, x, y)) modules[y][x] = !modules[y][x]
}
}
}
function finderPenaltyAddHistory(size: number, runLength: number, history: number[]): void {
if (history[0] === 0) runLength += size // the quiet zone counts as a light run
history.pop()
history.unshift(runLength)
}
function finderPenaltyCountPatterns(history: number[]): number {
const n = history[1]
const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n
return (core && history[0] >= n * 4 && history[6] >= n ? 1 : 0) + (core && history[6] >= n * 4 && history[0] >= n ? 1 : 0)
}
function finderPenaltyTerminate(size: number, runColor: boolean, runLength: number, history: number[]): number {
if (runColor) {
finderPenaltyAddHistory(size, runLength, history)
runLength = 0
}
finderPenaltyAddHistory(size, runLength + size, history)
return finderPenaltyCountPatterns(history)
}
/** The spec's four penalty rules; the mask with the lowest score wins. */
function penaltyScore(modules: boolean[][]): number {
const size = modules.length
let result = 0
for (let outer = 0; outer < size; outer++) {
for (const horizontal of [true, false]) {
let runColor = false
let runLength = 0
const history = [0, 0, 0, 0, 0, 0, 0]
for (let inner = 0; inner < size; inner++) {
const cell = horizontal ? modules[outer][inner] : modules[inner][outer]
if (cell === runColor) {
runLength++
if (runLength === 5) result += PENALTY_N1
else if (runLength > 5) result++
} else {
finderPenaltyAddHistory(size, runLength, history)
if (!runColor) result += finderPenaltyCountPatterns(history) * PENALTY_N3
runColor = cell
runLength = 1
}
}
result += finderPenaltyTerminate(size, runColor, runLength, history) * PENALTY_N3
}
}
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const cell = modules[y][x]
if (cell === modules[y][x + 1] && cell === modules[y + 1][x] && cell === modules[y + 1][x + 1]) result += PENALTY_N2
}
}
let dark = 0
for (const row of modules) for (const cell of row) if (cell) dark++
const total = size * size
const deviation = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1
return result + deviation * PENALTY_N4
}
/**
* Encode `text` into a square matrix of modules; `true` is a dark module.
* The matrix carries no quiet zone — the renderer adds it.
*/
function encodeQr(text: string): boolean[][] {
const { version, codewords } = toCodewords(text)
const size = version * 4 + 17
const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
const setFunction = (x: number, y: number, dark: boolean) => {
modules[y][x] = dark
isFunction[y][x] = true
}
// Timing patterns
for (let i = 0; i < size; i++) {
setFunction(6, i, i % 2 === 0)
setFunction(i, 6, i % 2 === 0)
}
// Finder patterns, together with their separators
for (const [cx, cy] of [
[3, 3],
[size - 4, 3],
[3, size - 4],
]) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy))
const x = cx + dx
const y = cy + dy
if (x >= 0 && x < size && y >= 0 && y < size) setFunction(x, y, dist !== 2 && dist !== 4)
}
}
}
// Alignment patterns, skipping the three that would collide with the finders
const alignPos = alignmentPatternPositions(version)
for (let i = 0; i < alignPos.length; i++) {
for (let j = 0; j < alignPos.length; j++) {
if ((i === 0 && j === 0) || (i === 0 && j === alignPos.length - 1) || (i === alignPos.length - 1 && j === 0)) continue
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setFunction(alignPos[i] + dx, alignPos[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
}
}
}
}
const drawFormatBits = (mask: number) => {
const data = (EC_FORMAT_BITS << 3) | mask
let rem = data
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
const bits = ((data << 10) | rem) ^ 0x5412
for (let i = 0; i <= 5; i++) setFunction(8, i, getBit(bits, i))
setFunction(8, 7, getBit(bits, 6))
setFunction(8, 8, getBit(bits, 7))
setFunction(7, 8, getBit(bits, 8))
for (let i = 9; i < 15; i++) setFunction(14 - i, 8, getBit(bits, i))
for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, getBit(bits, i))
for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, getBit(bits, i))
setFunction(8, size - 8, true) // the module that is always dark
}
drawFormatBits(0)
if (version >= 7) {
let rem = version
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
const bits = (version << 12) | rem
for (let i = 0; i < 18; i++) {
const dark = getBit(bits, i)
const a = size - 11 + (i % 3)
const b = Math.floor(i / 3)
setFunction(a, b, dark)
setFunction(b, a, dark)
}
}
// Data, zig-zagging up and down two-module-wide columns
const allCodewords = addEccAndInterleave(version, codewords)
let bit = 0
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j
const upward = ((right + 1) & 2) === 0
const y = upward ? size - 1 - vert : vert
if (!isFunction[y][x] && bit < allCodewords.length * 8) {
modules[y][x] = getBit(allCodewords[bit >>> 3], 7 - (bit & 7))
bit++
}
}
}
}
// Try every mask and keep the one the penalty rules like best
let bestMask = 0
let bestPenalty = Infinity
for (let mask = 0; mask < 8; mask++) {
applyMask(modules, isFunction, mask)
drawFormatBits(mask)
const penalty = penaltyScore(modules)
if (penalty < bestPenalty) {
bestPenalty = penalty
bestMask = mask
}
applyMask(modules, isFunction, mask) // XOR is its own inverse, so this undoes it
}
applyMask(modules, isFunction, bestMask)
drawFormatBits(bestMask)
return modules
}
/**
* QR codes are read optically, so the colours are hard-coded rather than themed:
* a dark-on-dark code in dark mode does not scan.
*/
function QrCode({ value, size }: { value: string; size: number }) {
const matrix = useMemo(() => {
try {
return encodeQr(value)
} catch (error) {
console.error(error)
return null
}
}, [value])
if (!matrix) return <p className="text-xs text-destructive">{m.pairingQrCodeError()}</p>
const quietZone = 4
const dimension = matrix.length + quietZone * 2
let path = ''
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix.length; x++) {
if (matrix[y][x]) path += `M${x + quietZone} ${y + quietZone}h1v1h-1z`
}
}
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${dimension} ${dimension}`}
shapeRendering="crispEdges"
role="img"
aria-label={m.pairingQrCode()}>
<rect width={dimension} height={dimension} fill="#ffffff" />
<path d={path} fill="#000000" />
</svg>
)
}
/* -------------------------------------------------------------------------- */
/* Phone handoff settings */
/* -------------------------------------------------------------------------- */
/** As it comes off the wire — accept either spelling, the Rust side may or may not rename to camelCase. */
interface HandoffStatusPayload {
enabled: boolean
endpointId?: string | null
endpoint_id?: string | null
pairingUrl?: string | null
pairing_url?: string | null
}
interface HandoffStatus {
enabled: boolean
endpointId: string | null
pairingUrl: string | null
}
interface HandoffActivity {
state: 'receiving' | 'loading_model' | 'transcribing' | 'done' | 'error'
message: string | null
/** Where the phone's audio was saved. Only present on `done`; either spelling is accepted. */
savedPath?: string | null
saved_path?: string | null
}
const OFF: HandoffStatus = { enabled: false, endpointId: null, pairingUrl: null }
function normalizeStatus(payload: HandoffStatusPayload): HandoffStatus {
return {
enabled: Boolean(payload.enabled),
endpointId: payload.endpointId ?? payload.endpoint_id ?? null,
pairingUrl: payload.pairingUrl ?? payload.pairing_url ?? null,
}
}
function errorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) return String((error as { message: unknown }).message)
return String(error)
}
/** The backend half of this feature may not be in the build yet — tell them so instead of crashing. */
function isMissingCommand(error: unknown): boolean {
const text = errorMessage(error).toLowerCase()
return text.includes('not found') || text.includes('not allowed') || text.includes('unknown') || text.includes('__tauri')
}
/** Whatever the platform calls its file manager. Falls back to neutral wording off-Tauri. */
function revealLabel(): string {
try {
if (platform() === 'macos') return m.showInFinder()
if (platform() === 'windows') return m.showInFileExplorer()
} catch (error) {
console.error(error)
}
return m.showInFolder()
}
/** Endpoint ids are 64 hex characters; only the ends are useful to a human. */
function shortenEndpointId(id: string): string {
return id.length <= 20 ? id : `${id.slice(0, 8)}${id.slice(-8)}`
}
function activityLine(activity: HandoffActivity | null): { text: string; busy: boolean; failed: boolean } {
if (!activity) return { text: m.phoneWaitingForRecording(), busy: false, failed: false }
switch (activity.state) {
case 'receiving':
return { text: activity.message ?? m.phoneReceivingAudio(), busy: true, failed: false }
case 'loading_model':
return { text: activity.message ?? m.phoneLoadingModel(), busy: true, failed: false }
case 'transcribing':
return { text: activity.message ?? m.phoneTranscribing(), busy: true, failed: false }
case 'done':
return { text: activity.message ?? m.phoneTranscriptSentBack(), busy: false, failed: false }
default:
return { text: activity.message ?? m.phoneRecordingFailed(), busy: false, failed: true }
}
}
export function PhoneSection(_props: { vm: SettingsViewModel }) {
const [status, setStatus] = useState<HandoffStatus>(OFF)
const [unavailable, setUnavailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [activity, setActivity] = useState<HandoffActivity | null>(null)
const [copied, setCopied] = useState(false)
function handleError(caught: unknown) {
console.error(caught)
if (isMissingCommand(caught)) setUnavailable(true)
else setError(errorMessage(caught))
}
useEffect(() => {
invoke<HandoffStatusPayload>('handoff_status')
.then((payload) => setStatus(normalizeStatus(payload)))
.catch((caught) => {
console.error(caught)
if (isMissingCommand(caught)) setUnavailable(true)
else setError(errorMessage(caught))
})
}, [])
useEffect(() => {
let unlisten: UnlistenFn | undefined
let cancelled = false
listen<HandoffActivity>('handoff_activity', (event) => setActivity(event.payload))
.then((fn) => {
if (cancelled) fn()
else unlisten = fn
})
.catch(console.error)
return () => {
cancelled = true
unlisten?.()
}
}, [])
async function toggle(next: boolean) {
setBusy(true)
setError(null)
try {
if (next) {
setStatus(normalizeStatus(await invoke<HandoffStatusPayload>('handoff_start')))
} else {
await invoke('handoff_stop')
setStatus(OFF)
setActivity(null)
}
} catch (caught) {
handleError(caught)
} finally {
setBusy(false)
}
}
async function regenerate() {
setBusy(true)
setError(null)
try {
setStatus(normalizeStatus(await invoke<HandoffStatusPayload>('handoff_regenerate_token')))
} catch (caught) {
handleError(caught)
} finally {
setBusy(false)
}
}
async function copyPairingUrl() {
if (!status.pairingUrl) return
try {
await clipboard.writeText(status.pairingUrl)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch (caught) {
handleError(caught)
}
}
const blurb = m.phoneHandoffInfo()
if (unavailable) {
return (
<div className="space-y-6">
<SettingsGroup description={blurb}>
<SettingsNote>{m.phoneHandoffUnavailable()}</SettingsNote>
</SettingsGroup>
</div>
)
}
const activityState = activityLine(activity)
// The phone's recording is kept, not transcribed from a temp file — so it is worth pointing at.
const savedPath = activity?.state === 'done' ? (activity.savedPath ?? activity.saved_path ?? null) : null
return (
<div className="space-y-6">
<SettingsGroup description={blurb}>
<SettingsRow label={m.phoneHandoff()} description={m.phoneHandoffToggleInfo()}>
<Switch checked={status.enabled} disabled={busy} onCheckedChange={toggle} />
</SettingsRow>
<SettingsNote>{m.phoneHandoffRelayNote()}</SettingsNote>
</SettingsGroup>
{error && (
<SettingsGroup>
<SettingsNote>
<span className="text-destructive">{error}</span>
</SettingsNote>
</SettingsGroup>
)}
{status.enabled && (
<>
<SettingsGroup title={m.pairAPhone()}>
{status.pairingUrl ? (
<>
<SettingsField description={m.scanPairingCodeInfo()}>
<div className="flex justify-center">
<div className="rounded-xl bg-white p-3 shadow-xs">
<QrCode value={status.pairingUrl} size={200} />
</div>
</div>
</SettingsField>
<SettingsField label={m.pairingLink()}>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 rounded-lg border border-border/60 bg-muted/40 px-3 py-2 font-mono text-xs break-all text-foreground select-text">
{status.pairingUrl}
</code>
<IconAction
label={copied ? m.copied() : m.copyPairingLink()}
icon={copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
onClick={copyPairingUrl}
/>
</div>
</SettingsField>
</>
) : (
<SettingsNote>{m.noPairingCode()}</SettingsNote>
)}
{status.endpointId && (
<SettingsRow label={m.thisComputer()} description={m.endpointIdInfo()}>
<span className="font-mono text-xs text-muted-foreground select-text">{shortenEndpointId(status.endpointId)}</span>
</SettingsRow>
)}
</SettingsGroup>
<SettingsGroup title={m.status()}>
<SettingsRow label={m.phone()} description={activityState.text} clampDescription={false}>
{activityState.busy && <Spinner className="text-muted-foreground" />}
{activityState.failed && <span className="text-xs text-destructive">{m.failed()}</span>}
</SettingsRow>
{savedPath && (
<ActionRow
label={revealLabel()}
description={savedPath}
icon={<FolderOpen className="h-4 w-4" />}
onClick={() => void invoke('open_path', { path: savedPath })}
/>
)}
</SettingsGroup>
<SettingsGroup>
<ActionRow
label={m.regeneratePairingCode()}
description={m.regeneratePairingCodeInfo()}
icon={<RefreshCw className="h-4 w-4" />}
disabled={busy}
destructive
onClick={regenerate}
/>
</SettingsGroup>
</>
)}
</div>
)
}
+4056
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "handoff-probe"
version = "0.1.0"
edition = "2021"
publish = false
# Standalone: detached from the vibe workspace so it never affects the desktop build.
[workspace]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
iroh = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+182
View File
@@ -0,0 +1,182 @@
//! Fake phone for the Vibe handoff protocol.
//!
//! Speaks the exact wire protocol the PWA speaks, but natively, so the desktop
//! receiver can be tested end to end without a browser or a wasm build in the loop.
//!
//! handoff-probe send --peer <endpoint_id>:<token> --file recording.m4a
//! handoff-probe send --url 'http://localhost:8088/#<endpoint_id>:<token>' --file a.wav
use anyhow::{bail, Context, Result};
use clap::Parser;
use iroh::{endpoint::presets, Endpoint, EndpointId};
use serde::Serialize;
use std::path::PathBuf;
const ALPN: &[u8] = b"vibe/handoff/0";
#[derive(Parser)]
#[command(about = "Fake phone for the Vibe handoff protocol")]
struct Cli {
/// Pairing as `<endpoint_id>:<token>`.
#[arg(long, conflicts_with = "url")]
peer: Option<String>,
/// Full pairing URL as encoded in the desktop's QR code.
#[arg(long)]
url: Option<String>,
/// Audio file to send. Not needed with --capabilities.
#[arg(long, required_unless_present = "capabilities")]
file: Option<PathBuf>,
/// Optional whisper language code; omitted means auto-detect.
#[arg(long)]
lang: Option<String>,
/// Ask the desktop what it supports instead of sending audio.
#[arg(long)]
capabilities: bool,
}
#[derive(Serialize)]
struct Header {
op: &'static str,
token: String,
filename: String,
mime: String,
lang: Option<String>,
}
fn parse_pairing(cli: &Cli) -> Result<(EndpointId, String)> {
let raw = match (&cli.peer, &cli.url) {
(Some(peer), _) => peer.clone(),
(None, Some(url)) => url
.split_once('#')
.map(|(_, frag)| frag.to_string())
.context("pairing URL has no `#<endpoint_id>:<token>` fragment")?,
(None, None) => bail!("pass either --peer or --url"),
};
let (id, token) = raw
.split_once(':')
.context("pairing must look like <endpoint_id>:<token>")?;
let endpoint_id: EndpointId = id.trim().parse().context("invalid endpoint id")?;
if token.trim().is_empty() {
bail!("pairing token is empty");
}
Ok((endpoint_id, token.trim().to_string()))
}
fn guess_mime(path: &std::path::Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"m4a" | "mp4" => "audio/mp4",
"webm" => "audio/webm",
"ogg" | "opus" => "audio/ogg",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
_ => "application/octet-stream",
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
let (endpoint_id, token) = parse_pairing(&cli)?;
let (header, audio) = if cli.capabilities {
let header = serde_json::to_vec(&Header {
op: "capabilities",
token,
filename: String::new(),
mime: String::new(),
lang: None,
})?;
(header, Vec::new())
} else {
let path = cli.file.as_ref().expect("clap enforces --file");
let audio = tokio::fs::read(path)
.await
.with_context(|| format!("failed to read {}", path.display()))?;
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("recording")
.to_string();
let header = serde_json::to_vec(&Header {
op: "transcribe",
token,
filename,
mime: guess_mime(path).to_string(),
lang: cli.lang.clone(),
})?;
(header, audio)
};
println!("connecting to {endpoint_id}");
let endpoint = Endpoint::bind(presets::N0).await?;
let conn = endpoint.connect(endpoint_id, ALPN).await?;
if cli.capabilities {
println!("connected; asking for capabilities");
} else {
println!("connected; sending {} bytes of audio", audio.len());
}
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(&(header.len() as u32).to_be_bytes()).await?;
send.write_all(&header).await?;
if !audio.is_empty() {
send.write_all(&audio).await?;
}
send.finish()?;
// Read newline-delimited JSON events until the desktop finishes the stream.
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let mut pending = Vec::new();
let mut saw_terminal = false;
loop {
let n = match recv.read(&mut chunk).await? {
Some(0) | None => break,
Some(n) => n,
};
buf.extend_from_slice(&chunk[..n]);
while let Some(nl) = buf.iter().position(|b| *b == b'\n') {
pending = buf.split_off(nl + 1);
let line = String::from_utf8_lossy(&buf[..nl]).to_string();
buf = std::mem::take(&mut pending);
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(event) => {
let kind = event.get("type").and_then(|t| t.as_str()).unwrap_or("?");
if kind == "done" || kind == "error" || kind == "capabilities" {
saw_terminal = true;
}
println!("<< {event}");
}
Err(e) => println!("<< [unparseable: {e}] {line}"),
}
}
}
conn.close(0u32.into(), b"bye");
endpoint.close().await;
if !saw_terminal {
bail!("stream ended without a terminal `done` or `error` event");
}
println!("ok");
Ok(())
}
+1
View File
@@ -0,0 +1 @@
/target
+4188
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "handoff-wasm"
version = "0.1.0"
edition = "2021"
publish = false
# Standalone crate: an empty `[workspace]` table detaches this crate from the
# repo-root workspace so the desktop build never tries to compile it.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
iroh = { version = "1.0.0", default-features = false, features = ["tls-ring"] }
n0-future = "0.3"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
tracing-subscriber-wasm = "0.1"
async-channel = "2"
getrandom = { version = "0.3", features = ["wasm_js"] }
wasm-bindgen = "=0.2.122"
wasm-bindgen-futures = "0.4.50"
console_error_panic_hook = "0.1"
wasm-streams = "0.5"
serde-wasm-bindgen = "0.6"
[profile.release]
opt-level = "s"
lto = true
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Build the browser handoff client and emit JS bindings into ../pwa/public/wasm.
#
# The PWA is a React + Vite app; Vite serves `public/` verbatim, so the module
# lands at /wasm/handoff_wasm.js with its .wasm next to it, which is what
# wasm-bindgen's `--target web` glue expects by default.
#
# Requires:
# rustup target add wasm32-unknown-unknown
# cargo install wasm-bindgen-cli --version 0.2.122 # must match the wasm-bindgen dep
set -euo pipefail
cd "$(dirname "$0")"
OUT_DIR=../pwa/public/wasm
cargo build --target wasm32-unknown-unknown --release
mkdir -p "$OUT_DIR"
wasm-bindgen ./target/wasm32-unknown-unknown/release/handoff_wasm.wasm \
--out-dir "$OUT_DIR" \
--weak-refs \
--target web
# Shrink the module — it ships to phones, often on cellular. Optional: without
# binaryen installed we keep the unoptimized artifact rather than failing.
WASM="$OUT_DIR/handoff_wasm_bg.wasm"
if command -v wasm-opt >/dev/null 2>&1; then
wasm-opt --enable-nontrapping-float-to-int --enable-bulk-memory -Os \
-o "$WASM.opt" "$WASM"
mv "$WASM.opt" "$WASM"
echo "wasm-opt: $WASM is now $(wc -c <"$WASM" | tr -d ' ') bytes"
else
echo "WARNING: wasm-opt not found on PATH; shipping the unoptimized module" >&2
echo " (roughly 2x larger). Install binaryen to shrink it:" >&2
echo " brew install binaryen # or your platform's package manager" >&2
fi
echo "wrote bindings to $OUT_DIR"
+326
View File
@@ -0,0 +1,326 @@
//! Browser-side iroh client for Vibe's phone-handoff feature.
//!
//! The phone (PWA) records audio, sends it to the desktop over an iroh
//! bi-directional stream, and streams transcript events back. It can also ask
//! the desktop what it is capable of (`op: "capabilities"`) — the PWA must
//! never hardcode a language list, since that depends on the loaded model.
//!
//! Browsers cannot hole-punch, so all traffic here is relayed. That is expected.
use anyhow::{Context, Result};
use async_channel::Sender;
use iroh::{
endpoint::{Connection, RecvStream, SendStream},
Endpoint, EndpointId,
};
use n0_future::{task, Stream, StreamExt};
use serde::Serialize;
use serde_json::{json, Value};
use tracing::level_filters::LevelFilter;
use tracing_subscriber_wasm::MakeConsoleWriter;
use wasm_bindgen::{prelude::wasm_bindgen, JsError, JsValue};
use wasm_streams::{readable::sys::ReadableStream as JsReadableStream, ReadableStream};
/// ALPN for the handoff protocol (see CONTRACT.md).
pub const ALPN: &[u8] = b"vibe/handoff/0";
/// Header is capped by the desktop at 8 KiB.
const MAX_HEADER_LEN: usize = 8192;
/// Total audio is capped by the desktop at 512 MiB.
const MAX_AUDIO_LEN: usize = 512 * 1024 * 1024;
/// How much audio we push per `uploadProgress` event.
const UPLOAD_CHUNK: usize = 256 * 1024;
/// Read buffer for response lines.
const READ_CHUNK: usize = 8192;
#[wasm_bindgen(start)]
fn start() {
console_error_panic_hook::set_once();
tracing_subscriber::fmt()
.with_max_level(LevelFilter::DEBUG)
.with_writer(
// Avoid trace events in the browser showing their JS backtrace.
MakeConsoleWriter::default().map_trace_level_to(tracing::Level::DEBUG),
)
// Without this we get a runtime error in the browser.
.without_time()
.with_ansi(false)
.init();
tracing::info!("vibe handoff wasm client loaded");
}
/// The browser-side handoff client. Holds a bound iroh [`Endpoint`].
#[wasm_bindgen]
pub struct HandoffClient {
endpoint: Endpoint,
}
#[wasm_bindgen]
impl HandoffClient {
/// Bind a browser endpoint. Relay-only; that is expected.
pub async fn create() -> Result<HandoffClient, JsError> {
let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)
.bind()
.await
.context("failed to bind endpoint")
.map_err(to_js_err)?;
Ok(HandoffClient { endpoint })
}
/// Our own endpoint id, for debugging display.
pub fn endpoint_id(&self) -> String {
self.endpoint.id().to_string()
}
/// Ask the desktop what it can do.
///
/// Resolves to a single plain JS object, either
/// `{type:"capabilities", modelLoaded, modelName, languages, languageDetection, translation}`
/// or `{type:"error", code, message}`. Failures are returned as `error`
/// objects rather than thrown, so React callers only need one code path.
pub async fn fetch_capabilities(
&self,
endpoint_id: String,
token: String,
) -> Result<JsValue, JsError> {
let value = match capabilities(&self.endpoint, endpoint_id, token).await {
Ok(value) => value,
Err(err) => {
tracing::warn!("capabilities request failed: {err:#}");
json!({
"type": "error",
"code": "transport",
"message": format!("{err:#}"),
})
}
};
to_js(&value).map_err(to_js_err)
}
/// Send one recording and stream back transcript events.
///
/// Returns a `ReadableStream` of JS objects: the newline-delimited JSON
/// events sent by the desktop, plus locally generated
/// `{"type":"uploadProgress","sent":n,"total":n}` events while uploading.
/// Transport failures surface as
/// `{"type":"error","code":"transport","message":"..."}`.
pub fn send_recording(
&self,
endpoint_id: String,
token: String,
filename: String,
mime: String,
lang: Option<String>,
translate: bool,
audio: Vec<u8>,
) -> Result<JsReadableStream, JsError> {
let endpoint_id = parse_endpoint_id(&endpoint_id).map_err(to_js_err)?;
let header = encode_header(&json!({
"op": "transcribe",
"token": token,
"filename": filename,
"mime": mime,
"lang": lang,
"translate": translate,
}))
.map_err(to_js_err)?;
if audio.len() > MAX_AUDIO_LEN {
return Err(JsError::new("recording too large (max 512 MiB)"));
}
let endpoint = self.endpoint.clone();
let (tx, rx) = async_channel::bounded::<Value>(32);
task::spawn(async move {
if let Err(err) = transcribe(&endpoint, endpoint_id, header, audio, &tx).await {
tracing::warn!("handoff transfer failed: {err:#}");
tx.send(json!({
"type": "error",
"code": "transport",
"message": format!("{err:#}"),
}))
.await
.ok();
}
tx.close();
});
Ok(into_js_readable_stream(rx))
}
}
/// One request/response round trip carrying no body.
async fn capabilities(endpoint: &Endpoint, endpoint_id: String, token: String) -> Result<Value> {
let endpoint_id = parse_endpoint_id(&endpoint_id)?;
// filename/mime are meaningless here but sent as empty strings so the
// desktop can deserialize one header struct for every op.
let header = encode_header(&json!({
"op": "capabilities",
"token": token,
"filename": "",
"mime": "",
"lang": null,
}))?;
let conn = connect(endpoint, endpoint_id).await?;
let (mut send, mut recv) = conn.open_bi().await.context("failed to open stream")?;
write_header(&mut send, &header).await?;
send.finish().context("failed to finish send stream")?;
let mut lines = Lines::new(&mut recv);
let value = lines
.next_line()
.await?
.context("desktop closed the stream without answering")?;
conn.close(0u32.into(), b"bye");
Ok(value)
}
/// Upload one recording, then stream response events into `tx`.
async fn transcribe(
endpoint: &Endpoint,
endpoint_id: EndpointId,
header: Vec<u8>,
audio: Vec<u8>,
tx: &Sender<Value>,
) -> Result<()> {
let conn = connect(endpoint, endpoint_id).await?;
let (mut send, mut recv) = conn.open_bi().await.context("failed to open stream")?;
write_header(&mut send, &header).await?;
// Raw audio bytes, chunked so we can report upload progress.
let total = audio.len();
let mut sent = 0usize;
tx.send(json!({ "type": "uploadProgress", "sent": 0, "total": total }))
.await
.ok();
while sent < total {
let end = (sent + UPLOAD_CHUNK).min(total);
send.write_all(&audio[sent..end])
.await
.context("failed to write audio")?;
sent = end;
tx.send(json!({ "type": "uploadProgress", "sent": sent, "total": total }))
.await
.ok();
}
send.finish().context("failed to finish send stream")?;
// Newline-delimited JSON responses until stream end.
let mut lines = Lines::new(&mut recv);
while let Some(value) = lines.next_line().await? {
tx.send(value).await.ok();
}
conn.close(0u32.into(), b"bye");
Ok(())
}
async fn connect(endpoint: &Endpoint, endpoint_id: EndpointId) -> Result<Connection> {
endpoint
.connect(endpoint_id, ALPN)
.await
.context("failed to connect to desktop")
}
/// u32-BE header length, then the JSON header itself.
async fn write_header(send: &mut SendStream, header: &[u8]) -> Result<()> {
send.write_all(&(header.len() as u32).to_be_bytes())
.await
.context("failed to write header length")?;
send.write_all(header)
.await
.context("failed to write header")
}
fn encode_header(header: &Value) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(header).context("failed to encode header")?;
anyhow::ensure!(bytes.len() <= MAX_HEADER_LEN, "handoff header too large");
Ok(bytes)
}
fn parse_endpoint_id(raw: &str) -> Result<EndpointId> {
raw.trim().parse().context("failed to parse endpoint id")
}
/// Reads newline-delimited JSON off a [`RecvStream`], one value at a time.
///
/// Malformed lines are logged and skipped rather than killing the transfer.
struct Lines<'a> {
recv: &'a mut RecvStream,
buf: Vec<u8>,
eof: bool,
}
impl<'a> Lines<'a> {
fn new(recv: &'a mut RecvStream) -> Self {
Self {
recv,
buf: Vec::new(),
eof: false,
}
}
/// Next parsed JSON value, or `None` once the stream is exhausted.
async fn next_line(&mut self) -> Result<Option<Value>> {
let mut chunk = [0u8; READ_CHUNK];
loop {
if let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = self.buf.drain(..=pos).collect();
match parse_line(&line[..line.len() - 1]) {
Some(value) => return Ok(Some(value)),
None => continue,
}
}
if self.eof {
// Tolerate a final line with no trailing newline.
let rest = std::mem::take(&mut self.buf);
return Ok(parse_line(&rest));
}
match self.recv.read(&mut chunk).await.context("failed to read")? {
Some(0) | None => self.eof = true,
Some(n) => self.buf.extend_from_slice(&chunk[..n]),
}
}
}
}
fn parse_line(line: &[u8]) -> Option<Value> {
let line = std::str::from_utf8(line).unwrap_or("").trim();
if line.is_empty() {
return None;
}
match serde_json::from_str::<Value>(line) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!("ignoring malformed event line: {err}");
None
}
}
}
fn to_js_err(err: impl Into<anyhow::Error>) -> JsError {
let err: anyhow::Error = err.into();
JsError::new(&format!("{err:#}"))
}
/// `json_compatible` maps serde maps to plain JS objects instead of `Map`s.
fn serializer() -> serde_wasm_bindgen::Serializer {
serde_wasm_bindgen::Serializer::json_compatible()
}
fn to_js<T: Serialize>(value: &T) -> Result<JsValue> {
value
.serialize(&serializer())
.map_err(|err| anyhow::anyhow!("failed to convert to JS value: {err}"))
}
fn into_js_readable_stream<T: Serialize>(
stream: impl Stream<Item = T> + 'static,
) -> JsReadableStream {
let stream = stream.map(|event| Ok(to_js(&event).unwrap()));
ReadableStream::from_stream(stream).into_raw()
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Затварянето на прозореца оставя Vibe да работи в системния трей, така че глобалната диктовка продължава да работи. Затворете го от менюто на трея.",
"trayShow": "Отваряне на Vibe",
"trayHide": "Скриване на Vibe",
"trayQuit": "Изход от Vibe"
"trayQuit": "Изход от Vibe",
"launchAtStartup": "Стартиране при влизане в системата",
"launchAtStartupInfo": "Отваря Vibe автоматично, когато влезете в компютъра си.",
"couldNotEnableLaunchAtStartup": "Стартирането при влизане не можа да бъде включено: {error}",
"couldNotDisableLaunchAtStartup": "Стартирането при влизане не можа да бъде изключено: {error}",
"phone": "Телефон",
"phoneHandoff": "Прехвърляне от телефон",
"phoneHandoffInfo": "Записвайте на телефона си и оставете този компютър да направи транскрибирането — готовият текст се връща право на телефона.",
"phoneHandoffToggleInfo": "Сдвоете телефона веднъж и след това записвайте от него по всяко време.",
"phoneHandoffRelayNote": "Звукът минава през препращащ сървър, защото браузърите на телефоните не могат да се свържат директно, но е шифрован от край до край — сървърът вижда само шифрован текст. Vibe трябва да остане отворен на този компютър, за да може телефонът да го достигне.",
"phoneHandoffUnavailable": "Прехвърлянето от телефон не е налично в тази версия на Vibe.",
"pairAPhone": "Сдвояване на телефон",
"pairingQrCode": "QR код за сдвояване",
"pairingQrCodeError": "Кодът за сдвояване не можа да бъде показан.",
"scanPairingCodeInfo": "Насочете камерата на телефона си към този код и запазете страницата, която се отваря.",
"pairingLink": "Връзка за сдвояване",
"copyPairingLink": "Копиране на връзката за сдвояване",
"noPairingCode": "Все още няма код за сдвояване.",
"thisComputer": "Този компютър",
"endpointIdInfo": "ID на крайната точка, към която се свързва телефонът",
"status": "Състояние",
"failed": "Неуспешно",
"phoneWaitingForRecording": "Изчакване телефон да изпрати запис.",
"phoneReceivingAudio": "Получаване на звук от телефона...",
"phoneLoadingModel": "Зареждане на модела за транскрибиране...",
"phoneTranscribing": "Транскрибиране на записа...",
"phoneTranscriptSentBack": "Готово — транскрипцията се върна на телефона ви.",
"phoneRecordingFailed": "Последният запис беше неуспешен.",
"regeneratePairingCode": "Генериране на нов код за сдвояване",
"regeneratePairingCodeInfo": "Издава нов код и прави стария невалиден — телефон, който вече е сдвоен, трябва да сканира отново.",
"showInFinder": "Покажи във Finder",
"showInFileExplorer": "Покажи във Файлов мениджър",
"phoneTranscriptionSaved": "Транскрипцията от телефона е запазена",
"phoneRecording": "Запис от телефона"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "En tancar la finestra, Vibe continua executant-se a la safata del sistema, de manera que el dictat global segueix funcionant. Pots sortir-ne des del menú de la safata.",
"trayShow": "Obre Vibe",
"trayHide": "Amaga Vibe",
"trayQuit": "Surt de Vibe"
"trayQuit": "Surt de Vibe",
"launchAtStartup": "Obrir en iniciar la sessió",
"launchAtStartupInfo": "Obre Vibe automàticament quan inicies la sessió a l'ordinador.",
"couldNotEnableLaunchAtStartup": "No s'ha pogut activar l'obertura en iniciar la sessió: {error}",
"couldNotDisableLaunchAtStartup": "No s'ha pogut desactivar l'obertura en iniciar la sessió: {error}",
"phone": "Telèfon",
"phoneHandoff": "Gravació des del telèfon",
"phoneHandoffInfo": "Grava amb el telèfon i deixa que aquest ordinador faci la transcripció — el text torna directament al telèfon.",
"phoneHandoffToggleInfo": "Vincula un telèfon un sol cop i, a partir d'aleshores, hi pots gravar quan vulguis.",
"phoneHandoffRelayNote": "L'àudio passa per un servidor de retransmissió perquè els navegadors dels telèfons no s'hi poden connectar directament, però està xifrat d'extrem a extrem — el servidor només veu text xifrat. Vibe ha de romandre obert en aquest ordinador perquè el telèfon hi pugui arribar.",
"phoneHandoffUnavailable": "La gravació des del telèfon no està disponible en aquesta compilació de Vibe.",
"pairAPhone": "Vincular un telèfon",
"pairingQrCode": "Codi QR de vinculació",
"pairingQrCodeError": "No s'ha pogut mostrar el codi de vinculació.",
"scanPairingCodeInfo": "Enfoca aquest codi amb la càmera del telèfon i deixa oberta la pàgina que s'obri.",
"pairingLink": "Enllaç de vinculació",
"copyPairingLink": "Copiar l'enllaç de vinculació",
"noPairingCode": "Encara no hi ha cap codi de vinculació.",
"thisComputer": "Aquest ordinador",
"endpointIdInfo": "Identificador del punt final al qual es connecta el telèfon",
"status": "Estat",
"failed": "Ha fallat",
"phoneWaitingForRecording": "S'espera que un telèfon enviï una gravació.",
"phoneReceivingAudio": "S'està rebent àudio del telèfon…",
"phoneLoadingModel": "S'està carregant el model de transcripció…",
"phoneTranscribing": "S'està transcrivint la gravació…",
"phoneTranscriptSentBack": "Fet — la transcripció ha tornat al telèfon.",
"phoneRecordingFailed": "L'última gravació ha fallat.",
"regeneratePairingCode": "Regenerar el codi de vinculació",
"regeneratePairingCodeInfo": "Emet un codi nou i invalida l'antic — un telèfon ja vinculat haurà de tornar a escanejar.",
"showInFinder": "Mostrar al Finder",
"showInFileExplorer": "Mostrar a l'Explorador de fitxers",
"phoneTranscriptionSaved": "S'ha desat la transcripció del telèfon",
"phoneRecording": "Gravació del telèfon"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Zavření okna ponechá Vibe běžet v oznamovací oblasti, takže globální diktování dál funguje. Ukončit ho můžete z nabídky ikony.",
"trayShow": "Otevřít Vibe",
"trayHide": "Skrýt Vibe",
"trayQuit": "Ukončit Vibe"
"trayQuit": "Ukončit Vibe",
"launchAtStartup": "Spouštět po přihlášení",
"launchAtStartupInfo": "Otevře Vibe automaticky, když se přihlásíte k počítači.",
"couldNotEnableLaunchAtStartup": "Nepodařilo se zapnout spouštění po přihlášení: {error}",
"couldNotDisableLaunchAtStartup": "Nepodařilo se vypnout spouštění po přihlášení: {error}",
"phone": "Telefon",
"phoneHandoff": "Předání z telefonu",
"phoneHandoffInfo": "Nahrávejte na telefonu a přepis nechte na tomto počítači — hotový text se vrátí rovnou do telefonu.",
"phoneHandoffToggleInfo": "Telefon stačí spárovat jednou, pak z něj můžete nahrávat kdykoli.",
"phoneHandoffRelayNote": "Zvuk putuje přes předávací server, protože prohlížeče v telefonech se nedokážou připojit přímo, je ale end-to-end šifrovaný — server vidí jen zašifrovaná data. Vibe musí na tomto počítači zůstat spuštěný, aby se k němu telefon dostal.",
"phoneHandoffUnavailable": "Předání z telefonu není v této verzi Vibe k dispozici.",
"pairAPhone": "Spárovat telefon",
"pairingQrCode": "QR kód pro párování",
"pairingQrCodeError": "Párovací kód se nepodařilo vykreslit.",
"scanPairingCodeInfo": "Namiřte na tento kód fotoaparát telefonu a stránku, která se otevře, si ponechte.",
"pairingLink": "Odkaz pro párování",
"copyPairingLink": "Kopírovat odkaz pro párování",
"noPairingCode": "Zatím žádný párovací kód.",
"thisComputer": "Tento počítač",
"endpointIdInfo": "ID koncového bodu, ke kterému se telefon připojuje",
"status": "Stav",
"failed": "Selhalo",
"phoneWaitingForRecording": "Čekání na nahrávku z telefonu.",
"phoneReceivingAudio": "Příjem zvuku z telefonu...",
"phoneLoadingModel": "Načítání modelu pro přepis...",
"phoneTranscribing": "Přepisování nahrávky...",
"phoneTranscriptSentBack": "Hotovo — přepis se vrátil do telefonu.",
"phoneRecordingFailed": "Poslední nahrávka selhala.",
"regeneratePairingCode": "Vygenerovat nový párovací kód",
"regeneratePairingCodeInfo": "Vydá nový kód a ten starý zneplatní — telefon, který už je spárovaný, musí kód naskenovat znovu.",
"showInFinder": "Zobrazit ve Finderu",
"showInFileExplorer": "Zobrazit v Průzkumníku souborů",
"phoneTranscriptionSaved": "Přepis z telefonu byl uložen",
"phoneRecording": "Nahrávka z telefonu"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Beim Schließen des Fensters läuft Vibe im Infobereich weiter, sodass das globale Diktat weiterhin funktioniert. Beenden können Sie es über das Menü im Infobereich.",
"trayShow": "Vibe öffnen",
"trayHide": "Vibe ausblenden",
"trayQuit": "Vibe beenden"
"trayQuit": "Vibe beenden",
"launchAtStartup": "Beim Anmelden starten",
"launchAtStartupInfo": "Vibe automatisch öffnen, wenn Sie sich an Ihrem Computer anmelden.",
"couldNotEnableLaunchAtStartup": "Start beim Anmelden konnte nicht aktiviert werden: {error}",
"couldNotDisableLaunchAtStartup": "Start beim Anmelden konnte nicht deaktiviert werden: {error}",
"phone": "Telefon",
"phoneHandoff": "Übergabe vom Telefon",
"phoneHandoffInfo": "Nehmen Sie auf Ihrem Telefon auf und lassen Sie diesen Computer transkribieren das Transkript kommt direkt auf das Telefon zurück.",
"phoneHandoffToggleInfo": "Koppeln Sie ein Telefon einmal, danach können Sie jederzeit darüber aufnehmen.",
"phoneHandoffRelayNote": "Das Audio läuft über einen Relay-Server, weil Browser auf dem Telefon sich nicht direkt verbinden können, ist dabei aber Ende-zu-Ende-verschlüsselt der Relay sieht nur verschlüsselte Daten. Vibe muss auf diesem Computer geöffnet bleiben, damit das Telefon es erreichen kann.",
"phoneHandoffUnavailable": "Die Übergabe vom Telefon ist in dieser Vibe-Version nicht verfügbar.",
"pairAPhone": "Telefon koppeln",
"pairingQrCode": "QR-Code zum Koppeln",
"pairingQrCodeError": "Der Kopplungscode konnte nicht angezeigt werden.",
"scanPairingCodeInfo": "Richten Sie die Kamera Ihres Telefons auf diesen Code und behalten Sie die Seite, die sich öffnet.",
"pairingLink": "Kopplungslink",
"copyPairingLink": "Kopplungslink kopieren",
"noPairingCode": "Noch kein Kopplungscode vorhanden.",
"thisComputer": "Dieser Computer",
"endpointIdInfo": "Endpunkt-ID, mit der sich Ihr Telefon verbindet",
"status": "Status",
"failed": "Fehlgeschlagen",
"phoneWaitingForRecording": "Warten auf eine Aufnahme von einem Telefon.",
"phoneReceivingAudio": "Audio wird vom Telefon empfangen…",
"phoneLoadingModel": "Transkriptionsmodell wird geladen…",
"phoneTranscribing": "Aufnahme wird transkribiert…",
"phoneTranscriptSentBack": "Fertig das Transkript ist zurück auf Ihrem Telefon.",
"phoneRecordingFailed": "Die letzte Aufnahme ist fehlgeschlagen.",
"regeneratePairingCode": "Kopplungscode neu erzeugen",
"regeneratePairingCodeInfo": "Erzeugt einen neuen Code und macht den alten ungültig ein bereits gekoppeltes Telefon muss erneut scannen.",
"showInFinder": "Im Finder anzeigen",
"showInFileExplorer": "Im Datei-Explorer anzeigen",
"phoneTranscriptionSaved": "Telefon-Transkription gespeichert",
"phoneRecording": "Telefonaufnahme"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Closing the window leaves Vibe running in the tray, so global dictation keeps working. Quit it from the tray menu.",
"trayShow": "Open Vibe",
"trayHide": "Hide Vibe",
"trayQuit": "Quit Vibe"
"trayQuit": "Quit Vibe",
"launchAtStartup": "Launch at startup",
"launchAtStartupInfo": "Open Vibe automatically when you log in to your computer.",
"couldNotEnableLaunchAtStartup": "Could not enable launch at startup: {error}",
"couldNotDisableLaunchAtStartup": "Could not disable launch at startup: {error}",
"phone": "Phone",
"phoneHandoff": "Phone handoff",
"phoneHandoffInfo": "Record on your phone and let this computer do the transcribing — the transcript comes straight back to the phone.",
"phoneHandoffToggleInfo": "Pair a phone once, then record from it any time.",
"phoneHandoffRelayNote": "Audio travels through a relay server because phone browsers cannot connect directly, but it is end-to-end encrypted — the relay only sees ciphertext. Vibe has to stay open on this computer for the phone to reach it.",
"phoneHandoffUnavailable": "Phone handoff is not available in this build of Vibe.",
"pairAPhone": "Pair a phone",
"pairingQrCode": "Pairing QR code",
"pairingQrCodeError": "Could not render the pairing code.",
"scanPairingCodeInfo": "Point your phones camera at this code, then keep the page it opens.",
"pairingLink": "Pairing link",
"copyPairingLink": "Copy pairing link",
"noPairingCode": "No pairing code yet.",
"thisComputer": "This computer",
"endpointIdInfo": "Endpoint id your phone connects to",
"status": "Status",
"failed": "Failed",
"phoneWaitingForRecording": "Waiting for a phone to send a recording.",
"phoneReceivingAudio": "Receiving audio from your phone…",
"phoneLoadingModel": "Loading the transcription model…",
"phoneTranscribing": "Transcribing the recording…",
"phoneTranscriptSentBack": "Done — the transcript went back to your phone.",
"phoneRecordingFailed": "The last recording failed.",
"regeneratePairingCode": "Regenerate pairing code",
"regeneratePairingCodeInfo": "Issues a new code and invalidates the old one — a phone that is already paired has to scan again.",
"showInFinder": "Show in Finder",
"showInFileExplorer": "Show in File Explorer",
"phoneTranscriptionSaved": "Phone transcription saved",
"phoneRecording": "Phone recording"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Al cerrar la ventana, Vibe sigue ejecutándose en la bandeja del sistema, así que el dictado global continúa funcionando. Puedes salir desde el menú de la bandeja.",
"trayShow": "Abrir Vibe",
"trayHide": "Ocultar Vibe",
"trayQuit": "Salir de Vibe"
"trayQuit": "Salir de Vibe",
"launchAtStartup": "Abrir al iniciar sesión",
"launchAtStartupInfo": "Abre Vibe automáticamente cuando inicias sesión en el ordenador.",
"couldNotEnableLaunchAtStartup": "No se ha podido activar la apertura al iniciar sesión: {error}",
"couldNotDisableLaunchAtStartup": "No se ha podido desactivar la apertura al iniciar sesión: {error}",
"phone": "Móvil",
"phoneHandoff": "Transferencia desde el móvil",
"phoneHandoffInfo": "Graba en el móvil y deja que este ordenador haga la transcripción: el texto vuelve directamente al móvil.",
"phoneHandoffToggleInfo": "Vincula un móvil una sola vez y graba desde él siempre que quieras.",
"phoneHandoffRelayNote": "El audio pasa por un servidor de retransmisión, porque los navegadores del móvil no pueden conectarse directamente, pero va cifrado de extremo a extremo: el servidor solo ve datos cifrados. Vibe tiene que seguir abierto en este ordenador para que el móvil pueda llegar hasta él.",
"phoneHandoffUnavailable": "La transferencia desde el móvil no está disponible en esta versión de Vibe.",
"pairAPhone": "Vincular un móvil",
"pairingQrCode": "Código QR de vinculación",
"pairingQrCodeError": "No se ha podido mostrar el código de vinculación.",
"scanPairingCodeInfo": "Apunta con la cámara del móvil a este código y guarda la página que se abra.",
"pairingLink": "Enlace de vinculación",
"copyPairingLink": "Copiar enlace de vinculación",
"noPairingCode": "Todavía no hay ningún código de vinculación.",
"thisComputer": "Este ordenador",
"endpointIdInfo": "Identificador del punto de conexión al que se conecta tu móvil",
"status": "Estado",
"failed": "Error",
"phoneWaitingForRecording": "Esperando a que un móvil envíe una grabación.",
"phoneReceivingAudio": "Recibiendo audio del móvil…",
"phoneLoadingModel": "Cargando el modelo de transcripción…",
"phoneTranscribing": "Transcribiendo la grabación…",
"phoneTranscriptSentBack": "Listo: la transcripción ya está de vuelta en el móvil.",
"phoneRecordingFailed": "La última grabación ha fallado.",
"regeneratePairingCode": "Regenerar el código de vinculación",
"regeneratePairingCodeInfo": "Genera un código nuevo e invalida el anterior: un móvil que ya esté vinculado tendrá que escanearlo otra vez.",
"showInFinder": "Mostrar en el Finder",
"showInFileExplorer": "Mostrar en el Explorador de archivos",
"phoneTranscriptionSaved": "Transcripción del móvil guardada",
"phoneRecording": "Grabación del móvil"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Al cerrar la ventana, Vibe sigue ejecutándose en la bandeja del sistema, así que el dictado global continúa funcionando. Puedes salir desde el menú de la bandeja.",
"trayShow": "Abrir Vibe",
"trayHide": "Ocultar Vibe",
"trayQuit": "Salir de Vibe"
"trayQuit": "Salir de Vibe",
"launchAtStartup": "Abrir al iniciar sesión",
"launchAtStartupInfo": "Abre Vibe automáticamente cuando inicias sesión en la computadora.",
"couldNotEnableLaunchAtStartup": "No se pudo activar la apertura al iniciar sesión: {error}",
"couldNotDisableLaunchAtStartup": "No se pudo desactivar la apertura al iniciar sesión: {error}",
"phone": "Celular",
"phoneHandoff": "Transferencia desde el celular",
"phoneHandoffInfo": "Graba en tu celular y deja que esta computadora haga la transcripción: el texto regresa directo al celular.",
"phoneHandoffToggleInfo": "Vincula un celular una sola vez y graba desde él cuando quieras.",
"phoneHandoffRelayNote": "El audio pasa por un servidor de retransmisión, porque los navegadores del celular no se pueden conectar directamente, pero va cifrado de extremo a extremo: el servidor solo ve datos cifrados. Vibe tiene que seguir abierto en esta computadora para que el celular pueda alcanzarlo.",
"phoneHandoffUnavailable": "La transferencia desde el celular no está disponible en esta versión de Vibe.",
"pairAPhone": "Vincular un celular",
"pairingQrCode": "Código QR de vinculación",
"pairingQrCodeError": "No se pudo mostrar el código de vinculación.",
"scanPairingCodeInfo": "Apunta la cámara de tu celular a este código y conserva la página que se abra.",
"pairingLink": "Enlace de vinculación",
"copyPairingLink": "Copiar enlace de vinculación",
"noPairingCode": "Todavía no hay ningún código de vinculación.",
"thisComputer": "Esta computadora",
"endpointIdInfo": "Identificador del punto de conexión al que se conecta tu celular",
"status": "Estado",
"failed": "Falló",
"phoneWaitingForRecording": "Esperando a que un celular envíe una grabación.",
"phoneReceivingAudio": "Recibiendo audio de tu celular…",
"phoneLoadingModel": "Cargando el modelo de transcripción…",
"phoneTranscribing": "Transcribiendo la grabación…",
"phoneTranscriptSentBack": "Listo: la transcripción ya regresó a tu celular.",
"phoneRecordingFailed": "La última grabación falló.",
"regeneratePairingCode": "Regenerar el código de vinculación",
"regeneratePairingCodeInfo": "Genera un código nuevo e invalida el anterior: un celular que ya esté vinculado tendrá que escanearlo de nuevo.",
"showInFinder": "Mostrar en el Finder",
"showInFileExplorer": "Mostrar en el Explorador de archivos",
"phoneTranscriptionSaved": "Transcripción del celular guardada",
"phoneRecording": "Grabación del celular"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Fermer la fenêtre laisse Vibe s'exécuter dans la zone de notification, la dictée globale continue donc de fonctionner. Quittez-le depuis le menu de l'icône.",
"trayShow": "Ouvrir Vibe",
"trayHide": "Masquer Vibe",
"trayQuit": "Quitter Vibe"
"trayQuit": "Quitter Vibe",
"launchAtStartup": "Lancer au démarrage",
"launchAtStartupInfo": "Ouvre Vibe automatiquement lorsque vous ouvrez une session sur votre ordinateur.",
"couldNotEnableLaunchAtStartup": "Impossible d'activer le lancement au démarrage : {error}",
"couldNotDisableLaunchAtStartup": "Impossible de désactiver le lancement au démarrage : {error}",
"phone": "Téléphone",
"phoneHandoff": "Transfert depuis le téléphone",
"phoneHandoffInfo": "Enregistrez depuis votre téléphone et laissez cet ordinateur faire la transcription : le texte revient directement sur le téléphone.",
"phoneHandoffToggleInfo": "Associez un téléphone une seule fois, puis enregistrez depuis celui-ci quand vous voulez.",
"phoneHandoffRelayNote": "L'audio passe par un serveur relais, car les navigateurs des téléphones ne peuvent pas se connecter directement, mais il est chiffré de bout en bout : le relais ne voit que des données chiffrées. Vibe doit rester ouvert sur cet ordinateur pour que le téléphone puisse le joindre.",
"phoneHandoffUnavailable": "Le transfert depuis le téléphone n'est pas disponible dans cette version de Vibe.",
"pairAPhone": "Associer un téléphone",
"pairingQrCode": "Code QR d'association",
"pairingQrCodeError": "Impossible d'afficher le code d'association.",
"scanPairingCodeInfo": "Dirigez la caméra de votre téléphone vers ce code, puis gardez la page qui s'ouvre.",
"pairingLink": "Lien d'association",
"copyPairingLink": "Copier le lien d'association",
"noPairingCode": "Aucun code d'association pour le moment.",
"thisComputer": "Cet ordinateur",
"endpointIdInfo": "Identifiant du point de terminaison auquel votre téléphone se connecte",
"status": "État",
"failed": "Échec",
"phoneWaitingForRecording": "En attente d'un enregistrement envoyé par un téléphone.",
"phoneReceivingAudio": "Réception de l'audio depuis votre téléphone…",
"phoneLoadingModel": "Chargement du modèle de transcription…",
"phoneTranscribing": "Transcription de l'enregistrement…",
"phoneTranscriptSentBack": "Terminé : la transcription est repartie vers votre téléphone.",
"phoneRecordingFailed": "Le dernier enregistrement a échoué.",
"regeneratePairingCode": "Régénérer le code d'association",
"regeneratePairingCodeInfo": "Génère un nouveau code et invalide l'ancien : un téléphone déjà associé devra scanner de nouveau.",
"showInFinder": "Afficher dans le Finder",
"showInFileExplorer": "Afficher dans l'Explorateur de fichiers",
"phoneTranscriptionSaved": "Transcription du téléphone enregistrée",
"phoneRecording": "Enregistrement du téléphone"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "סגירת החלון משאירה את Vibe פועל במגש המערכת, כך שההכתבה הגלובלית ממשיכה לעבוד. אפשר לצאת מתפריט המגש.",
"trayShow": "פתיחת Vibe",
"trayHide": "הסתרת Vibe",
"trayQuit": "יציאה מ-Vibe"
"trayQuit": "יציאה מ-Vibe",
"launchAtStartup": "הפעלה אוטומטית בהתחברות",
"launchAtStartupInfo": "Vibe ייפתח אוטומטית בכל כניסה למחשב.",
"couldNotEnableLaunchAtStartup": "לא ניתן להפעיל את ההפעלה האוטומטית בהתחברות: {error}",
"couldNotDisableLaunchAtStartup": "לא ניתן לבטל את ההפעלה האוטומטית בהתחברות: {error}",
"phone": "טלפון",
"phoneHandoff": "תמלול מהטלפון",
"phoneHandoffInfo": "הקליטו בטלפון ותנו למחשב הזה לתמלל — התמלול חוזר ישירות לטלפון.",
"phoneHandoffToggleInfo": "מספיק לחבר טלפון פעם אחת, ואז אפשר להקליט ממנו מתי שרוצים.",
"phoneHandoffRelayNote": "האודיו עובר דרך שרת ממסר מפני שדפדפנים בטלפון לא יכולים להתחבר ישירות, אבל הוא מוצפן מקצה לקצה — הממסר רואה רק טקסט מוצפן. Vibe צריך להישאר פתוח במחשב הזה כדי שהטלפון יוכל להגיע אליו.",
"phoneHandoffUnavailable": "תמלול מהטלפון אינו זמין בגרסה הזו של Vibe.",
"pairAPhone": "חיבור טלפון",
"pairingQrCode": "קוד QR לחיבור",
"pairingQrCodeError": "לא ניתן להציג את קוד החיבור.",
"scanPairingCodeInfo": "כוונו את מצלמת הטלפון אל הקוד הזה, והשאירו פתוח את הדף שנפתח.",
"pairingLink": "קישור לחיבור",
"copyPairingLink": "העתק קישור חיבור",
"noPairingCode": "עדיין אין קוד חיבור.",
"thisComputer": "המחשב הזה",
"endpointIdInfo": "מזהה נקודת הקצה שהטלפון מתחבר אליה",
"status": "מצב",
"failed": "נכשל",
"phoneWaitingForRecording": "ממתין להקלטה מהטלפון.",
"phoneReceivingAudio": "מתקבל אודיו מהטלפון…",
"phoneLoadingModel": "טוען את מודל התמלול…",
"phoneTranscribing": "מתמלל את ההקלטה…",
"phoneTranscriptSentBack": "הסתיים — התמלול נשלח חזרה לטלפון.",
"phoneRecordingFailed": "ההקלטה האחרונה נכשלה.",
"regeneratePairingCode": "יצירת קוד חיבור חדש",
"regeneratePairingCodeInfo": "מנפיק קוד חדש ומבטל את הישן — טלפון שכבר מחובר יצטרך לסרוק שוב.",
"showInFinder": "הצג ב-Finder",
"showInFileExplorer": "הצג בסייר הקבצים",
"phoneTranscriptionSaved": "התמלול מהטלפון נשמר",
"phoneRecording": "הקלטה מהטלפון"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "विंडो बंद करने पर Vibe सिस्टम ट्रे में चलता रहता है, जिससे ग्लोबल डिक्टेशन काम करता रहता है। इसे ट्रे मेन्यू से बंद करें।",
"trayShow": "Vibe खोलें",
"trayHide": "Vibe छिपाएँ",
"trayQuit": "Vibe बंद करें"
"trayQuit": "Vibe बंद करें",
"launchAtStartup": "स्टार्टअप पर शुरू करें",
"launchAtStartupInfo": "कंप्यूटर में लॉग इन करते ही Vibe अपने आप खुल जाता है।",
"couldNotEnableLaunchAtStartup": "स्टार्टअप पर शुरू होना चालू नहीं किया जा सका: {error}",
"couldNotDisableLaunchAtStartup": "स्टार्टअप पर शुरू होना बंद नहीं किया जा सका: {error}",
"phone": "फ़ोन",
"phoneHandoff": "फ़ोन से रिकॉर्डिंग",
"phoneHandoffInfo": "अपने फ़ोन पर रिकॉर्ड करें और ट्रांसक्राइब करने का काम इस कंप्यूटर पर होने दें — ट्रांसक्रिप्ट सीधे फ़ोन पर वापस पहुँच जाता है।",
"phoneHandoffToggleInfo": "फ़ोन को एक बार पेयर करें, फिर जब चाहें उससे रिकॉर्ड करें।",
"phoneHandoffRelayNote": "फ़ोन के ब्राउज़र सीधे कनेक्ट नहीं कर पाते, इसलिए ऑडियो एक रिले सर्वर से होकर जाता है, लेकिन वह एंड-टू-एंड एन्क्रिप्टेड रहता है — रिले को सिर्फ़ एन्क्रिप्टेड डेटा दिखता है। फ़ोन इस कंप्यूटर तक पहुँच सके, इसके लिए Vibe यहाँ खुला रहना चाहिए।",
"phoneHandoffUnavailable": "Vibe के इस बिल्ड में फ़ोन से रिकॉर्डिंग उपलब्ध नहीं है।",
"pairAPhone": "फ़ोन पेयर करें",
"pairingQrCode": "पेयरिंग QR कोड",
"pairingQrCodeError": "पेयरिंग कोड नहीं दिखाया जा सका।",
"scanPairingCodeInfo": "अपने फ़ोन का कैमरा इस कोड पर ले जाएँ, फिर जो पेज खुले उसे खुला रहने दें।",
"pairingLink": "पेयरिंग लिंक",
"copyPairingLink": "पेयरिंग लिंक कॉपी करें",
"noPairingCode": "अभी कोई पेयरिंग कोड नहीं है।",
"thisComputer": "यह कंप्यूटर",
"endpointIdInfo": "एंडपॉइंट आईडी, जिससे आपका फ़ोन कनेक्ट होता है",
"status": "स्थिति",
"failed": "विफल",
"phoneWaitingForRecording": "किसी फ़ोन से रिकॉर्डिंग आने का इंतज़ार है।",
"phoneReceivingAudio": "फ़ोन से ऑडियो मिल रहा है…",
"phoneLoadingModel": "ट्रांसक्रिप्शन मॉडल लोड हो रहा है…",
"phoneTranscribing": "रिकॉर्डिंग ट्रांसक्राइब हो रही है…",
"phoneTranscriptSentBack": "हो गया — ट्रांसक्रिप्ट आपके फ़ोन पर वापस भेज दिया गया।",
"phoneRecordingFailed": "पिछली रिकॉर्डिंग विफल रही।",
"regeneratePairingCode": "नया पेयरिंग कोड बनाएँ",
"regeneratePairingCodeInfo": "नया कोड जारी होता है और पुराना अमान्य हो जाता है — पहले से पेयर किए गए फ़ोन को दोबारा स्कैन करना होगा।",
"showInFinder": "Finder में दिखाएँ",
"showInFileExplorer": "फ़ाइल एक्सप्लोरर में दिखाएँ",
"phoneTranscriptionSaved": "फ़ोन का ट्रांसक्रिप्शन सहेजा गया",
"phoneRecording": "फ़ोन रिकॉर्डिंग"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Chiudendo la finestra, Vibe resta in esecuzione nell'area di notifica, così la dettatura globale continua a funzionare. Puoi uscire dal menu dell'icona.",
"trayShow": "Apri Vibe",
"trayHide": "Nascondi Vibe",
"trayQuit": "Esci da Vibe"
"trayQuit": "Esci da Vibe",
"launchAtStartup": "Avvia all'accesso",
"launchAtStartupInfo": "Apre Vibe automaticamente quando accedi al computer.",
"couldNotEnableLaunchAtStartup": "Impossibile attivare l'avvio all'accesso: {error}",
"couldNotDisableLaunchAtStartup": "Impossibile disattivare l'avvio all'accesso: {error}",
"phone": "Telefono",
"phoneHandoff": "Trasferimento dal telefono",
"phoneHandoffInfo": "Registra dal telefono e lascia che sia questo computer a trascrivere: la trascrizione torna direttamente sul telefono.",
"phoneHandoffToggleInfo": "Associa un telefono una sola volta, poi registra da lì quando vuoi.",
"phoneHandoffRelayNote": "L'audio passa attraverso un server relay, perché i browser dei telefoni non possono connettersi direttamente, ma è cifrato end-to-end: il relay vede solo dati cifrati. Vibe deve restare aperto su questo computer perché il telefono possa raggiungerlo.",
"phoneHandoffUnavailable": "Il trasferimento dal telefono non è disponibile in questa versione di Vibe.",
"pairAPhone": "Associa un telefono",
"pairingQrCode": "Codice QR di associazione",
"pairingQrCodeError": "Impossibile mostrare il codice di associazione.",
"scanPairingCodeInfo": "Inquadra questo codice con la fotocamera del telefono, poi conserva la pagina che si apre.",
"pairingLink": "Link di associazione",
"copyPairingLink": "Copia il link di associazione",
"noPairingCode": "Nessun codice di associazione per ora.",
"thisComputer": "Questo computer",
"endpointIdInfo": "ID dell'endpoint a cui si connette il telefono",
"status": "Stato",
"failed": "Non riuscito",
"phoneWaitingForRecording": "In attesa di una registrazione da un telefono.",
"phoneReceivingAudio": "Ricezione dell'audio dal telefono…",
"phoneLoadingModel": "Caricamento del modello di trascrizione…",
"phoneTranscribing": "Trascrizione della registrazione…",
"phoneTranscriptSentBack": "Fatto: la trascrizione è tornata sul telefono.",
"phoneRecordingFailed": "L'ultima registrazione non è riuscita.",
"regeneratePairingCode": "Rigenera il codice di associazione",
"regeneratePairingCodeInfo": "Genera un nuovo codice e invalida quello vecchio: un telefono già associato dovrà scansionarlo di nuovo.",
"showInFinder": "Mostra nel Finder",
"showInFileExplorer": "Mostra in Esplora file",
"phoneTranscriptionSaved": "Trascrizione dal telefono salvata",
"phoneRecording": "Registrazione dal telefono"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "ウィンドウを閉じてもVibeは通知領域で動作を続けるため、グローバル音声入力をそのまま使えます。終了するには通知領域のメニューから操作してください。",
"trayShow": "Vibeを開く",
"trayHide": "Vibeを隠す",
"trayQuit": "Vibeを終了"
"trayQuit": "Vibeを終了",
"launchAtStartup": "ログイン時に自動起動",
"launchAtStartupInfo": "コンピューターにログインしたときにVibeを自動的に起動します。",
"couldNotEnableLaunchAtStartup": "自動起動を有効にできませんでした: {error}",
"couldNotDisableLaunchAtStartup": "自動起動を無効にできませんでした: {error}",
"phone": "スマートフォン",
"phoneHandoff": "スマートフォン連携",
"phoneHandoffInfo": "スマートフォンで録音し、文字起こしはこのコンピューターが行います。結果はそのままスマートフォンに戻ります。",
"phoneHandoffToggleInfo": "一度ペアリングすれば、いつでもスマートフォンから録音できます。",
"phoneHandoffRelayNote": "スマートフォンのブラウザーは直接接続できないため、音声は中継サーバーを経由します。ただしエンドツーエンドで暗号化されており、中継サーバーには暗号化されたデータしか見えません。スマートフォンから利用するには、このコンピューターでVibeを開いたままにしてください。",
"phoneHandoffUnavailable": "このビルドのVibeではスマートフォン連携を利用できません。",
"pairAPhone": "スマートフォンをペアリング",
"pairingQrCode": "ペアリング用QRコード",
"pairingQrCodeError": "ペアリングコードを表示できませんでした。",
"scanPairingCodeInfo": "スマートフォンのカメラでこのコードを読み取り、開いたページはそのままにしておいてください。",
"pairingLink": "ペアリングリンク",
"copyPairingLink": "ペアリングリンクをコピー",
"noPairingCode": "ペアリングコードはまだありません。",
"thisComputer": "このコンピューター",
"endpointIdInfo": "スマートフォンの接続先エンドポイントID",
"status": "状態",
"failed": "失敗",
"phoneWaitingForRecording": "スマートフォンからの録音を待っています。",
"phoneReceivingAudio": "スマートフォンから音声を受信しています…",
"phoneLoadingModel": "文字起こしモデルを読み込んでいます…",
"phoneTranscribing": "録音を文字起こししています…",
"phoneTranscriptSentBack": "完了しました。文字起こしをスマートフォンに送り返しました。",
"phoneRecordingFailed": "前回の録音は失敗しました。",
"regeneratePairingCode": "ペアリングコードを再生成",
"regeneratePairingCodeInfo": "新しいコードを発行し、古いコードを無効にします。ペアリング済みのスマートフォンは再度読み取りが必要です。",
"showInFinder": "Finderで表示",
"showInFileExplorer": "エクスプローラーで表示",
"phoneTranscriptionSaved": "スマートフォンの文字起こしを保存しました",
"phoneRecording": "スマートフォンの録音"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "창을 닫아도 Vibe가 시스템 트레이에서 계속 실행되어 전역 받아쓰기를 그대로 사용할 수 있습니다. 종료하려면 트레이 메뉴를 사용하세요.",
"trayShow": "Vibe 열기",
"trayHide": "Vibe 숨기기",
"trayQuit": "Vibe 종료"
"trayQuit": "Vibe 종료",
"launchAtStartup": "시작 시 자동 실행",
"launchAtStartupInfo": "컴퓨터에 로그인하면 Vibe를 자동으로 실행합니다.",
"couldNotEnableLaunchAtStartup": "시작 시 자동 실행을 켤 수 없습니다: {error}",
"couldNotDisableLaunchAtStartup": "시작 시 자동 실행을 끌 수 없습니다: {error}",
"phone": "휴대폰",
"phoneHandoff": "휴대폰 연동",
"phoneHandoffInfo": "휴대폰으로 녹음하면 이 컴퓨터가 받아쓰기를 처리하고, 결과는 곧바로 휴대폰으로 돌아갑니다.",
"phoneHandoffToggleInfo": "휴대폰을 한 번만 페어링하면 언제든지 휴대폰에서 녹음할 수 있습니다.",
"phoneHandoffRelayNote": "휴대폰 브라우저는 직접 연결할 수 없어 오디오가 중계 서버를 거치지만, 종단 간 암호화되어 있어 중계 서버는 암호문만 볼 수 있습니다. 휴대폰이 연결하려면 이 컴퓨터에서 Vibe가 계속 실행 중이어야 합니다.",
"phoneHandoffUnavailable": "이 빌드의 Vibe에서는 휴대폰 연동을 사용할 수 없습니다.",
"pairAPhone": "휴대폰 페어링",
"pairingQrCode": "페어링 QR 코드",
"pairingQrCodeError": "페어링 코드를 표시할 수 없습니다.",
"scanPairingCodeInfo": "휴대폰 카메라로 이 코드를 비춘 다음, 열린 페이지를 그대로 두세요.",
"pairingLink": "페어링 링크",
"copyPairingLink": "페어링 링크 복사",
"noPairingCode": "아직 페어링 코드가 없습니다.",
"thisComputer": "이 컴퓨터",
"endpointIdInfo": "휴대폰이 연결할 엔드포인트 ID",
"status": "상태",
"failed": "실패",
"phoneWaitingForRecording": "휴대폰이 녹음을 보내기를 기다리는 중입니다.",
"phoneReceivingAudio": "휴대폰에서 오디오를 받는 중…",
"phoneLoadingModel": "받아쓰기 모델을 불러오는 중…",
"phoneTranscribing": "녹음을 받아쓰는 중…",
"phoneTranscriptSentBack": "완료되었습니다. 받아쓴 내용을 휴대폰으로 보냈습니다.",
"phoneRecordingFailed": "마지막 녹음이 실패했습니다.",
"regeneratePairingCode": "페어링 코드 재생성",
"regeneratePairingCodeInfo": "새 코드를 발급하고 이전 코드를 무효화합니다. 이미 페어링된 휴대폰은 다시 스캔해야 합니다.",
"showInFinder": "Finder에서 보기",
"showInFileExplorer": "파일 탐색기에서 보기",
"phoneTranscriptionSaved": "휴대폰 받아쓰기 저장됨",
"phoneRecording": "휴대폰 녹음"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Når du lukker vinduet, fortsetter Vibe å kjøre i systemstatusfeltet, slik at global diktering fortsatt virker. Avslutt det fra menyen i systemstatusfeltet.",
"trayShow": "Åpne Vibe",
"trayHide": "Skjul Vibe",
"trayQuit": "Avslutt Vibe"
"trayQuit": "Avslutt Vibe",
"launchAtStartup": "Start ved pålogging",
"launchAtStartupInfo": "Åpner Vibe automatisk når du logger på datamaskinen.",
"couldNotEnableLaunchAtStartup": "Kunne ikke slå på start ved pålogging: {error}",
"couldNotDisableLaunchAtStartup": "Kunne ikke slå av start ved pålogging: {error}",
"phone": "Telefon",
"phoneHandoff": "Overlevering fra telefon",
"phoneHandoffInfo": "Ta opp på telefonen og la denne datamaskinen stå for transkripsjonen — teksten kommer rett tilbake til telefonen.",
"phoneHandoffToggleInfo": "Sammenkoble en telefon én gang, så kan du ta opp fra den når som helst.",
"phoneHandoffRelayNote": "Lyden går via en reléserver fordi nettlesere på telefoner ikke kan koble til direkte, men den er ende-til-ende-kryptert — reléet ser bare kryptert data. Vibe må være åpent på denne datamaskinen for at telefonen skal nå det.",
"phoneHandoffUnavailable": "Overlevering fra telefon er ikke tilgjengelig i denne versjonen av Vibe.",
"pairAPhone": "Sammenkoble en telefon",
"pairingQrCode": "QR-kode for sammenkobling",
"pairingQrCodeError": "Kunne ikke vise sammenkoblingskoden.",
"scanPairingCodeInfo": "Rett telefonkameraet mot denne koden, og behold siden som åpnes.",
"pairingLink": "Sammenkoblingslenke",
"copyPairingLink": "Kopier sammenkoblingslenke",
"noPairingCode": "Ingen sammenkoblingskode ennå.",
"thisComputer": "Denne datamaskinen",
"endpointIdInfo": "Endepunkt-ID som telefonen kobler seg til",
"status": "Status",
"failed": "Mislyktes",
"phoneWaitingForRecording": "Venter på at en telefon skal sende et opptak.",
"phoneReceivingAudio": "Mottar lyd fra telefonen…",
"phoneLoadingModel": "Laster inn transkripsjonsmodellen…",
"phoneTranscribing": "Transkriberer opptaket…",
"phoneTranscriptSentBack": "Ferdig — transkripsjonen gikk tilbake til telefonen.",
"phoneRecordingFailed": "Det siste opptaket mislyktes.",
"regeneratePairingCode": "Lag ny sammenkoblingskode",
"regeneratePairingCodeInfo": "Utsteder en ny kode og ugyldiggjør den gamle — en telefon som allerede er sammenkoblet, må skanne på nytt.",
"showInFinder": "Vis i Finder",
"showInFileExplorer": "Vis i Filutforsker",
"phoneTranscriptionSaved": "Telefontranskripsjon lagret",
"phoneRecording": "Telefonopptak"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Zamknięcie okna pozostawia Vibe działający w zasobniku systemowym, więc dyktowanie globalne nadal działa. Zamknij go z menu w zasobniku.",
"trayShow": "Otwórz Vibe",
"trayHide": "Ukryj Vibe",
"trayQuit": "Zakończ Vibe"
"trayQuit": "Zakończ Vibe",
"launchAtStartup": "Uruchamiaj przy starcie systemu",
"launchAtStartupInfo": "Otwiera Vibe automatycznie po zalogowaniu się do komputera.",
"couldNotEnableLaunchAtStartup": "Nie udało się włączyć uruchamiania przy starcie systemu: {error}",
"couldNotDisableLaunchAtStartup": "Nie udało się wyłączyć uruchamiania przy starcie systemu: {error}",
"phone": "Telefon",
"phoneHandoff": "Przekazywanie z telefonu",
"phoneHandoffInfo": "Nagrywaj na telefonie, a transkrypcję wykona ten komputer — gotowy tekst wróci prosto na telefon.",
"phoneHandoffToggleInfo": "Sparuj telefon raz, a potem nagrywaj z niego, kiedy tylko chcesz.",
"phoneHandoffRelayNote": "Dźwięk przechodzi przez serwer pośredniczący, ponieważ przeglądarki w telefonach nie mogą połączyć się bezpośrednio, jest jednak szyfrowany end-to-end — serwer widzi wyłącznie zaszyfrowane dane. Vibe musi pozostać otwarty na tym komputerze, aby telefon mógł się z nim połączyć.",
"phoneHandoffUnavailable": "Przekazywanie z telefonu nie jest dostępne w tej wersji Vibe.",
"pairAPhone": "Sparuj telefon",
"pairingQrCode": "Kod QR do parowania",
"pairingQrCodeError": "Nie udało się wyświetlić kodu parowania.",
"scanPairingCodeInfo": "Skieruj aparat telefonu na ten kod, a następnie zachowaj otwartą stronę, która się pojawi.",
"pairingLink": "Link do parowania",
"copyPairingLink": "Kopiuj link do parowania",
"noPairingCode": "Nie ma jeszcze kodu parowania.",
"thisComputer": "Ten komputer",
"endpointIdInfo": "Identyfikator punktu końcowego, z którym łączy się telefon",
"status": "Stan",
"failed": "Niepowodzenie",
"phoneWaitingForRecording": "Oczekiwanie na nagranie z telefonu.",
"phoneReceivingAudio": "Odbieranie dźwięku z telefonu...",
"phoneLoadingModel": "Ładowanie modelu transkrypcji...",
"phoneTranscribing": "Transkrypcja nagrania...",
"phoneTranscriptSentBack": "Gotowe — transkrypcja wróciła na telefon.",
"phoneRecordingFailed": "Ostatnie nagranie się nie powiodło.",
"regeneratePairingCode": "Wygeneruj nowy kod parowania",
"regeneratePairingCodeInfo": "Tworzy nowy kod i unieważnia stary — telefon, który jest już sparowany, będzie musiał zeskanować kod ponownie.",
"showInFinder": "Pokaż w Finderze",
"showInFileExplorer": "Pokaż w Eksploratorze plików",
"phoneTranscriptionSaved": "Zapisano transkrypcję z telefonu",
"phoneRecording": "Nagranie z telefonu"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Ao fechar a janela, o Vibe continua em execução na bandeja do sistema, então o ditado global continua funcionando. Saia pelo menu da bandeja.",
"trayShow": "Abrir Vibe",
"trayHide": "Ocultar Vibe",
"trayQuit": "Sair do Vibe"
"trayQuit": "Sair do Vibe",
"launchAtStartup": "Iniciar com o sistema",
"launchAtStartupInfo": "Abre o Vibe automaticamente quando você faz login no computador.",
"couldNotEnableLaunchAtStartup": "Não foi possível ativar o início automático: {error}",
"couldNotDisableLaunchAtStartup": "Não foi possível desativar o início automático: {error}",
"phone": "Celular",
"phoneHandoff": "Gravação pelo celular",
"phoneHandoffInfo": "Grave no celular e deixe a transcrição com este computador — o texto volta direto para o celular.",
"phoneHandoffToggleInfo": "Pareie o celular uma vez e grave por ele quando quiser.",
"phoneHandoffRelayNote": "O áudio passa por um servidor de retransmissão porque os navegadores do celular não conseguem se conectar diretamente, mas ele tem criptografia de ponta a ponta — o servidor só vê dados cifrados. O Vibe precisa continuar aberto neste computador para o celular alcançá-lo.",
"phoneHandoffUnavailable": "A gravação pelo celular não está disponível nesta versão do Vibe.",
"pairAPhone": "Parear um celular",
"pairingQrCode": "QR code de pareamento",
"pairingQrCodeError": "Não foi possível exibir o código de pareamento.",
"scanPairingCodeInfo": "Aponte a câmera do celular para este código e mantenha aberta a página que aparecer.",
"pairingLink": "Link de pareamento",
"copyPairingLink": "Copiar link de pareamento",
"noPairingCode": "Ainda não há código de pareamento.",
"thisComputer": "Este computador",
"endpointIdInfo": "ID do endpoint ao qual o celular se conecta",
"status": "Status",
"failed": "Falhou",
"phoneWaitingForRecording": "Aguardando um celular enviar uma gravação.",
"phoneReceivingAudio": "Recebendo áudio do celular…",
"phoneLoadingModel": "Carregando o modelo de transcrição…",
"phoneTranscribing": "Transcrevendo a gravação…",
"phoneTranscriptSentBack": "Pronto — a transcrição voltou para o seu celular.",
"phoneRecordingFailed": "A última gravação falhou.",
"regeneratePairingCode": "Gerar novo código de pareamento",
"regeneratePairingCodeInfo": "Emite um código novo e invalida o antigo — um celular já pareado precisará escanear de novo.",
"showInFinder": "Mostrar no Finder",
"showInFileExplorer": "Mostrar no Explorador de Arquivos",
"phoneTranscriptionSaved": "Transcrição do celular salva",
"phoneRecording": "Gravação do celular"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "При закрытии окна Vibe продолжает работать в трее, поэтому глобальная диктовка остаётся доступной. Выйти можно из меню в трее.",
"trayShow": "Открыть Vibe",
"trayHide": "Скрыть Vibe",
"trayQuit": "Выйти из Vibe"
"trayQuit": "Выйти из Vibe",
"launchAtStartup": "Запускать при входе в систему",
"launchAtStartupInfo": "Открывает Vibe автоматически при входе в систему.",
"couldNotEnableLaunchAtStartup": "Не удалось включить запуск при входе в систему: {error}",
"couldNotDisableLaunchAtStartup": "Не удалось отключить запуск при входе в систему: {error}",
"phone": "Телефон",
"phoneHandoff": "Передача с телефона",
"phoneHandoffInfo": "Записывайте на телефоне, а транскрипцию выполнит этот компьютер — готовый текст сразу вернётся на телефон.",
"phoneHandoffToggleInfo": "Достаточно один раз связать телефон, и потом можно записывать с него в любое время.",
"phoneHandoffRelayNote": "Звук идёт через сервер-ретранслятор, потому что браузеры на телефонах не могут подключиться напрямую, но он защищён сквозным шифрованием — ретранслятор видит только зашифрованные данные. Vibe должен оставаться открытым на этом компьютере, чтобы телефон мог до него дотянуться.",
"phoneHandoffUnavailable": "Передача с телефона недоступна в этой сборке Vibe.",
"pairAPhone": "Связать телефон",
"pairingQrCode": "QR-код для связывания",
"pairingQrCodeError": "Не удалось отобразить код связывания.",
"scanPairingCodeInfo": "Наведите камеру телефона на этот код, а затем не закрывайте открывшуюся страницу.",
"pairingLink": "Ссылка для связывания",
"copyPairingLink": "Копировать ссылку для связывания",
"noPairingCode": "Кода связывания пока нет.",
"thisComputer": "Этот компьютер",
"endpointIdInfo": "Идентификатор конечной точки, к которой подключается телефон",
"status": "Состояние",
"failed": "Ошибка",
"phoneWaitingForRecording": "Ожидание записи с телефона.",
"phoneReceivingAudio": "Приём звука с телефона...",
"phoneLoadingModel": "Загрузка модели транскрибирования...",
"phoneTranscribing": "Транскрибирование записи...",
"phoneTranscriptSentBack": "Готово — транскрипция вернулась на телефон.",
"phoneRecordingFailed": "Последняя запись не удалась.",
"regeneratePairingCode": "Создать новый код связывания",
"regeneratePairingCodeInfo": "Выдаёт новый код и делает старый недействительным — уже связанному телефону придётся отсканировать код заново.",
"showInFinder": "Показать в Finder",
"showInFileExplorer": "Показать в Проводнике",
"phoneTranscriptionSaved": "Транскрипция с телефона сохранена",
"phoneRecording": "Запись с телефона"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "När du stänger fönstret fortsätter Vibe att köra i systemfältet, så global diktering fungerar fortfarande. Avsluta det från menyn i systemfältet.",
"trayShow": "Öppna Vibe",
"trayHide": "Dölj Vibe",
"trayQuit": "Avsluta Vibe"
"trayQuit": "Avsluta Vibe",
"launchAtStartup": "Starta vid inloggning",
"launchAtStartupInfo": "Öppnar Vibe automatiskt när du loggar in på datorn.",
"couldNotEnableLaunchAtStartup": "Kunde inte aktivera start vid inloggning: {error}",
"couldNotDisableLaunchAtStartup": "Kunde inte inaktivera start vid inloggning: {error}",
"phone": "Telefon",
"phoneHandoff": "Överlämning från telefon",
"phoneHandoffInfo": "Spela in på telefonen och låt den här datorn sköta transkriberingen — texten kommer direkt tillbaka till telefonen.",
"phoneHandoffToggleInfo": "Parkoppla en telefon en gång, sedan kan du spela in från den när du vill.",
"phoneHandoffRelayNote": "Ljudet går via en reläserver eftersom webbläsare i telefoner inte kan ansluta direkt, men det är totalsträckskrypterat — relät ser bara krypterad data. Vibe måste vara öppet på den här datorn för att telefonen ska nå det.",
"phoneHandoffUnavailable": "Överlämning från telefon är inte tillgängligt i den här versionen av Vibe.",
"pairAPhone": "Parkoppla en telefon",
"pairingQrCode": "QR-kod för parkoppling",
"pairingQrCodeError": "Det gick inte att visa parkopplingskoden.",
"scanPairingCodeInfo": "Rikta telefonens kamera mot den här koden och behåll sedan sidan som öppnas.",
"pairingLink": "Parkopplingslänk",
"copyPairingLink": "Kopiera parkopplingslänk",
"noPairingCode": "Ingen parkopplingskod ännu.",
"thisComputer": "Den här datorn",
"endpointIdInfo": "Slutpunkts-id som telefonen ansluter till",
"status": "Status",
"failed": "Misslyckades",
"phoneWaitingForRecording": "Väntar på att en telefon ska skicka en inspelning.",
"phoneReceivingAudio": "Tar emot ljud från telefonen...",
"phoneLoadingModel": "Läser in transkriberingsmodellen...",
"phoneTranscribing": "Transkriberar inspelningen...",
"phoneTranscriptSentBack": "Klart — transkriberingen skickades tillbaka till telefonen.",
"phoneRecordingFailed": "Den senaste inspelningen misslyckades.",
"regeneratePairingCode": "Skapa ny parkopplingskod",
"regeneratePairingCodeInfo": "Utfärdar en ny kod och gör den gamla ogiltig — en telefon som redan är parkopplad måste skanna igen.",
"showInFinder": "Visa i Finder",
"showInFileExplorer": "Visa i Utforskaren",
"phoneTranscriptionSaved": "Telefontranskribering sparad",
"phoneRecording": "Telefoninspelning"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Pencereyi kapattığınızda Vibe sistem tepsisinde çalışmayı sürdürür, böylece genel dikte çalışmaya devam eder. Tepsi menüsünden çıkabilirsiniz.",
"trayShow": "Vibe'ı aç",
"trayHide": "Vibe'ı gizle",
"trayQuit": "Vibe'dan çık"
"trayQuit": "Vibe'dan çık",
"launchAtStartup": "Başlangıçta çalıştır",
"launchAtStartupInfo": "Bilgisayarınızda oturum açtığınızda Vibe otomatik olarak açılır.",
"couldNotEnableLaunchAtStartup": "Başlangıçta çalıştırma etkinleştirilemedi: {error}",
"couldNotDisableLaunchAtStartup": "Başlangıçta çalıştırma devre dışı bırakılamadı: {error}",
"phone": "Telefon",
"phoneHandoff": "Telefondan kayıt",
"phoneHandoffInfo": "Telefonunuzla kaydedin, transkripsiyonu bu bilgisayar yapsın — metin doğrudan telefonunuza geri döner.",
"phoneHandoffToggleInfo": "Telefonu bir kez eşleştirin, sonra istediğiniz zaman onunla kayıt yapın.",
"phoneHandoffRelayNote": "Telefon tarayıcıları doğrudan bağlanamadığı için ses bir aktarma (relay) sunucusu üzerinden geçer, ancak uçtan uca şifrelidir — sunucu yalnızca şifreli veriyi görür. Telefonun ulaşabilmesi için Vibe'ın bu bilgisayarda açık kalması gerekir.",
"phoneHandoffUnavailable": "Telefondan kayıt, Vibe'ın bu sürümünde kullanılamıyor.",
"pairAPhone": "Telefon eşleştir",
"pairingQrCode": "Eşleştirme QR kodu",
"pairingQrCodeError": "Eşleştirme kodu görüntülenemedi.",
"scanPairingCodeInfo": "Telefonunuzun kamerasını bu koda tutun, ardından açılan sayfayı kapatmayın.",
"pairingLink": "Eşleştirme bağlantısı",
"copyPairingLink": "Eşleştirme bağlantısını kopyala",
"noPairingCode": "Henüz eşleştirme kodu yok.",
"thisComputer": "Bu bilgisayar",
"endpointIdInfo": "Telefonunuzun bağlandığı uç nokta kimliği",
"status": "Durum",
"failed": "Başarısız",
"phoneWaitingForRecording": "Bir telefondan kayıt gelmesi bekleniyor.",
"phoneReceivingAudio": "Telefonunuzdan ses alınıyor…",
"phoneLoadingModel": "Transkripsiyon modeli yükleniyor…",
"phoneTranscribing": "Kayıt transkribe ediliyor…",
"phoneTranscriptSentBack": "Tamamlandı — metin telefonunuza geri gönderildi.",
"phoneRecordingFailed": "Son kayıt başarısız oldu.",
"regeneratePairingCode": "Eşleştirme kodunu yenile",
"regeneratePairingCodeInfo": "Yeni bir kod oluşturur ve eskisini geçersiz kılar — hâlihazırda eşleştirilmiş bir telefonun yeniden taraması gerekir.",
"showInFinder": "Finder'da göster",
"showInFileExplorer": "Dosya Gezgini'nde göster",
"phoneTranscriptionSaved": "Telefon transkripsiyonu kaydedildi",
"phoneRecording": "Telefon kaydı"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "Đóng cửa sổ sẽ để Vibe tiếp tục chạy trong khay hệ thống, nên đọc chính tả toàn cục vẫn hoạt động. Thoát bằng menu ở khay hệ thống.",
"trayShow": "Mở Vibe",
"trayHide": "Ẩn Vibe",
"trayQuit": "Thoát Vibe"
"trayQuit": "Thoát Vibe",
"launchAtStartup": "Khởi chạy cùng hệ thống",
"launchAtStartupInfo": "Tự động mở Vibe khi bạn đăng nhập vào máy tính.",
"couldNotEnableLaunchAtStartup": "Không thể bật khởi chạy cùng hệ thống: {error}",
"couldNotDisableLaunchAtStartup": "Không thể tắt khởi chạy cùng hệ thống: {error}",
"phone": "Điện thoại",
"phoneHandoff": "Ghi âm từ điện thoại",
"phoneHandoffInfo": "Ghi âm trên điện thoại và để máy tính này lo việc phiên âm — bản phiên âm sẽ được gửi thẳng về điện thoại.",
"phoneHandoffToggleInfo": "Ghép nối điện thoại một lần, sau đó ghi âm từ nó bất cứ lúc nào.",
"phoneHandoffRelayNote": "Âm thanh đi qua một máy chủ chuyển tiếp vì trình duyệt trên điện thoại không thể kết nối trực tiếp, nhưng nó được mã hóa đầu cuối — máy chủ chuyển tiếp chỉ thấy dữ liệu đã mã hóa. Vibe phải luôn mở trên máy tính này để điện thoại kết nối được.",
"phoneHandoffUnavailable": "Bản dựng Vibe này không hỗ trợ ghi âm từ điện thoại.",
"pairAPhone": "Ghép nối điện thoại",
"pairingQrCode": "Mã QR ghép nối",
"pairingQrCodeError": "Không thể hiển thị mã ghép nối.",
"scanPairingCodeInfo": "Hướng camera điện thoại vào mã này, rồi giữ nguyên trang vừa mở.",
"pairingLink": "Liên kết ghép nối",
"copyPairingLink": "Sao chép liên kết ghép nối",
"noPairingCode": "Chưa có mã ghép nối.",
"thisComputer": "Máy tính này",
"endpointIdInfo": "ID điểm cuối mà điện thoại của bạn kết nối tới",
"status": "Trạng thái",
"failed": "Thất bại",
"phoneWaitingForRecording": "Đang chờ điện thoại gửi bản ghi âm.",
"phoneReceivingAudio": "Đang nhận âm thanh từ điện thoại…",
"phoneLoadingModel": "Đang tải mô hình phiên âm…",
"phoneTranscribing": "Đang phiên âm bản ghi…",
"phoneTranscriptSentBack": "Xong — bản phiên âm đã được gửi về điện thoại.",
"phoneRecordingFailed": "Bản ghi gần nhất đã thất bại.",
"regeneratePairingCode": "Tạo lại mã ghép nối",
"regeneratePairingCodeInfo": "Tạo mã mới và vô hiệu hóa mã cũ — điện thoại đã ghép nối sẽ phải quét lại.",
"showInFinder": "Hiện trong Finder",
"showInFileExplorer": "Hiện trong File Explorer",
"phoneTranscriptionSaved": "Đã lưu bản phiên âm từ điện thoại",
"phoneRecording": "Bản ghi từ điện thoại"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "关闭窗口后 Vibe 会继续在系统托盘中运行,全局听写仍然可用。可从托盘菜单退出。",
"trayShow": "打开 Vibe",
"trayHide": "隐藏 Vibe",
"trayQuit": "退出 Vibe"
"trayQuit": "退出 Vibe",
"launchAtStartup": "开机时启动",
"launchAtStartupInfo": "登录电脑后自动打开 Vibe。",
"couldNotEnableLaunchAtStartup": "无法开启开机启动:{error}",
"couldNotDisableLaunchAtStartup": "无法关闭开机启动:{error}",
"phone": "手机",
"phoneHandoff": "手机接力",
"phoneHandoffInfo": "在手机上录音,由这台电脑完成转录,转录结果会直接回到手机上。",
"phoneHandoffToggleInfo": "配对一次手机,之后随时都能用它录音。",
"phoneHandoffRelayNote": "手机浏览器无法直接连接,音频会经由中继服务器传输,但全程端到端加密,中继服务器只能看到密文。手机要连上这台电脑,Vibe 需要保持打开。",
"phoneHandoffUnavailable": "此版本的 Vibe 不支持手机接力。",
"pairAPhone": "配对手机",
"pairingQrCode": "配对二维码",
"pairingQrCodeError": "无法生成配对码。",
"scanPairingCodeInfo": "用手机摄像头扫描此二维码,然后保持打开的页面不要关闭。",
"pairingLink": "配对链接",
"copyPairingLink": "复制配对链接",
"noPairingCode": "尚无配对码。",
"thisComputer": "这台电脑",
"endpointIdInfo": "手机连接的端点 ID",
"status": "状态",
"failed": "失败",
"phoneWaitingForRecording": "正在等待手机发送录音。",
"phoneReceivingAudio": "正在接收手机的音频…",
"phoneLoadingModel": "正在加载转录模型…",
"phoneTranscribing": "正在转录录音…",
"phoneTranscriptSentBack": "完成,转录结果已回传到手机。",
"phoneRecordingFailed": "上一次录音失败。",
"regeneratePairingCode": "重新生成配对码",
"regeneratePairingCodeInfo": "签发新的配对码并作废旧的配对码,已配对的手机需要重新扫描。",
"showInFinder": "在访达中显示",
"showInFileExplorer": "在文件资源管理器中显示",
"phoneTranscriptionSaved": "已保存手机转录",
"phoneRecording": "手机录音"
}
+34 -1
View File
@@ -445,5 +445,38 @@
"closeToTrayInfo": "關閉視窗後 Vibe 會繼續在系統匣中運行,全域語音輸入仍然可用。可從系統匣選單結束程式。",
"trayShow": "開啟 Vibe",
"trayHide": "隱藏 Vibe",
"trayQuit": "結束 Vibe"
"trayQuit": "結束 Vibe",
"launchAtStartup": "開機時啟動",
"launchAtStartupInfo": "登入電腦後自動開啟 Vibe。",
"couldNotEnableLaunchAtStartup": "無法開啟開機啟動:{error}",
"couldNotDisableLaunchAtStartup": "無法關閉開機啟動:{error}",
"phone": "手機",
"phoneHandoff": "手機接力",
"phoneHandoffInfo": "在手機上錄音,由這部電腦完成轉錄,轉錄結果會直接傳回手機。",
"phoneHandoffToggleInfo": "手機配對一次,之後隨時都可以用它錄音。",
"phoneHandoffRelayNote": "手機瀏覽器無法直接連線,音頻會經由中繼伺服器傳送,但全程端對端加密,中繼伺服器只會看到密文。手機要連上這部電腦,Vibe 必須保持開啟。",
"phoneHandoffUnavailable": "此版本的 Vibe 不支援手機接力。",
"pairAPhone": "配對手機",
"pairingQrCode": "配對二維碼",
"pairingQrCodeError": "無法產生配對碼。",
"scanPairingCodeInfo": "用手機鏡頭掃描此二維碼,然後保持開啟的頁面不要關閉。",
"pairingLink": "配對連結",
"copyPairingLink": "複製配對連結",
"noPairingCode": "尚未有配對碼。",
"thisComputer": "這部電腦",
"endpointIdInfo": "手機連接的端點 ID",
"status": "狀態",
"failed": "失敗",
"phoneWaitingForRecording": "正在等待手機傳送錄音。",
"phoneReceivingAudio": "正在接收手機的音頻…",
"phoneLoadingModel": "正在載入轉錄模型…",
"phoneTranscribing": "正在轉錄錄音…",
"phoneTranscriptSentBack": "完成,轉錄結果已傳回手機。",
"phoneRecordingFailed": "上一次錄音失敗。",
"regeneratePairingCode": "重新產生配對碼",
"regeneratePairingCodeInfo": "簽發新的配對碼並使舊的失效,已配對的手機需要重新掃描。",
"showInFinder": "在 Finder 中顯示",
"showInFileExplorer": "在檔案總管中顯示",
"phoneTranscriptionSaved": "已儲存手機轉錄",
"phoneRecording": "手機錄音"
}
+30
View File
@@ -64,3 +64,33 @@ check-i18n:
# Type-check desktop and website
check-types:
pnpm check-types
# --- Phone handoff -----------------------------------------------------------
# Build the browser wasm client (writes pwa/public/wasm/)
phone-wasm:
./handoff-wasm/build.sh
# Run the phone PWA locally on http://localhost:8088 (builds wasm first)
phone: phone-wasm
cd pwa && pnpm install && pnpm dev
# Send an audio file to a running Vibe as if you were the phone.
# Copy the pairing URL from Vibe -> Settings -> Phone.
# just phone-probe 'http://localhost:8088/#<endpointId>:<token>' samples/single.wav
phone-probe url file:
cd handoff-probe && cargo run --quiet -- --url '{{url}}' --file '../{{file}}'
# Ask a running Vibe what it supports, without sending audio.
# just phone-caps 'http://localhost:8088/#<endpointId>:<token>'
phone-caps url:
cd handoff-probe && cargo run --quiet -- --url '{{url}}' --capabilities
# Serve the phone PWA over an HTTPS tunnel so a real phone can load it.
# Needs `cloudflared` (brew install cloudflared). Prints a https://…trycloudflare.com URL.
# Start Vibe with that URL so the QR points at it:
# VIBE_PWA_ORIGIN=https://<host> just dev
phone-tunnel: phone-wasm
cd pwa && pnpm install && PWA_BASE=/ pnpm build
@echo "Serving pwa/dist and opening a tunnel — copy the https:// URL below."
uv run pwa/serve.py & cloudflared tunnel --url http://localhost:8088
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
# Generated by handoff-wasm/build.sh (CI rebuilds it; see .github/workflows/website.yml)
public/wasm
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Vibe Phone</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#181818" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Vibe Phone" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "vibe-phone",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 8088 --host",
"build": "tsc && vite build",
"preview": "vite preview --port 8088 --host"
},
"dependencies": {
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}
+2088
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+32
View File
@@ -0,0 +1,32 @@
{
"name": "Vibe Phone",
"short_name": "Vibe",
"description": "Record on your phone, transcribe on your Vibe desktop.",
"start_url": "./",
"scope": "./",
"id": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#181818",
"theme_color": "#2563eb",
"icons": [
{
"src": "./icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+84
View File
@@ -0,0 +1,84 @@
// Minimal service worker. Vite serves `public/` verbatim, so this stays a plain
// classic script with no imports and no build step — which also means nothing
// here is rewritten for the deploy base. Every path is therefore resolved
// against `self.location`, i.e. the directory this worker is served from.
//
// The app is deployed under a subpath (`/vibe/phone/` on GitHub Pages), and a
// worker's default scope is its own directory: a worker at `/vibe/phone/sw.js`
// controls `/vibe/phone/` and below, and nothing of the website around it.
//
// It exists for one reason: make the app installable to the iOS/Android home
// screen and survive a flaky network. It deliberately NEVER cache-firsts the
// handoff wasm, which is rebuilt constantly during development.
const CACHE = 'vibe-phone-v1'
/** Directory this worker was served from — `/` in dev, `/vibe/phone/` in production. */
const BASE = new URL('./', self.location).href
const at = (path) => new URL(path, BASE).href
// Hashed Vite assets are cached on demand; only the entry document is precached.
const SHELL = [at('.'), at('index.html'), at('manifest.webmanifest'), at('icons/icon-192.png'), at('icons/icon-512.png')]
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((c) => c.addAll(SHELL))
.catch(() => undefined)
.then(() => self.skipWaiting())
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (event) => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== self.location.origin) return
// Always network for the wasm bundle so a rebuild is picked up immediately.
if (url.pathname.includes('/wasm/') || url.pathname.endsWith('.wasm')) {
event.respondWith(fetch(req, { cache: 'no-store' }))
return
}
// Navigations: network first, cached shell as the offline fallback. The
// fallback is this app's own index.html, not the site root's.
if (req.mode === 'navigate') {
event.respondWith(fetch(req).catch(() => caches.match(at('index.html')).then((r) => r || Response.error())))
return
}
// Everything else (hashed JS/CSS, icons): cache first, refresh in background.
event.respondWith(
caches.match(req).then((hit) => {
const network = fetch(req)
.then((res) => {
if (res && res.ok) {
const copy = res.clone()
caches
.open(CACHE)
.then((c) => c.put(req, copy))
.catch(() => {})
}
return res
})
.catch((err) => {
if (hit) return hit
throw err
})
return hit || network
})
)
})
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Serve the *built* Vibe Phone PWA (`pwa/dist/`) over HTTP.
For development use Vite instead: `pnpm -C pwa dev` (port 8088). This script is
for checking a production build without a bundler in the loop:
pnpm -C pwa build && uv run pwa/serve.py # http://localhost:8088
Sets the MIME types browsers require for `.wasm` and `.webmanifest`, disables
caching (so a rebuilt wasm is always picked up) and sends permissive CORS plus
the cross-origin isolation headers that SharedArrayBuffer-using wasm may need.
"""
from __future__ import annotations
import argparse
import functools
import http.server
import socket
import socketserver
from pathlib import Path
ROOT = Path(__file__).resolve().parent / "dist"
EXTRA_TYPES = {
".wasm": "application/wasm",
".webmanifest": "application/manifest+json",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
}
class Handler(http.server.SimpleHTTPRequestHandler):
extensions_map = {
**http.server.SimpleHTTPRequestHandler.extensions_map,
**EXTRA_TYPES,
}
def end_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def do_OPTIONS(self) -> None: # noqa: N802 - stdlib naming
self.send_response(204)
self.end_headers()
def log_message(self, fmt: str, *args) -> None:
print(f" {self.address_string()} {fmt % args}")
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def lan_ip() -> str:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--port", type=int, default=8088)
ap.add_argument("--host", default="0.0.0.0")
args = ap.parse_args()
if not ROOT.is_dir():
raise SystemExit(f"{ROOT} does not exist — run `pnpm -C pwa build` first.")
handler = functools.partial(Handler, directory=str(ROOT))
with Server((args.host, args.port), handler) as httpd:
print(f"Serving {ROOT} on:")
print(f" http://localhost:{args.port}")
print(f" http://{lan_ip()}:{args.port} (phones: needs HTTPS for the microphone)")
print("Ctrl-C to stop.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()
+807
View File
@@ -0,0 +1,807 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertTriangle, Check, Copy, HardDriveDownload, Mic, QrCode, RefreshCw, RotateCcw, Settings, Square, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { InstallHint } from '~/components/install-hint'
import { SettingsSheet } from '~/components/settings-sheet'
import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { Card, CardContent } from '~/components/ui/card'
import { Progress } from '~/components/ui/progress'
import { Spinner } from '~/components/ui/spinner'
import {
basename,
clearPeer,
fetchCapabilities,
getClient,
LANG_KEY,
loadPeer,
normalizeEvent,
parsePairingHash,
savePeer,
truncateId,
type Capabilities,
type HandoffError,
type HandoffEvent,
type Peer,
} from '~/lib/handoff'
import { languageLabel } from '~/lib/languages'
import { canRecord, filenameFor, formatDuration, formatSize, pickMimeType } from '~/lib/recorder'
import { cn } from '~/lib/style'
import { requestPersistentStorage } from '~/lib/use-install'
import { useWakeLock } from '~/lib/use-wake-lock'
type Phase = 'idle' | 'recording' | 'sending' | 'done' | 'failed'
/** How long to wait for the desktop after the page returns to the foreground. */
const STALL_GRACE_MS = 20_000
interface Failure {
code: string
message: string
}
export function App() {
const [peer, setPeer] = useState<Peer | null>(null)
const [lang, setLang] = useState('')
const [phase, setPhase] = useState<Phase>('idle')
const [elapsed, setElapsed] = useState(0)
const [status, setStatus] = useState('')
const [uploadPct, setUploadPct] = useState<number | null>(null)
const [transcribePct, setTranscribePct] = useState<number | null>(null)
const [transcript, setTranscript] = useState('')
const [failure, setFailure] = useState<Failure | null>(null)
const [copied, setCopied] = useState(false)
const [savedPath, setSavedPath] = useState<string | null>(null)
const [sizeWarning, setSizeWarning] = useState(false)
const [loadingModel, setLoadingModel] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
// What the desktop says it can do. Never assumed locally.
const [capabilities, setCapabilities] = useState<Capabilities | null>(null)
const [capabilitiesError, setCapabilitiesError] = useState<HandoffError | null>(null)
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<BlobPart[]>([])
const bytesRef = useRef(0)
const blobRef = useRef<Blob | null>(null)
const segmentsRef = useRef<string[]>([])
const sendingRef = useRef(false)
const langRef = useRef('')
const maxBytesRef = useRef(0)
const peerRef = useRef<Peer | null>(null)
const startedAtRef = useRef(0)
// Last time the desktop said anything, used to notice a dropped relay.
const lastEventAtRef = useRef(0)
const stallTimerRef = useRef<number | null>(null)
const abandonedRef = useRef(false)
const { acquire, release, reacquireIfWanted } = useWakeLock()
langRef.current = lang
peerRef.current = peer
maxBytesRef.current = capabilities?.maxAudioBytes ?? 0
const secure = typeof window !== 'undefined' && window.isSecureContext
const recordable = canRecord()
/* ---------------------------------------------------------- pairing --- */
useEffect(() => {
const adopt = () => {
const fromHash = parsePairingHash(location.hash)
if (fromHash) {
savePeer(fromHash)
setPeer(fromHash)
// The pairing now lives only in localStorage; ask the browser to keep it.
void requestPersistentStorage()
// Drop the token from the address bar so it does not linger in history.
history.replaceState(null, '', location.pathname + location.search)
toast.success('Paired with your desktop')
return true
}
return false
}
if (!adopt()) setPeer(loadPeer())
try {
setLang(localStorage.getItem(LANG_KEY) ?? '')
} catch {
/* private mode */
}
const onHashChange = () => adopt()
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
/* ----------------------------------------------------- capabilities --- */
const refreshCapabilities = useCallback(async (target: Peer) => {
setCapabilitiesLoading(true)
setCapabilitiesError(null)
const result = await fetchCapabilities(target)
if (result.type === 'error') {
setCapabilities(null)
setCapabilitiesError(result)
} else {
setCapabilities(result)
// A language saved from an earlier model may not exist on this one.
setLang((current) => {
if (!current) return current
if (result.languages.includes(current)) return current
try {
localStorage.removeItem(LANG_KEY)
} catch {
/* private mode */
}
return ''
})
}
setCapabilitiesLoading(false)
}, [])
useEffect(() => {
if (!peer || !secure) return
void refreshCapabilities(peer)
}, [peer, secure, refreshCapabilities])
/* ------------------------------------------------------------ timer --- */
useEffect(() => {
if (phase !== 'recording') return
const id = window.setInterval(() => setElapsed(Date.now() - startedAtRef.current), 200)
return () => window.clearInterval(id)
}, [phase])
/* -------------------------------------------------- stall watchdog --- */
const clearStallTimer = useCallback(() => {
if (stallTimerRef.current !== null) {
window.clearTimeout(stallTimerRef.current)
stallTimerRef.current = null
}
}, [])
/**
* The transcript arrives over a single live stream with no resume in the
* protocol. If the phone was backgrounded long enough for the relay
* connection to die, the read below simply never resolves — a silent hang.
* So after the page comes back, give the desktop a grace period and, if
* nothing arrives, say so and offer the retry (the audio is still in memory).
*/
const armStallTimer = useCallback(() => {
clearStallTimer()
const resumedAt = Date.now()
stallTimerRef.current = window.setTimeout(() => {
stallTimerRef.current = null
if (!sendingRef.current || lastEventAtRef.current >= resumedAt) return
abandonedRef.current = true
sendingRef.current = false
release()
setLoadingModel(false)
setStatus('')
setFailure({
code: 'connection_lost',
message:
'The connection to your desktop dropped while the app was in the background. Your recording is still here — send it again.',
})
setPhase('failed')
}, STALL_GRACE_MS)
}, [clearStallTimer, release])
/* ------------------------------------------------------------- send --- */
const send = useCallback(async () => {
const currentPeer = peerRef.current
const blob = blobRef.current
if (!currentPeer || !blob || sendingRef.current) return
// Refuse an upload the desktop is going to reject on arrival — no point
// burning cellular data on it. A missing or zero cap means "unknown".
const cap = maxBytesRef.current
if (cap > 0 && blob.size > cap) {
release()
setFailure({
code: 'too_large',
message: `This recording is ${formatSize(blob.size)}, over your desktop's ${formatSize(cap)} limit. It was not uploaded — record a shorter take.`,
})
setPhase('failed')
setStatus('')
return
}
sendingRef.current = true
abandonedRef.current = false
lastEventAtRef.current = Date.now()
// Retry starts an operation with no recording in progress, so take the
// lock here too; acquire() is idempotent when we already hold it.
void acquire()
segmentsRef.current = []
setTranscript('')
setFailure(null)
setUploadPct(0)
setTranscribePct(null)
setSavedPath(null)
setLoadingModel(false)
setPhase('sending')
setStatus('Connecting to your desktop…')
const mime = blob.type || 'application/octet-stream'
const filename = filenameFor(mime)
const wireLang = langRef.current ? langRef.current : null
try {
const client = await getClient()
const bytes = new Uint8Array(await blob.arrayBuffer())
setStatus(`Sending ${formatSize(bytes.length)}`)
const stream = client.send_recording(currentPeer.endpointId, currentPeer.token, filename, mime, wireLang, false, bytes)
const reader = stream.getReader()
for (;;) {
const { value, done } = await reader.read()
if (done) break
const event = normalizeEvent(value) as HandoffEvent | null
if (!event) continue
// A stalled operation was already reported to the user; do not
// resurrect it if the connection limps back to life.
if (abandonedRef.current) break
lastEventAtRef.current = Date.now()
switch (event.type) {
case 'uploadProgress': {
const pct = event.total > 0 ? (event.sent / event.total) * 100 : 0
setUploadPct(Math.min(100, Math.round(pct)))
break
}
case 'accepted':
setUploadPct(100)
setStatus('Desktop received it.')
break
case 'status':
// Loading a large model into Sona takes tens of seconds and reports
// no percentage, so show an indeterminate bar rather than a 0% one
// that reads as a stall. Unknown phases are ignored on purpose.
if (event.phase === 'loading_model') {
setLoadingModel(true)
setTranscribePct(null)
setStatus('Loading model on your desktop…')
} else if (event.phase === 'transcribing') {
setLoadingModel(false)
setTranscribePct((current) => current ?? 0)
setStatus('Transcribing…')
}
break
case 'progress':
setLoadingModel(false)
setTranscribePct(Math.max(0, Math.min(100, Math.round(Number(event.progress) || 0))))
break
case 'segment':
segmentsRef.current.push(String(event.text ?? ''))
setTranscript(segmentsRef.current.join(' ').replace(/\s+/g, ' ').trim())
break
case 'done':
release()
setLoadingModel(false)
if (typeof event.text === 'string') setTranscript(event.text.trim())
if (typeof event.savedPath === 'string' && event.savedPath) setSavedPath(event.savedPath)
setTranscribePct(100)
setPhase('done')
setStatus(
typeof event.processingTimeSec === 'number' ? `Transcribed in ${Math.round(event.processingTimeSec)}s.` : 'Transcribed.'
)
break
case 'error':
release()
setLoadingModel(false)
setFailure({ code: event.code || 'error', message: event.message || 'The desktop reported an error.' })
setPhase('failed')
setStatus('')
break
default:
break
}
}
// Stream ended without a terminal event.
setPhase((current) => {
if (current !== 'sending') return current
setFailure({ code: 'incomplete', message: 'The desktop closed the connection before finishing.' })
return 'failed'
})
} catch (err) {
setFailure({ code: 'transport', message: err instanceof Error ? err.message : String(err) })
setPhase('failed')
setStatus('')
} finally {
sendingRef.current = false
clearStallTimer()
// Backstop: covers the transport catch, a stream that ends without a
// terminal event, and any path the cases above missed.
release()
}
}, [])
/* -------------------------------------------------------- recording --- */
const startRecording = useCallback(async () => {
setFailure(null)
setTranscript('')
setStatus('')
setUploadPct(null)
setTranscribePct(null)
setSavedPath(null)
setSizeWarning(false)
blobRef.current = null
chunksRef.current = []
bytesRef.current = 0
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
setFailure({ code: 'microphone', message: err instanceof Error ? err.message : String(err) })
setPhase('failed')
return
}
const preferred = pickMimeType()
let recorder: MediaRecorder
try {
recorder = preferred ? new MediaRecorder(stream, { mimeType: preferred }) : new MediaRecorder(stream)
} catch {
recorder = new MediaRecorder(stream)
}
recorderRef.current = recorder
recorder.ondataavailable = (event) => {
if (!event.data || event.data.size === 0) return
chunksRef.current.push(event.data)
bytesRef.current += event.data.size
// Warn as the recording approaches the desktop's cap, and stop at it
// rather than letting the user keep talking into an upload that would
// be refused on arrival.
const limit = maxBytesRef.current
if (limit > 0) {
if (bytesRef.current >= limit) {
toast.warning('Size limit reached', {
description: `Your desktop accepts at most ${formatSize(limit)}. Sending what was recorded so far.`,
})
if (recorderRef.current && recorderRef.current.state !== 'inactive') recorderRef.current.stop()
} else if (bytesRef.current >= limit * 0.8) {
setSizeWarning(true)
}
}
}
recorder.onstop = () => {
for (const track of stream.getTracks()) track.stop()
// The lock is deliberately NOT released here: the upload and the
// desktop's transcription still have to happen, and that is exactly
// when the screen must not sleep.
const first = chunksRef.current[0]
const type = (first instanceof Blob ? first.type : '') || recorder.mimeType || preferred || 'application/octet-stream'
const blob = new Blob(chunksRef.current, { type })
chunksRef.current = []
recorderRef.current = null
blobRef.current = blob
if (blob.size === 0) {
release()
setFailure({ code: 'empty', message: 'Nothing was captured. Check the microphone permission and try again.' })
setPhase('failed')
return
}
void send()
}
recorder.onerror = () => {
release()
setFailure({ code: 'recorder', message: 'The browser stopped the recording unexpectedly.' })
setPhase('failed')
}
recorder.start(1000)
startedAtRef.current = Date.now()
setElapsed(0)
setPhase('recording')
void acquire()
}, [acquire, release, send])
const stopRecording = useCallback(() => {
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') recorder.stop()
}, [])
// Backgrounding means two different things depending on where we are.
//
// While RECORDING: iOS suspends the microphone, silently truncating the
// capture, so stop cleanly and say why.
//
// While SENDING: the audio has left the phone and the desktop keeps working
// regardless — but the transcript comes back over one live stream that
// cannot be resumed, so a long suspension loses it. We cannot reconnect, so
// we warn plainly, and on return re-take the wake lock and watch for a
// stream that never speaks again.
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
if (recorderRef.current) {
stopRecording()
toast.warning('Recording stopped', {
description: 'iOS suspends the microphone when the app is not on screen, so we sent what we had.',
})
} else if (sendingRef.current) {
toast.warning('Keep this screen open', {
description: 'The transcript is arriving over a live connection. Leaving the app can drop it.',
})
}
return
}
// Back on screen: the spec dropped our lock, so take it back.
reacquireIfWanted()
if (sendingRef.current) armStallTimer()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}, [stopRecording, reacquireIfWanted, armStallTimer])
/* ---------------------------------------------------------- actions --- */
const onCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(transcript)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
toast.error('Could not copy', { description: 'Select the text and copy it manually.' })
}
}, [transcript])
const onDiscard = useCallback(() => {
// Stopping and discarding without sending must not leave the screen awake.
release()
clearStallTimer()
abandonedRef.current = true
blobRef.current = null
segmentsRef.current = []
setTranscript('')
setFailure(null)
setStatus('')
setUploadPct(null)
setTranscribePct(null)
setSavedPath(null)
setSizeWarning(false)
setLoadingModel(false)
setPhase('idle')
}, [])
const onUnpair = useCallback(() => {
clearPeer()
setPeer(null)
setCapabilities(null)
setCapabilitiesError(null)
setSettingsOpen(false)
onDiscard()
}, [onDiscard])
const onLangChange = useCallback((value: string) => {
setLang(value)
try {
if (value) localStorage.setItem(LANG_KEY, value)
else localStorage.removeItem(LANG_KEY)
} catch {
/* private mode */
}
}, [])
/* ----------------------------------------------------------- render --- */
if (!secure) return <Shell>{<InsecureNotice />}</Shell>
if (!peer) return <Shell>{<UnpairedNotice />}</Shell>
const recording = phase === 'recording'
const busy = phase === 'sending'
const hasRecording = blobRef.current !== null
// Recording is gated on the desktop being ready, and on having a language
// when the loaded model cannot detect one for itself.
const modelLoaded = capabilities?.modelLoaded ?? false
const maxBytes = capabilities?.maxAudioBytes ?? 0
const needsExplicitLang = !!capabilities && !capabilities.languageDetection && !lang
const ready = recordable && modelLoaded && !needsExplicitLang
const langSummary = lang ? languageLabel(lang) : 'Auto-detect'
return (
<Shell
onSettings={() => setSettingsOpen(true)}
badge={
<Badge variant="secondary" className="font-mono text-[10px] font-normal">
{truncateId(peer.endpointId)}
</Badge>
}>
{!recordable && (
<Card className="mb-4 border-destructive/40">
<CardContent className="pt-6 text-sm text-muted-foreground">
This browser has no <code className="font-mono">MediaRecorder</code>, so it cannot record audio. Use Safari 17+ or Chrome.
</CardContent>
</Card>
)}
{capabilitiesLoading && (
<Card className="stagger-in mb-4">
<CardContent className="flex items-center gap-3 pt-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
<span>Asking your desktop what it can do</span>
</CardContent>
</Card>
)}
{!capabilitiesLoading && capabilitiesError && (
<Card className="stagger-in mb-4 border-destructive/40">
<CardContent className="space-y-3 pt-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-4" />
<span className="eyebrow text-destructive">{capabilitiesError.code}</span>
</div>
<p className="text-sm">{capabilitiesError.message}</p>
{capabilitiesError.code === 'unauthorized' ? (
<>
<p className="text-sm text-muted-foreground">
This pairing is no longer valid the desktop has a new token. Unpair and scan the QR code again.
</p>
<Button variant="destructive" className="h-12 w-full" onClick={onUnpair}>
Unpair and rescan
</Button>
</>
) : (
<Button variant="outline" className="h-12 w-full" onClick={() => void refreshCapabilities(peer)}>
<RefreshCw />
Try again
</Button>
)}
</CardContent>
</Card>
)}
{!capabilitiesLoading && capabilities && !capabilities.modelLoaded && (
<Card className="stagger-in mb-4">
<CardContent className="space-y-3 pt-6">
<h2 className="text-base font-semibold">No model loaded</h2>
<p className="text-sm text-muted-foreground">
Load a model in Vibe on your desktop, then re-check. Recording is disabled until then.
</p>
<Button variant="outline" className="h-12 w-full" onClick={() => void refreshCapabilities(peer)}>
<RefreshCw />
Re-check
</Button>
</CardContent>
</Card>
)}
{needsExplicitLang && (
<Card className="stagger-in mb-4">
<CardContent className="space-y-3 pt-6">
<p className="text-sm text-muted-foreground">
This model cannot detect the spoken language. Choose one before recording.
</p>
<Button variant="outline" className="h-12 w-full" onClick={() => setSettingsOpen(true)}>
Choose a language
</Button>
</CardContent>
</Card>
)}
<div className="flex flex-col items-center py-8">
<button
type="button"
disabled={!ready || busy}
onClick={recording ? stopRecording : () => void startRecording()}
aria-label={recording ? 'Stop recording' : 'Start recording'}
className={cn(
'flex size-44 flex-col items-center justify-center gap-3 rounded-full text-lg font-semibold shadow-lg transition-transform duration-150 active:scale-[0.97] disabled:opacity-50',
recording ? 'record-pulse bg-destructive text-destructive-foreground' : 'bg-primary text-primary-foreground'
)}>
{recording ? <Square className="size-9 fill-current" /> : <Mic className="size-10" />}
<span>{recording ? 'Stop' : 'Record'}</span>
</button>
<div className="mt-5 h-8 text-3xl font-semibold tabular-nums">{recording ? formatDuration(elapsed) : ''}</div>
{recording && sizeWarning && maxBytes > 0 && (
<p className="mt-1 text-center text-xs text-destructive">
Approaching your desktop's {formatSize(maxBytes)} limit — recording will stop there.
</p>
)}
<p className="text-sm text-muted-foreground">{recording ? 'Keep this screen open.' : 'Tap to record, tap again to send.'}</p>
{capabilities?.modelLoaded && (
<p className="mt-3 text-center text-xs text-muted-foreground">
{langSummary}
{capabilities.modelName && (
<>
{' · '}
<code className="font-mono">{capabilities.modelName}</code>
</>
)}
</p>
)}
</div>
{(busy || status || uploadPct !== null || failure) && (
<Card className="stagger-in mb-4">
<CardContent className="space-y-4 pt-6">
{status && (
<div className="flex items-center gap-2 text-sm">
{busy && <Spinner className="size-4" />}
<span>{status}</span>
</div>
)}
{busy && <p className="text-xs text-muted-foreground">Keep this screen open until the transcript arrives.</p>}
{uploadPct !== null && <Meter label="Upload" value={uploadPct} />}
{loadingModel && <IndeterminateMeter label="Loading model" />}
{transcribePct !== null && <Meter label="Transcribing" value={transcribePct} />}
{failure && (
<div className="rounded-xl border border-destructive/40 bg-destructive/10 p-4">
<div className="mb-1 flex items-center gap-2 text-destructive">
<AlertTriangle className="size-4" />
<span className="eyebrow text-destructive">{failure.code}</span>
</div>
<p className="text-sm">{failure.message}</p>
</div>
)}
{phase === 'done' && savedPath && (
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<HardDriveDownload className="mt-0.5 size-3.5 shrink-0" />
<span>
Saved on your desktop as <code className="font-mono break-all">{basename(savedPath)}</code>
</span>
</div>
)}
{(failure || phase === 'done') && (
<div className="flex flex-wrap gap-2">
{failure && hasRecording && (
<Button className="h-12 flex-1" onClick={() => void send()}>
<RotateCcw />
Retry send
</Button>
)}
<Button variant="outline" className="h-12 flex-1" onClick={onDiscard}>
<Trash2 />
Discard
</Button>
</div>
)}
</CardContent>
</Card>
)}
{transcript && (
<Card className="stagger-in mb-4">
<CardContent className="pt-6">
<div className="mb-3 flex items-center justify-between">
<span className="eyebrow">Transcript</span>
<Button variant="ghost" size="sm" onClick={() => void onCopy()}>
{copied ? <Check /> : <Copy />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{transcript}</p>
</CardContent>
</Card>
)}
<InstallHint variant="subtle" />
<SettingsSheet
open={settingsOpen}
endpointId={peer.endpointId}
capabilities={capabilities}
lang={lang}
onLangChange={onLangChange}
onUnpair={onUnpair}
onClose={() => setSettingsOpen(false)}
/>
</Shell>
)
}
function Meter({ label, value }: { label: string; value: number }) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="tabular-nums">{value}%</span>
</div>
<Progress value={value} className="progress-aurora h-2" />
</div>
)
}
/** For work with no reportable percentage — the desktop loading a model. */
function IndeterminateMeter({ label }: { label: string }) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span>this can take a while</span>
</div>
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="aurora-bar handoff-sweep h-full w-1/3 rounded-full" />
</div>
</div>
)
}
function Shell({ children, onSettings, badge }: { children: React.ReactNode; onSettings?: () => void; badge?: React.ReactNode }) {
return (
<div className="safe-bottom mx-auto flex min-h-dvh w-full max-w-md flex-col px-4">
<header className="safe-top flex items-center justify-between pb-2">
<div className="flex items-center gap-2">
<h1 className="text-base font-semibold tracking-tight">Vibe Phone</h1>
{badge}
</div>
{onSettings && (
<Button variant="ghost" size="icon" onClick={onSettings} aria-label="Settings">
<Settings />
</Button>
)}
</header>
<main className="flex-1">{children}</main>
</div>
)
}
function UnpairedNotice() {
return (
<div className="mt-10 space-y-4">
<Card className="stagger-in">
<CardContent className="flex flex-col items-center gap-4 py-10 text-center">
<div className="aurora flex size-20 items-center justify-center rounded-2xl">
<QrCode className="size-9" />
</div>
<div>
<h2 className="text-lg font-semibold">Not paired yet</h2>
<p className="mt-1 text-sm text-muted-foreground">
Scan the QR code in Vibe &rarr; Settings &rarr; Phone to link this device to your desktop.
</p>
<p className="mt-3 text-xs text-muted-foreground">
Paired before and seeing this? Scanning the QR code again is all it takes — it re-pairs in one step.
</p>
</div>
</CardContent>
</Card>
<InstallHint variant="pre-pairing" />
</div>
)
}
function InsecureNotice() {
return (
<Card className="stagger-in mt-10 border-destructive/40">
<CardContent className="flex flex-col gap-3 py-8">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-5" />
<h2 className="text-base font-semibold">Insecure connection</h2>
</div>
<p className="text-sm text-muted-foreground">
Microphone access needs HTTPS or <code className="font-mono">localhost</code>. This page was served over plain HTTP from{' '}
<code className="font-mono break-all">{location.origin}</code>, so recording is disabled.
</p>
<p className="text-sm text-muted-foreground">
Open it on the desktop at <code className="font-mono">http://localhost:8088</code>, or put the app behind HTTPS (or a tunnel)
before testing on a phone.
</p>
</CardContent>
</Card>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Share, Plus, X, Smartphone } from 'lucide-react'
import { Button } from '~/components/ui/button'
import { useInstall } from '~/lib/use-install'
interface Props {
/**
* `pre-pairing` is shown in the unpaired empty state and argues for
* installing *first*: on iOS the installed app gets its own storage, so a
* pairing scanned in Safari does not follow you into the installed app.
*/
variant: 'pre-pairing' | 'subtle'
}
export function InstallHint({ variant }: Props) {
const { mode, install, dismiss } = useInstall()
if (mode === 'none') return null
const prePairing = variant === 'pre-pairing'
return (
<div className={prePairing ? 'rounded-2xl border border-border bg-muted/60 p-4' : 'mt-2 rounded-xl border border-border p-3'}>
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Add Vibe Phone to your home screen</p>
{mode === 'ios-manual' ? (
<p className="mt-1 flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
Tap <Share className="inline size-3.5" /> Share, then <Plus className="inline size-3.5" /> Add to Home Screen.
</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground">
{prePairing
? 'Install first, then scan the QR code from inside the installed app — on iOS the installed app keeps its own storage, so a pairing made in the browser does not carry over.'
: 'It opens full screen, and keeps your pairing from being cleared when the browser tidies up unused sites.'}
</p>
{mode === 'prompt' && (
<Button size="sm" className="mt-3 h-10" onClick={() => void install()}>
Add to home screen
</Button>
)}
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-m-2 shrink-0 cursor-pointer p-2 text-muted-foreground">
<X className="size-4" />
</button>
</div>
</div>
)
}
+385
View File
@@ -0,0 +1,385 @@
// Ported from `desktop/src/components/language-combobox.tsx` so the phone and the
// main window stay visually identical. Differences are phone-specific only:
// larger touch targets, a viewport-bounded popover, and no autofocus on the
// search field (autofocusing pops the on-screen keyboard the instant the picker
// opens, hiding the very list the user came to browse).
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Globe } from 'lucide-react'
import { cn } from '~/lib/style'
import { Popover, PopoverAnchor, PopoverContent } from '~/components/ui/popover'
export interface LanguageOption {
/** Value handed back to `onSelect`. */
code: string
/** Text shown in the row and, when selected, on the trigger. */
label: string
/** Extra needles the search matches against (english name, iso code, ...). */
keywords?: string[]
/** Key used to look up the flag; falls back to `code`. */
flagCode?: string
/** Secondary flag key, usually the lowercase english name. */
flagName?: string
/** Show a globe instead of a flag (used by "auto detect"). */
globe?: boolean
/** Render the row a touch heavier (used by "auto detect"). */
emphasis?: boolean
}
export interface LanguageGroup {
label: string | null
items: LanguageOption[]
}
interface LanguageComboboxProps {
/** Currently selected code; the matching row gets a checkmark. */
value: string
onSelect: (code: string) => void
/** Groups to render. Callers filter by `query` themselves so they own the grouping rules. */
groups: LanguageGroup[]
query: string
onQueryChange: (query: string) => void
/** Text on the closed trigger, and the placeholder while typing. */
triggerLabel: string
ariaLabel: string
emptyLabel?: string
/**
* `prominent` renders the pill used in the toolbar; `default` is the plain full-width control.
*/
variant?: 'default' | 'prominent'
/** Extra classes for the trigger (width overrides, mostly). */
className?: string
contentClassName?: string
/** Capitalize labels that ship lowercase (the display-language registry does). */
capitalize?: boolean
}
// Whisper reports languages either as ISO codes or as lowercase English names — key both.
const FLAGS: Record<string, string> = {
en: '🇺🇸',
english: '🇺🇸',
es: '🇪🇸',
spanish: '🇪🇸',
hi: '🇮🇳',
hindi: '🇮🇳',
fr: '🇫🇷',
french: '🇫🇷',
de: '🇩🇪',
german: '🇩🇪',
it: '🇮🇹',
italian: '🇮🇹',
pt: '🇧🇷',
portuguese: '🇧🇷',
ru: '🇷🇺',
russian: '🇷🇺',
ja: '🇯🇵',
japanese: '🇯🇵',
ko: '🇰🇷',
korean: '🇰🇷',
zh: '🇨🇳',
chinese: '🇨🇳',
ar: '🇸🇦',
arabic: '🇸🇦',
he: '🇮🇱',
iw: '🇮🇱',
hebrew: '🇮🇱',
tr: '🇹🇷',
turkish: '🇹🇷',
nl: '🇳🇱',
dutch: '🇳🇱',
pl: '🇵🇱',
polish: '🇵🇱',
uk: '🇺🇦',
ukrainian: '🇺🇦',
sv: '🇸🇪',
swedish: '🇸🇪',
no: '🇳🇴',
nb: '🇳🇴',
norwegian: '🇳🇴',
da: '🇩🇰',
danish: '🇩🇰',
fi: '🇫🇮',
finnish: '🇫🇮',
cs: '🇨🇿',
czech: '🇨🇿',
sk: '🇸🇰',
slovak: '🇸🇰',
hu: '🇭🇺',
hungarian: '🇭🇺',
ro: '🇷🇴',
romanian: '🇷🇴',
bg: '🇧🇬',
bulgarian: '🇧🇬',
hr: '🇭🇷',
croatian: '🇭🇷',
sr: '🇷🇸',
serbian: '🇷🇸',
sl: '🇸🇮',
slovenian: '🇸🇮',
el: '🇬🇷',
greek: '🇬🇷',
id: '🇮🇩',
indonesian: '🇮🇩',
ms: '🇲🇾',
malay: '🇲🇾',
vi: '🇻🇳',
vietnamese: '🇻🇳',
th: '🇹🇭',
thai: '🇹🇭',
fa: '🇮🇷',
persian: '🇮🇷',
ur: '🇵🇰',
urdu: '🇵🇰',
bn: '🇧🇩',
bengali: '🇧🇩',
ta: '🇮🇳',
tamil: '🇮🇳',
te: '🇮🇳',
telugu: '🇮🇳',
mr: '🇮🇳',
marathi: '🇮🇳',
kn: '🇮🇳',
kannada: '🇮🇳',
ml: '🇮🇳',
malayalam: '🇮🇳',
pa: '🇮🇳',
punjabi: '🇮🇳',
gu: '🇮🇳',
gujarati: '🇮🇳',
az: '🇦🇿',
azerbaijani: '🇦🇿',
kk: '🇰🇿',
kazakh: '🇰🇿',
uz: '🇺🇿',
uzbek: '🇺🇿',
ka: '🇬🇪',
georgian: '🇬🇪',
hy: '🇦🇲',
armenian: '🇦🇲',
et: '🇪🇪',
estonian: '🇪🇪',
lv: '🇱🇻',
latvian: '🇱🇻',
lt: '🇱🇹',
lithuanian: '🇱🇹',
be: '🇧🇾',
belarusian: '🇧🇾',
mk: '🇲🇰',
macedonian: '🇲🇰',
sq: '🇦🇱',
albanian: '🇦🇱',
bs: '🇧🇦',
bosnian: '🇧🇦',
is: '🇮🇸',
icelandic: '🇮🇸',
mt: '🇲🇹',
maltese: '🇲🇹',
sw: '🇰🇪',
swahili: '🇰🇪',
af: '🇿🇦',
afrikaans: '🇿🇦',
am: '🇪🇹',
amharic: '🇪🇹',
ne: '🇳🇵',
nepali: '🇳🇵',
si: '🇱🇰',
sinhala: '🇱🇰',
my: '🇲🇲',
myanmar: '🇲🇲',
burmese: '🇲🇲',
km: '🇰🇭',
khmer: '🇰🇭',
lo: '🇱🇦',
lao: '🇱🇦',
mn: '🇲🇳',
mongolian: '🇲🇳',
tl: '🇵🇭',
tagalog: '🇵🇭',
filipino: '🇵🇭',
ca: '🇦🇩',
catalan: '🇦🇩',
gl: '🇪🇸',
galician: '🇪🇸',
eu: '🇪🇸',
basque: '🇪🇸',
cy: '🇬🇧',
welsh: '🇬🇧',
ga: '🇮🇪',
irish: '🇮🇪',
yi: '🇮🇱',
yiddish: '🇮🇱',
}
export function flagFor(code: string, name?: string): string | null {
return FLAGS[code.toLowerCase()] ?? (name ? (FLAGS[name.toLowerCase()] ?? null) : null)
}
export function FlagSlot({ code, name }: { code: string; name?: string }) {
const flag = flagFor(code, name)
return (
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center text-[15px] leading-none">
{flag ?? <Globe className="h-3.5 w-3.5 text-muted-foreground" />}
</span>
)
}
/** Search-as-you-type language picker: the pill turns into the input while open. */
export function LanguageCombobox({
value,
onSelect,
groups,
query,
onQueryChange,
triggerLabel,
ariaLabel,
emptyLabel,
variant = 'default',
className,
contentClassName,
capitalize,
}: LanguageComboboxProps) {
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const flat = useMemo(() => groups.flatMap((group) => group.items), [groups])
useEffect(() => {
setHighlighted(0)
}, [query, open])
useEffect(() => {
if (!open) onQueryChange('')
}, [open])
// Keep the highlighted row in view while arrowing through the list.
useEffect(() => {
listRef.current?.querySelector(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: 'nearest' })
}, [highlighted])
function pick(code: string) {
onSelect(code)
setOpen(false)
}
function onInputKeyDown(event: React.KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setHighlighted((index) => Math.min(index + 1, flat.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setHighlighted((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
const chosen = flat[highlighted] ?? flat[0]
if (chosen) pick(chosen.code)
} else if (event.key === 'Escape') {
setOpen(false)
}
}
const prominent = variant === 'prominent'
let runningIndex = -1
return (
<Popover open={open} onOpenChange={setOpen}>
{/* The pill itself becomes the input while open — same container, same shape. */}
<PopoverAnchor asChild>
<div
onClick={() => {
if (!open) setOpen(true)
}}
className={cn(
'flex items-center gap-2 border text-start transition-colors duration-150',
prominent ? 'h-12 w-[200px] rounded-full bg-card px-4 text-sm font-medium' : 'h-12 w-full rounded-xl bg-transparent px-4 text-base',
open ? 'border-ring/60' : 'cursor-pointer border-border hover:bg-muted/60',
className,
)}>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
{open ? (
<input
ref={inputRef}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder={triggerLabel}
aria-label={ariaLabel}
className={cn(
'w-full min-w-0 bg-transparent text-inherit outline-none placeholder:text-muted-foreground',
capitalize && 'placeholder:capitalize',
)}
/>
) : (
<button
type="button"
aria-label={ariaLabel}
aria-expanded={open}
className={cn('min-w-0 flex-1 cursor-pointer truncate text-start text-inherit outline-none', capitalize && 'capitalize')}>
{triggerLabel}
</button>
)}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
collisionPadding={12}
avoidCollisions
onOpenAutoFocus={(event) => event.preventDefault()}
className={cn(
'rounded-2xl p-0',
prominent ? 'w-[240px]' : 'w-[var(--radix-popper-anchor-width)] min-w-[240px] max-w-[calc(100vw-24px)]',
contentClassName
)}>
<div ref={listRef} className="max-h-[min(60dvh,var(--radix-popover-content-available-height))] overflow-y-auto overscroll-contain p-1.5">
{flat.length === 0 && <p className="px-3 py-8 text-center text-sm text-muted-foreground">{emptyLabel ?? 'No matches'}</p>}
{groups.map((group, groupIndex) => (
<div key={group.label ?? `group-${groupIndex}`}>
{group.label && (
<p className="px-2.5 pt-2.5 pb-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">{group.label}</p>
)}
{group.items.map((entry) => {
runningIndex += 1
const index = runningIndex
const active = entry.code === value
return (
<button
key={entry.code}
type="button"
data-index={index}
onClick={() => pick(entry.code)}
onMouseMove={() => setHighlighted(index)}
className={cn(
'flex min-h-12 w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-start text-base text-foreground',
index === highlighted && 'bg-muted/70',
entry.emphasis && 'font-medium',
)}>
{entry.globe ? (
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center">
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
</span>
) : (
<FlagSlot code={entry.flagCode ?? entry.code} name={entry.flagName} />
)}
<span className={cn('min-w-0 flex-1 truncate', capitalize && 'capitalize')}>{entry.label}</span>
{active && <Check className="h-4 w-4 shrink-0 text-foreground" />}
</button>
)
})}
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
/** Does an option match what the user typed? */
export function matchesQuery(option: LanguageOption, needle: string) {
if (!needle) return true
const haystack = [option.label, option.code, ...(option.keywords ?? [])]
return haystack.some((text) => text.toLowerCase().includes(needle))
}
+104
View File
@@ -0,0 +1,104 @@
import { useMemo, useState } from 'react'
import { LanguageCombobox, matchesQuery, type LanguageGroup, type LanguageOption } from '~/components/language-combobox'
import { AUTO_LANG, englishLanguageLabel, languageLabel, loadRecentLanguages, rememberLanguage } from '~/lib/languages'
import type { Capabilities } from '~/lib/handoff'
interface Props {
capabilities: Capabilities | null
/** `''` means auto-detect; the combobox uses the `auto` sentinel internally. */
value: string
onChange: (lang: string) => void
}
/**
* Adapted from `desktop/src/components/language-input.tsx`: same control, same
* grouping shape, sourced from the same data. The desktop reads
* `capabilities.language_detection` off the model metadata; the phone reads
* `languageDetection` off the capabilities reply — the same fact, one relay hop
* away. Nothing here knows any language the desktop did not send.
*/
export function LanguagePicker({ capabilities, value, onChange }: Props) {
const [query, setQuery] = useState('')
const [recent, setRecent] = useState<string[]>(() => loadRecentLanguages())
const hasAutoDetect = capabilities?.languageDetection ?? false
const selected = value || (hasAutoDetect ? AUTO_LANG : '')
// Localized label for display, English name as an extra search needle — so
// typing "german" finds "Deutsch", exactly as the desktop intends.
const entries = useMemo<LanguageOption[]>(() => {
const list = (capabilities?.languages ?? []).map((code) => {
const english = englishLanguageLabel(code)
return { code, label: languageLabel(code), keywords: [english], flagCode: code, flagName: english }
})
list.sort((a, b) => a.label.localeCompare(b.label))
return list
}, [capabilities])
const autoEntry: LanguageOption | null = hasAutoDetect
? { code: AUTO_LANG, label: 'Auto-detect', keywords: ['auto detect', 'automatic'], globe: true, emphasis: true }
: null
const needle = query.trim().toLowerCase()
const groups = useMemo<LanguageGroup[]>(() => {
if (needle) {
const matches = entries.filter((entry) => matchesQuery(entry, needle))
if (autoEntry && matchesQuery(autoEntry, needle)) matches.unshift(autoEntry)
return [{ label: null, items: matches }]
}
// "Popular" on the desktop is a fixed shortlist. On a phone the device
// already knows which languages this person uses, so ask it instead of
// inventing a list.
const deviceCodes = new Set(
(navigator.languages ?? [navigator.language])
.map((tag) => tag.split('-')[0]?.toLowerCase())
.filter((code): code is string => !!code)
)
const recentSet = new Set(recent)
const recentItems: LanguageOption[] = []
const deviceItems: LanguageOption[] = []
const others: LanguageOption[] = []
for (const entry of entries) {
if (recentSet.has(entry.code)) recentItems.push(entry)
else if (deviceCodes.has(entry.code.toLowerCase())) deviceItems.push(entry)
else others.push(entry)
}
recentItems.sort((a, b) => recent.indexOf(a.code) - recent.indexOf(b.code))
const result: LanguageGroup[] = []
if (autoEntry) result.push({ label: null, items: [autoEntry] })
if (recentItems.length) result.push({ label: 'Recently used', items: recentItems })
if (deviceItems.length) result.push({ label: 'On this device', items: deviceItems })
if (others.length) result.push({ label: 'Others', items: others })
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, needle, recent, hasAutoDetect])
function select(code: string) {
if (code === AUTO_LANG) {
onChange('')
return
}
onChange(code)
setRecent(rememberLanguage(code))
}
const current = entries.find((entry) => entry.code === selected)
const triggerLabel = selected === AUTO_LANG && autoEntry ? autoEntry.label : (current?.label ?? 'Choose a language')
return (
<LanguageCombobox
value={selected}
onSelect={select}
groups={groups}
query={query}
onQueryChange={setQuery}
triggerLabel={triggerLabel}
ariaLabel="Language"
/>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { Link2Off, X } from 'lucide-react'
import { Button } from '~/components/ui/button'
import { LanguagePicker } from '~/components/language-picker'
import { truncateId, type Capabilities } from '~/lib/handoff'
interface Props {
open: boolean
endpointId: string
capabilities: Capabilities | null
lang: string
onLangChange: (lang: string) => void
onUnpair: () => void
onClose: () => void
}
export function SettingsSheet({
open,
endpointId,
capabilities,
lang,
onLangChange,
onUnpair,
onClose,
}: Props) {
if (!open) return null
// Every option below comes from the desktop's capabilities reply.
const canAuto = capabilities?.languageDetection ?? false
const hasLanguages = (capabilities?.languages.length ?? 0) > 0
return (
<div className="fixed inset-0 z-50 flex items-end bg-black/50" onClick={onClose}>
<div
className="animate-in-smooth safe-bottom max-h-[85dvh] w-full overflow-y-auto rounded-t-3xl border-t border-border bg-card px-5 pt-5"
onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold">Settings</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close settings">
<X />
</Button>
</div>
<div className="mb-4 flex items-center justify-between gap-4 rounded-xl border border-border bg-muted/60 px-4 py-3">
<span className="text-sm text-muted-foreground">Paired with</span>
<code className="font-mono text-xs">{truncateId(endpointId)}</code>
</div>
{capabilities?.modelName && (
<div className="mb-5 flex items-center justify-between gap-4 rounded-xl border border-border bg-muted/60 px-4 py-3">
<span className="text-sm text-muted-foreground">Model</span>
<code className="font-mono text-xs break-all text-right">{capabilities.modelName}</code>
</div>
)}
<div className="mb-6">
<span className="eyebrow mb-2 block">Language</span>
{!hasLanguages ? (
<p className="text-sm text-muted-foreground">
The desktop has not reported any languages yet. Load a model in Vibe, then re-check.
</p>
) : (
<>
<LanguagePicker capabilities={capabilities} value={lang} onChange={onLangChange} />
<p className="mt-2 text-xs text-muted-foreground">
{canAuto
? 'Auto-detect lets the model work out the spoken language.'
: 'This model cannot detect the language, so pick one explicitly.'}
</p>
</>
)}
</div>
<Button variant="destructive" className="h-12 w-full" onClick={onUnpair}>
<Link2Off />
Unpair this phone
</Button>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '~/lib/style'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '~/lib/style'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold cursor-pointer transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input/75 bg-card text-foreground shadow-xs hover:bg-accent/65',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/86',
ghost: 'hover:bg-accent/65 hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-11 px-4 py-2 text-sm',
sm: 'h-9 rounded-lg px-3 text-sm',
lg: 'h-12 rounded-xl px-8 text-lg',
icon: 'h-10 w-10',
iconSm: 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
})
Button.displayName = 'Button'
export { Button, buttonVariants }
+35
View File
@@ -0,0 +1,35 @@
import * as React from 'react'
import { cn } from '~/lib/style'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-md border border-border/70 bg-card text-card-foreground shadow-xs', className)} {...props} />
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '~/lib/style'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>>(
({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in-smooth data-[state=closed]:animate-out-smooth origin-[--radix-popover-content-transform-origin]',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
)
PopoverContent.displayName = PopoverPrimitive.Content.displayName
const PopoverAnchor = PopoverPrimitive.Anchor
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+20
View File
@@ -0,0 +1,20 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '~/lib/style'
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>>(
({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root ref={ref} className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)} {...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
),
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+5
View File
@@ -0,0 +1,5 @@
import { cn } from '~/lib/style'
export function Spinner({ className }: { className?: string }) {
return <div className={cn('h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent', className)} />
}
+505
View File
@@ -0,0 +1,505 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
--font-serif: 'Inter Variable', 'Inter', -apple-system, 'Helvetica Neue', Arial, sans-serif;
--radius: 1rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
:root {
--radius: 1rem;
/* ChatGPT-style neutrals — light (default) */
--background: #ffffff;
--foreground: #1a1c1f;
--card: #ffffff;
--card-foreground: #1a1c1f;
--popover: #ffffff;
--popover-foreground: #1a1c1f;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #f4f4f4;
--secondary-foreground: #1a1c1f;
--muted: #f4f4f4;
--muted-foreground: #6e6e6e;
--accent: #f4f4f4;
--accent-foreground: #1a1c1f;
--destructive: #b4453c;
--destructive-foreground: #fdf6f5;
--success: #3f7d5f;
--success-foreground: #f3faf6;
--border: #e6e6e6;
--input: #e6e6e6;
--ring: rgb(37 99 235 / 0.35);
/* Aurora hues — the only chroma in the system */
--aurora-1: #5b8def;
--aurora-2: #b98de3;
--aurora-3: #e8a87c;
--aurora-4: #7cc5a0;
--chart-1: var(--aurora-1);
--chart-2: var(--aurora-2);
--chart-3: var(--aurora-3);
--chart-4: var(--aurora-4);
--chart-5: #6f6f6a;
--sidebar: #f9f9f9;
--sidebar-foreground: #1a1c1f;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f1ee;
--sidebar-accent-foreground: #111110;
--sidebar-border: #e7e7e2;
--sidebar-ring: rgb(17 17 16 / 0.2);
--shadow-2xs: 0 1px 1px rgb(17 17 16 / 0.04);
--shadow-xs: 0 1px 2px rgb(17 17 16 / 0.05);
--shadow-sm: 0 2px 8px rgb(17 17 16 / 0.04);
--shadow: 0 6px 18px rgb(17 17 16 / 0.05);
--shadow-md: 0 10px 26px rgb(17 17 16 / 0.06);
--shadow-lg: 0 16px 36px rgb(17 17 16 / 0.08);
--shadow-xl: 0 24px 56px rgb(17 17 16 / 0.1);
--shadow-2xl: 0 32px 72px rgb(17 17 16 / 0.12);
--tracking-normal: -0.01em;
}
.dark {
--background: #181818;
--foreground: #ececec;
--card: #212121;
--card-foreground: #ececec;
--popover: #2a2a2a;
--popover-foreground: #ececec;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #242424;
--secondary-foreground: #ececec;
--muted: #242424;
--muted-foreground: #9a9a9a;
--accent: #2a2a2a;
--accent-foreground: #ececec;
--destructive: #c4655c;
--destructive-foreground: #1a0f0e;
--success: #6aab8b;
--success-foreground: #0d1512;
--border: #303030;
--input: #303030;
--ring: rgb(51 156 255 / 0.4);
--aurora-1: #5b8def;
--aurora-2: #b98de3;
--aurora-3: #e8a87c;
--aurora-4: #7cc5a0;
--chart-5: #9a9a94;
--sidebar: #212121;
--sidebar-foreground: #ececec;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #2a2a2a;
--sidebar-accent-foreground: #ececec;
--sidebar-border: #303030;
--sidebar-ring: rgb(51 156 255 / 0.4);
--shadow-2xs: 0 1px 1px rgb(0 0 0 / 0.3);
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.35);
--shadow-sm: 0 2px 10px rgb(0 0 0 / 0.4);
--shadow: 0 8px 22px rgb(0 0 0 / 0.45);
--shadow-md: 0 14px 32px rgb(0 0 0 / 0.5);
--shadow-lg: 0 22px 46px rgb(0 0 0 / 0.55);
--shadow-xl: 0 30px 64px rgb(0 0 0 / 0.6);
--shadow-2xl: 0 40px 80px rgb(0 0 0 / 0.65);
}
@layer base {
html,
body,
#root {
height: 100%;
}
html {
font-family:
'Inter Variable',
'Inter',
-apple-system,
BlinkMacSystemFont,
'SF Pro Text',
'Helvetica Neue',
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground;
font-size: 14px;
line-height: 1.55;
letter-spacing: -0.01em;
font-feature-settings:
'rlig' 1,
'calt' 1,
'cv05' 1;
background-image: none;
overflow-x: hidden;
}
h1,
h2,
h3 {
letter-spacing: -0.03em;
}
html,
body {
scrollbar-width: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
}
.dark body {
background-image: none;
}
* {
border-color: var(--border);
}
::selection {
background: rgb(17 17 16 / 0.14);
}
.dark ::selection {
background: rgb(244 244 241 / 0.2);
}
::-webkit-scrollbar {
height: 6px;
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(17 17 16 / 0.14);
border-radius: 999px;
}
:hover::-webkit-scrollbar-thumb {
background: rgb(17 17 16 / 0.22);
}
.dark ::-webkit-scrollbar-thumb {
background: rgb(244 244 241 / 0.14);
}
.dark :hover::-webkit-scrollbar-thumb {
background: rgb(244 244 241 / 0.22);
}
.transcript-editor {
scrollbar-width: auto;
scrollbar-color: rgb(17 17 16 / 0.28) rgb(17 17 16 / 0.05);
scrollbar-gutter: stable;
}
.transcript-editor::-webkit-scrollbar {
width: 16px !important;
height: 0 !important;
background: transparent !important;
}
.transcript-editor::-webkit-scrollbar-thumb {
background: rgb(17 17 16 / 0.28) !important;
border: 3px solid transparent !important;
background-clip: padding-box !important;
border-radius: 999px !important;
min-height: 96px !important;
}
.transcript-editor::-webkit-scrollbar-thumb:hover {
background: rgb(17 17 16 / 0.4) !important;
background-clip: padding-box !important;
}
.transcript-editor::-webkit-scrollbar-track {
background: transparent !important;
border-left: 1px solid var(--border) !important;
}
.dark .transcript-editor::-webkit-scrollbar-thumb {
background: rgb(244 244 241 / 0.24) !important;
border: 3px solid transparent !important;
background-clip: padding-box !important;
}
.dark .transcript-editor::-webkit-scrollbar-thumb:hover {
background: rgb(244 244 241 / 0.36) !important;
background-clip: padding-box !important;
}
.dark .transcript-editor::-webkit-scrollbar-track {
background: transparent !important;
border-left: 1px solid var(--border) !important;
}
.dark .transcript-editor {
scrollbar-color: rgb(244 244 241 / 0.24) transparent;
}
}
@layer components {
.app-shell {
@apply mx-auto w-full max-w-5xl px-5 pb-10 pt-3 md:px-8 md:pt-4;
}
.app-hero {
@apply relative mb-5 rounded-xl border border-border bg-card px-6 py-5;
}
.app-panel {
@apply rounded-xl border border-border bg-card p-5 md:p-6;
}
.app-subtle {
@apply rounded-lg border border-border bg-muted/60 p-3;
}
.dark .app-hero,
.dark .app-panel {
@apply bg-card text-card-foreground;
background-image: none;
}
.dark .app-subtle {
@apply bg-muted text-card-foreground;
}
.app-title {
@apply text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[40px];
}
/* Eyebrow label — 11px, uppercase, +0.08em */
.app-kicker,
.eyebrow {
@apply text-[11px] font-medium uppercase leading-none tracking-[0.08em] text-muted-foreground;
}
.app-main-card {
@apply rounded-2xl border border-border bg-card p-6 shadow-sm md:p-9;
}
.dark .app-main-card {
@apply bg-card shadow-md;
background-image: none;
}
/*
* Aurora — the single expressive surface. Soft multi-hue gradient plus a
* grain overlay. Always behind a card or as a thin fill, never behind body text.
*/
.aurora {
position: relative;
isolation: isolate;
background-color: var(--muted);
background-image:
radial-gradient(58% 72% at 14% 16%, #5b8def8c 0%, transparent 68%), radial-gradient(54% 68% at 84% 10%, #b98de37a 0%, transparent 70%),
radial-gradient(58% 66% at 80% 88%, #e8a87c73 0%, transparent 70%), radial-gradient(62% 70% at 18% 92%, #7cc5a073 0%, transparent 70%);
background-repeat: no-repeat;
filter: saturate(0.85);
}
.aurora::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
opacity: 0.28;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
}
.dark .aurora {
filter: saturate(0.7) brightness(0.8);
}
/* Thin aurora fill for progress bars */
.aurora-bar {
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
filter: saturate(0.85);
}
/* Radix <Progress> whose indicator is filled with the aurora gradient */
.progress-aurora > * {
background-color: transparent;
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
filter: saturate(0.85);
}
.stagger-in {
animation: fade-up 240ms ease-out both;
}
}
/* ---- phone-specific base ---------------------------------------------- */
@layer base {
html,
body,
#root {
min-height: 100%;
height: auto;
}
body {
/* Mobile-first: slightly larger base text than the desktop app. */
font-size: 15px;
overscroll-behavior-y: none;
-webkit-tap-highlight-color: transparent;
}
button,
select,
a {
touch-action: manipulation;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes record-pulse {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--destructive) 45%, transparent);
}
50% {
box-shadow: 0 0 0 22px color-mix(in srgb, var(--destructive) 0%, transparent);
}
}
@layer utilities {
.record-pulse {
animation: record-pulse 1.6s ease-in-out infinite;
}
.safe-top {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.safe-bottom {
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
}
@media (prefers-reduced-motion: reduce) {
.record-pulse,
.stagger-in {
animation: none;
}
}
/* Indeterminate bar: a short segment sweeping its track, for work whose
duration the desktop cannot report (loading a model into Sona). Ported from
the desktop app's download bar so the two read the same. */
@keyframes handoff-sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
.handoff-sweep {
animation: handoff-sweep 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.handoff-sweep {
animation: none;
transform: translateX(0);
width: 100% !important;
opacity: 0.5;
}
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Wire types and the wasm bridge.
*
* The wasm bundle is produced by the `handoff-wasm` crate and dropped into
* `pwa/public/wasm/`. Vite copies `public/` verbatim, so we load it with an
* explicit runtime import of an absolute URL — never a bundler-resolved one —
* and rebuilding the crate is picked up without touching the app.
*/
export const PEER_KEY = 'vibe.handoff.peer'
export const LANG_KEY = 'vibe.handoff.lang'
// Rebased on the deploy base: the app is served from a subpath on GitHub
// Pages (`/vibe/phone/`), so a root-absolute `/wasm/...` would 404. BASE_URL
// always carries a trailing slash, and `new URL(..., document.baseURI)`
// resolves it against the real document location.
const WASM_JS_URL = new URL(`${import.meta.env.BASE_URL}wasm/handoff_wasm.js`, document.baseURI).href
const WASM_BIN_URL = new URL(`${import.meta.env.BASE_URL}wasm/handoff_wasm_bg.wasm`, document.baseURI).href
export interface Peer {
endpointId: string
token: string
}
/**
* Reply to `op: "capabilities"`. The desktop owns this knowledge because it
* depends on the model currently loaded there; the phone must never guess it.
*/
export interface Capabilities {
type: 'capabilities'
modelLoaded: boolean
modelName: string | null
languages: string[]
languageDetection: boolean
/** Parsed to mirror the wire contract; the phone does not offer translation. */
translation: boolean
/** The desktop's real audio cap in bytes. 0 or absent means "no known limit". */
maxAudioBytes?: number
}
export interface HandoffError {
type: 'error'
code: string
message: string
}
export type CapabilitiesResult = Capabilities | HandoffError
export type HandoffEvent =
| { type: 'uploadProgress'; sent: number; total: number }
| { type: 'accepted' }
/**
* Non-terminal progress phase. `phase` is deliberately a bare string: more
* phases may be added later and an unknown one must not break the UI.
*/
| { type: 'status'; phase: string }
| { type: 'progress'; progress: number }
| { type: 'segment'; start: number; stop: number; text: string; speaker: number | null }
| { type: 'done'; text: string; processingTimeSec?: number; savedPath?: string }
| { type: 'error'; code: string; message: string }
interface HandoffClient {
endpoint_id(): string
fetch_capabilities(endpointId: string, token: string): Promise<unknown>
send_recording(
endpointId: string,
token: string,
filename: string,
mime: string,
lang: string | null | undefined,
translate: boolean,
audio: Uint8Array
): ReadableStream
}
interface WasmModule {
default: (init?: unknown) => Promise<unknown>
HandoffClient: { create(): Promise<HandoffClient> }
}
let clientPromise: Promise<HandoffClient> | null = null
/** Bind the browser iroh endpoint once and reuse it for every send. */
export async function getClient(): Promise<HandoffClient> {
if (!clientPromise) {
clientPromise = (async () => {
const mod = (await import(/* @vite-ignore */ WASM_JS_URL)) as WasmModule
await mod.default({ module_or_path: new URL(WASM_BIN_URL) })
return await mod.HandoffClient.create()
})().catch((err) => {
clientPromise = null
throw err
})
}
return clientPromise
}
/**
* Events cross the wasm boundary as plain JS objects, but be liberal: a string
* or byte chunk is parsed as JSON so a change on the Rust side cannot silently
* break the UI.
*/
export function normalizeEvent(value: unknown): HandoffEvent | null {
if (value == null) return null
if (typeof value === 'string') {
try {
return JSON.parse(value) as HandoffEvent
} catch {
return null
}
}
if (value instanceof Uint8Array) {
try {
return JSON.parse(new TextDecoder().decode(value)) as HandoffEvent
} catch {
return null
}
}
if (value instanceof Map) return Object.fromEntries(value) as unknown as HandoffEvent
if (typeof value === 'object') return value as HandoffEvent
return null
}
/**
* Ask the desktop what it can do. Never falls back to a locally assumed answer:
* a failure here is reported so the user can retry, because guessing the
* language list is exactly what this round trip exists to avoid.
*/
export async function fetchCapabilities(peer: Peer): Promise<CapabilitiesResult> {
let client: HandoffClient
try {
client = await getClient()
} catch (err) {
return { type: 'error', code: 'wasm', message: err instanceof Error ? err.message : String(err) }
}
try {
const raw = await client.fetch_capabilities(peer.endpointId, peer.token)
const parsed = normalizeEvent(raw) as CapabilitiesResult | null
if (!parsed) return { type: 'error', code: 'protocol', message: 'The desktop sent an unreadable capabilities reply.' }
if (parsed.type === 'capabilities' || parsed.type === 'error') return parsed
return { type: 'error', code: 'protocol', message: 'Unexpected reply to the capabilities request.' }
} catch (err) {
return { type: 'error', code: 'transport', message: err instanceof Error ? err.message : String(err) }
}
}
/* ------------------------------------------------------------------ pairing */
export function loadPeer(): Peer | null {
try {
const raw = localStorage.getItem(PEER_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<Peer>
if (typeof parsed?.endpointId === 'string' && typeof parsed?.token === 'string') {
return { endpointId: parsed.endpointId, token: parsed.token }
}
} catch {
/* corrupt storage — treat as unpaired */
}
return null
}
export function savePeer(peer: Peer): void {
try {
localStorage.setItem(PEER_KEY, JSON.stringify(peer))
} catch {
/* private mode */
}
}
export function clearPeer(): void {
try {
localStorage.removeItem(PEER_KEY)
} catch {
/* ignore */
}
}
/** Pairing URL is `<origin>/#<endpointId>:<token>` — 64 hex, then 32 hex. */
export function parsePairingHash(hash: string): Peer | null {
const raw = hash.replace(/^#/, '').trim()
if (!raw) return null
const idx = raw.indexOf(':')
if (idx <= 0) return null
const endpointId = raw.slice(0, idx).trim().toLowerCase()
const token = raw.slice(idx + 1).trim()
if (!/^[0-9a-f]{64}$/.test(endpointId)) return null
if (!/^[0-9a-f]{32}$/.test(token)) return null
return { endpointId, token }
}
export function truncateId(id: string): string {
return id.length > 16 ? `${id.slice(0, 8)}${id.slice(-4)}` : id
}
/**
* Last path component of a desktop filesystem path. The full absolute path is
* meaningless on a phone screen, so the done state shows only this.
*/
export function basename(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : path
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Language *display*, not language *knowledge*.
*
* The set of supported languages belongs to the desktop — it depends on the
* model the user has loaded, so the phone asks for it (`op: "capabilities"`)
* and never carries a table of its own. What the phone does own is turning the
* raw whisper codes the desktop sends into names a human can read, which
* `Intl.DisplayNames` does in the browser's own locale for free.
*/
/** Sentinel for the Radix select, which cannot hold an empty-string value. */
export const AUTO_LANG = 'auto'
let displayNames: Intl.DisplayNames | null | undefined
function getDisplayNames(): Intl.DisplayNames | null {
if (displayNames === undefined) {
try {
displayNames = new Intl.DisplayNames(navigator.languages ?? [navigator.language], {
type: 'language',
fallback: 'none',
})
} catch {
displayNames = null
}
}
return displayNames
}
/** Human-readable name for a whisper language code, falling back to the code itself. */
export function languageLabel(code: string): string {
const names = getDisplayNames()
if (names) {
try {
const label = names.of(code)
if (label) return label[0].toUpperCase() + label.slice(1)
} catch {
/* malformed code — fall through */
}
}
return code
}
let englishNames: Intl.DisplayNames | null | undefined
/**
* English name for a code, used as a search keyword alongside the localized
* label so someone typing "german" still finds "Deutsch" — the same trick the
* desktop picker uses.
*/
export function englishLanguageLabel(code: string): string {
if (englishNames === undefined) {
try {
englishNames = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' })
} catch {
englishNames = null
}
}
if (englishNames) {
try {
const label = englishNames.of(code)
if (label) return label
} catch {
/* malformed code */
}
}
return code
}
/* ------------------------------------------------------------ recent list */
const RECENT_KEY = 'vibe.handoff.recentLangs'
const RECENT_MAX = 5
/**
* Recently-picked codes, most recent first. Stored as a plain ordered list, so
* the "recent" group needs no timestamps and therefore no date library.
*/
export function loadRecentLanguages(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter((code): code is string => typeof code === 'string').slice(0, RECENT_MAX)
} catch {
/* corrupt or unavailable storage */
}
return []
}
/** Move `code` to the front of the recent list and persist it. */
export function rememberLanguage(code: string): string[] {
const next = [code, ...loadRecentLanguages().filter((entry) => entry !== code)].slice(0, RECENT_MAX)
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch {
/* private mode */
}
return next
}
+73
View File
@@ -0,0 +1,73 @@
/**
* MediaRecorder container negotiation.
*
* Safari supports none of the Opus containers — it records `audio/mp4` (AAC).
* Chrome/Firefox prefer WebM/Opus. So probe in preference order and, if nothing
* reports support, construct the recorder with no `mimeType` at all and take
* whatever the browser produces. We never synthesise WAV; the desktop side gets
* the real `blob.type` and a filename whose extension matches it.
*/
export const MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'] as const
const EXT_BY_MIME: Array<[RegExp, string]> = [
[/^audio\/mp4$/, 'm4a'],
[/^audio\/x-m4a$/, 'm4a'],
[/^audio\/aac$/, 'aac'],
[/^audio\/webm$/, 'webm'],
[/^audio\/ogg$/, 'ogg'],
[/^audio\/opus$/, 'opus'],
[/^video\/mp4$/, 'mp4'],
[/^audio\/mpeg$/, 'mp3'],
[/^audio\/wav$/, 'wav'],
[/^audio\/x-wav$/, 'wav'],
]
/** Best supported container, or `null` to let the browser pick its default. */
export function pickMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null
if (typeof MediaRecorder.isTypeSupported !== 'function') return null
for (const candidate of MIME_CANDIDATES) {
try {
if (MediaRecorder.isTypeSupported(candidate)) return candidate
} catch {
/* some engines throw on unknown type strings */
}
}
return null
}
export function extForMime(mime: string): string {
const base = String(mime || '')
.split(';')[0]
.trim()
.toLowerCase()
for (const [pattern, ext] of EXT_BY_MIME) if (pattern.test(base)) return ext
return 'bin'
}
export function filenameFor(mime: string): string {
return `recording.${extForMime(mime)}`
}
export function canRecord(): boolean {
return (
typeof MediaRecorder !== 'undefined' &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+10
View File
@@ -0,0 +1,10 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function cx(...cns: (boolean | string | undefined)[]): string {
return cns.filter(Boolean).join(' ')
}
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react'
const DISMISSED_KEY = 'vibe.handoff.installDismissed'
/** The Chromium-only event; not in lib.dom, so declare the shape we use. */
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export type InstallMode = 'none' | 'prompt' | 'ios-manual'
/** Already running as an installed app? Then there is nothing to suggest. */
export function isStandalone(): boolean {
const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true
const displayMode = typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches
return iosStandalone || displayMode
}
function isIosSafari(): boolean {
const ua = navigator.userAgent
// iPadOS 13+ reports a desktop UA, so also treat a touch-capable "Mac" as iOS.
const ios = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
if (!ios) return false
// Chrome/Firefox/Edge on iOS cannot install to the home screen at all.
return !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua)
}
/**
* Home-screen install affordance.
*
* Chromium fires `beforeinstallprompt`, which we capture and replay behind our
* own control. iOS Safari has no such event — installing is a manual Share →
* Add to Home Screen gesture — so there we show instructions instead.
*/
export function useInstall() {
const [mode, setMode] = useState<InstallMode>('none')
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => {
if (isStandalone()) return
let dismissed = false
try {
dismissed = localStorage.getItem(DISMISSED_KEY) === '1'
} catch {
/* private mode */
}
if (dismissed) return
const onBeforeInstall = (event: Event) => {
event.preventDefault()
setDeferred(event as BeforeInstallPromptEvent)
setMode('prompt')
}
const onInstalled = () => {
setMode('none')
setDeferred(null)
try {
localStorage.setItem(DISMISSED_KEY, '1')
} catch {
/* private mode */
}
}
window.addEventListener('beforeinstallprompt', onBeforeInstall)
window.addEventListener('appinstalled', onInstalled)
if (isIosSafari()) setMode('ios-manual')
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstall)
window.removeEventListener('appinstalled', onInstalled)
}
}, [])
/** Never nag: a dismissal is remembered for good. */
const dismiss = useCallback(() => {
setMode('none')
setDeferred(null)
try {
localStorage.setItem(DISMISSED_KEY, '1')
} catch {
/* private mode */
}
}, [])
const install = useCallback(async () => {
if (!deferred) return
await deferred.prompt()
const { outcome } = await deferred.userChoice
if (outcome === 'dismissed') dismiss()
else {
setMode('none')
setDeferred(null)
}
}, [deferred, dismiss])
return { mode, install, dismiss }
}
/**
* Ask the browser to keep our storage. The pairing lives entirely in
* localStorage, and WebKit evicts script-written storage from origins that go
* unused. Persistence is granted on heuristics — being an installed home-screen
* web app is one of them — so this is best-effort and never blocks anything.
*/
export async function requestPersistentStorage(): Promise<void> {
try {
if (navigator.storage?.persist && navigator.storage.persisted) {
if (!(await navigator.storage.persisted())) await navigator.storage.persist()
}
} catch {
/* unsupported or refused — nothing to do */
}
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef } from 'react'
/**
* Screen wake lock for the whole handoff operation — recording, upload, and the
* wait for the desktop's transcript. Transcription on a large model runs for
* tens of seconds, and on iOS a sleeping screen suspends the page and drops the
* relay connection, so the lock must outlive the recording itself.
*
* Two facts drive the shape of this hook:
* - The spec releases the lock whenever the document becomes hidden, and never
* restores it. So we track whether a lock is still *wanted* and re-acquire on
* the way back to visible.
* - `request()` rejects when the document is hidden, on low battery, and in
* browsers without support. None of that may break a recording, so every
* call is guarded and failure is silent.
*/
export function useWakeLock() {
const lockRef = useRef<WakeLockSentinel | null>(null)
const wantedRef = useRef(false)
const acquire = useCallback(async () => {
wantedRef.current = true
if (lockRef.current) return
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
try {
if (!navigator.wakeLock?.request) return
const lock = await navigator.wakeLock.request('screen')
// Released while we were awaiting: honour that, do not leak the lock.
if (!wantedRef.current) {
void lock.release()
return
}
lockRef.current = lock
lock.addEventListener('release', () => {
if (lockRef.current === lock) lockRef.current = null
})
} catch {
lockRef.current = null
}
}, [])
const release = useCallback(() => {
wantedRef.current = false
const lock = lockRef.current
lockRef.current = null
if (lock) {
try {
void lock.release()
} catch {
/* already released */
}
}
}, [])
/** After the page becomes visible again, take the lock back if still needed. */
const reacquireIfWanted = useCallback(() => {
if (wantedRef.current && !lockRef.current) void acquire()
}, [acquire])
/** Test/diagnostic view of the current state. */
const isHeld = useCallback(() => lockRef.current !== null, [])
// A leaked screen lock is its own bug: drop it if we unmount mid-operation.
useEffect(() => {
return () => {
wantedRef.current = false
const lock = lockRef.current
lockRef.current = null
if (lock) {
try {
void lock.release()
} catch {
/* already released */
}
}
}
}, [])
return { acquire, release, reacquireIfWanted, isHeld }
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Toaster } from 'sonner'
import { App } from '~/App'
import '~/globals.css'
// The desktop app follows the OS theme; a phone PWA has no theme switcher, so
// mirror `prefers-color-scheme` onto the `.dark` class the tokens key off.
function syncTheme() {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => document.documentElement.classList.toggle('dark', media.matches)
apply()
media.addEventListener('change', apply)
}
syncTheme()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<Toaster position="top-center" richColors closeButton />
</React.StrictMode>
)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// Registered at the deploy base, not the root. A worker at
// `/vibe/phone/sw.js` gets scope `/vibe/phone/` — exactly the app's
// subtree, and nothing of the website around it.
const base = import.meta.env.BASE_URL
navigator.serviceWorker.register(`${base}sw.js`, { scope: base }).catch(() => {
/* installability is a nice-to-have, never fatal */
})
})
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"paths": {
"~/*": ["./src/*"]
},
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
/**
* The PWA is deployed inside the Vibe website's GitHub Pages artifact, at
* `https://thewh1teagle.github.io/vibe/phone/`. Nothing in the app may assume
* it lives at the domain root: every runtime URL is rebased on
* `import.meta.env.BASE_URL`, and the manifest/service worker use relative
* paths so they resolve against wherever they happen to be served from.
*
* `PWA_BASE` overrides the production base (must keep the leading and
* trailing slash). The dev server stays at `/` so `http://localhost:8088/`
* works unchanged.
*/
const PROD_BASE = process.env.PWA_BASE ?? '/vibe/phone/'
export default defineConfig(({ command }) => ({
base: command === 'serve' ? '/' : PROD_BASE,
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'~': '/src',
},
},
server: {
port: 8088,
strictPort: true,
host: true,
},
preview: {
port: 8088,
strictPort: true,
host: true,
},
}))