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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 Reed–Solomon ---- */
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Generated
+4056
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4188
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Executable
+40
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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": "Запис от телефона"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 phone’s 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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "הקלטה מהטלפון"
|
||||
}
|
||||
|
||||
@@ -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": "फ़ोन रिकॉर्डिंग"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "スマートフォンの録音"
|
||||
}
|
||||
|
||||
@@ -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": "휴대폰 녹음"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Запись с телефона"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "手机录音"
|
||||
}
|
||||
|
||||
@@ -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": "手機錄音"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Generated by handoff-wasm/build.sh (CI rebuilds it; see .github/workflows/website.yml)
|
||||
public/wasm
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+2088
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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 → Settings → 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 */
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user