diff --git a/android/app/src/main/java/app/efir/android/LocalServer.kt b/android/app/src/main/java/app/efir/android/LocalServer.kt index 6839f7a..c55790d 100644 --- a/android/app/src/main/java/app/efir/android/LocalServer.kt +++ b/android/app/src/main/java/app/efir/android/LocalServer.kt @@ -6,7 +6,7 @@ import java.io.ByteArrayInputStream import java.io.IOException import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors +import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong import org.json.JSONObject @@ -20,9 +20,9 @@ class LocalServer( ) : NanoWSD(address, port) { private val clients = ConcurrentHashMap.newKeySet() private val deadline = - Executors.newScheduledThreadPool(1) { task -> + ScheduledThreadPoolExecutor(1) { task -> Thread(task, "Efir socket deadline").apply { isDaemon = true } - } + }.apply { setRemoveOnCancelPolicy(true) } val clientCount get() = clients.size diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 48ab301..847573b 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -9,7 +9,10 @@ use serde_json::{json, Value}; use std::{ collections::HashMap, path::PathBuf, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, time::Duration, }; use tauri::{ @@ -61,8 +64,10 @@ fn js(window: &WebviewWindow, script: &str, args: Value) { } fn translate(state: &State, source: &str) -> String { let lang = state.prefs.lock().unwrap().language.clone(); - let catalogue: Value = - serde_json::from_str(include_str!("../../web/locales/messages.json")).unwrap_or_default(); + static CATALOGUE: OnceLock = OnceLock::new(); + let catalogue = CATALOGUE.get_or_init(|| { + serde_json::from_str(include_str!("../../web/locales/messages.json")).unwrap_or_default() + }); let index = catalogue["languages"] .as_array() .and_then(|list| list.iter().position(|code| code == &lang)) @@ -281,7 +286,22 @@ fn main() { .on_download(|_,event| { if let DownloadEvent::Requested {destination,..}=event { let name=destination.file_name().unwrap_or_default().to_string_lossy().into_owned();if let Some(path)=rfd::FileDialog::new().set_file_name(name).save_file(){*destination=path;}else{return false;} }true }) .build()?; let closed=window.clone(); let app_for_close=app.handle().clone(); - window.on_window_event(move |event| if let WindowEvent::CloseRequested {api,..}=event { api.prevent_close();app_for_close.state::().speech.lock().unwrap().stop();js(&closed,"() => { window.efirNative?.onState?.('off'); window.efirNative?.onVisible?.(false); }",Value::Null);let _=closed.hide(); }); + let visible = AtomicBool::new(true); + window.on_window_event(move |event| match event { + WindowEvent::CloseRequested {api,..} => { + api.prevent_close(); visible.store(false, Ordering::Relaxed); + app_for_close.state::().speech.lock().unwrap().stop(); + js(&closed,"() => { window.efirNative?.onState?.('off'); window.efirNative?.onVisible?.(false); }",Value::Null); + let _=closed.hide(); + } + WindowEvent::Resized(_) | WindowEvent::Focused(_) => { + let active = closed.is_visible().unwrap_or(true) && !closed.is_minimized().unwrap_or(false); + if visible.swap(active, Ordering::Relaxed) != active { + js(&closed,"active => window.efirNative?.onVisible?.(active)",json!(active)); + } + } + _ => {} + }); TrayIconBuilder::with_id("efir").icon(app.default_window_icon().unwrap().clone()).tooltip("Efir").on_menu_event(|app,event|match event.id().as_ref(){"open"=>show(app),"quit"=>app.exit(0),_=>{}}).build(app)?; rebuild_menu(app.handle()).map_err(std::io::Error::other)?; let handle=app.handle().clone();tauri::async_runtime::spawn(async move { diff --git a/desktop/src/server.rs b/desktop/src/server.rs index 264defe..f9302c6 100644 --- a/desktop/src/server.rs +++ b/desktop/src/server.rs @@ -21,6 +21,7 @@ use std::{ time::{Duration, UNIX_EPOCH}, }; use tokio::{ + io::{AsyncReadExt, AsyncSeekExt}, net::TcpListener, sync::{mpsc, watch}, }; @@ -339,10 +340,26 @@ async fn site(State(site): State, method: Method, uri: Uri, headers: Heade let body = if method == Method::HEAD { Body::empty() } else { - match tokio::fs::read(file).await { - Ok(bytes) => Body::from(bytes[start as usize..(start + len) as usize].to_vec()), - Err(_) => return StatusCode::NOT_FOUND.into_response(), + let Ok(mut input) = tokio::fs::File::open(file).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + if input.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return StatusCode::NOT_FOUND.into_response(); } + // Poll-driven chunks follow HTTP backpressure. A Range request reads + // only its slice, and slow downloads do not retain whole asset copies. + Body::from_stream(futures_util::stream::try_unfold( + (input, len), + |(mut input, remaining)| async move { + if remaining == 0 { + return Ok::<_, std::io::Error>(None); + } + let mut chunk = vec![0; remaining.min(64 * 1024) as usize]; + input.read_exact(&mut chunk).await?; + let remaining = remaining - chunk.len() as u64; + Ok(Some((chunk, (input, remaining)))) + }, + )) }; let mut result = response( if partial { diff --git a/docs/native-resource-audit.md b/docs/native-resource-audit.md new file mode 100644 index 0000000..6ee3b7a --- /dev/null +++ b/docs/native-resource-audit.md @@ -0,0 +1,28 @@ +# Native resource changes + +Checked October 12, 2026, against main `2dead9b`. These changes accompany the shared web resource audit and keep the existing relay and prompting behavior. + +| Platform | Finding | Change | +| --- | --- | --- | +| Windows / Linux server | GET reads an entire asset, then copies its requested range; large slow downloads retain whole buffers. | Seek to the requested offset and stream up to 64 KiB at a time under HTTP backpressure. HEAD, ETag, Content-Length, MIME and byte ranges remain intact. No new dependency. | +| Windows / Linux shell | The full translation JSON is parsed for each native menu or speech status translation. | Initialize one read-only catalogue with OnceLock; continue reading the current language on each call. | +| Windows / Linux shell | Explicit WebView visibility is sent on tray hide/reopen, but minimized-window transitions have no adapter message. | Report actual visibility/minimization on focus and resize events, only when that state changes. Losing focus while still visible keeps previews active. | +| Android server | Each send has a necessary five-second watchdog, but cancelled watchdog tasks stay in the scheduled executor queue until their deadline. | Enable remove-on-cancel on the same single executor. Retain socket deadlines, bounded packet/byte queues, idle-client expiry and shutdown behavior. | +| macOS speech | The microphone/speech state is delivered repeatedly even when unchanged, including one-second listening timer updates. | Send changed state/message pairs only. Reset for a new recognition session; preserve transcripts, final results, retry logic, rereading and microphone release. | + +## Verification + +- macOS app builds through the existing `script/build_and_run.sh --build`; this does not launch or replace the personal running app. +- The full Tauri shell checks locally with Cargo on macOS, and the production Rust relay builds/runs through `tests/RustServerHarness`. +- HTTP/WebSocket tests run against the original and changed Rust servers. Asset bytes are compared with a local fixture; supported ranges cover prefix, open-ended, suffix and unsatisfiable requests. Exact relay, no echo, fragmentation, shared-session semantics, conditional requests and HEAD remain covered. +- An additional 64 MiB patterned fixture verifies range bytes across a 64 KiB chunk boundary. +- Four concurrent paused downloads of that fixture produced these process RSS samples on the same Mac, using debug server binaries and the same files: + +| Sample | Original | Streaming | +| --- | ---: | ---: | +| Initial RSS | 8.2 MiB | 8.3 MiB | +| Peak RSS with four slow downloads | 393.3 MiB | 12.5 MiB | + +This is a deliberately large stress asset, not a typical bundled font. The numbers measure process memory, not battery use or all operating systems. Small real assets benefit from avoiding whole-file/range copies, but the exact RSS reduction depends on workload and allocator. + +Android release/lint and Windows/Linux builds are run by the repository's normal PR CI matrix. Native minimize/restore behavior and actual Android executor/battery use still require physical or VM lifecycle checks; compilation and shared browser adapter tests do not certify those devices. Speech transcript matching and microphone processing are preserved, rather than reduced in quality or frequency. diff --git a/mac/Sources/NativeSpeech.swift b/mac/Sources/NativeSpeech.swift index f259b18..a943150 100644 --- a/mac/Sources/NativeSpeech.swift +++ b/mac/Sources/NativeSpeech.swift @@ -17,10 +17,13 @@ final class NativeSpeech { private var retry: Task? private var lang = "ru-RU" private var lastHeard = Date.distantPast + private var lastState = "" + private var lastMessage = "" func start(lang: String, in webView: WKWebView) { stop() self.webView = webView + lastState = "" self.lang = lang active = true let session = generation @@ -171,9 +174,13 @@ final class NativeSpeech { } private func state(_ value: String, _ message: String = "", _ values: [String: String] = [:]) { + let translated = l(message, values) + guard value != lastState || translated != lastMessage else { return } + lastState = value + lastMessage = translated webView?.callAsyncJavaScript( "window.efirNative?.onState(state, message)", - arguments: ["state": value, "message": l(message, values)], in: nil, in: .page, + arguments: ["state": value, "message": translated], in: nil, in: .page, completionHandler: nil) } } diff --git a/tests/relay.mjs b/tests/relay.mjs index 3ec908c..949f6ca 100644 --- a/tests/relay.mjs +++ b/tests/relay.mjs @@ -1,5 +1,6 @@ // Check a running local server using an isolated test session. import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; const base = process.env.EFIR_URL || 'http://localhost:8765'; const meta = await fetch(base + '/efir-local.json'); assert.equal((await meta.json()).local, true); @@ -20,8 +21,28 @@ const head = await fetch(base + '/js/net.js', { method: 'HEAD' }); assert.equal(head.headers.get('etag'), etag); assert.equal(head.headers.get('content-length'), asset.headers.get('content-length')); const manifest = await (await fetch(base + '/vendor/SOURCES.json')).json(); -const font = await fetch(base + '/vendor/' + manifest.find(item => item.file.endsWith('.woff2')).file); +const fontFile = manifest.find(item => item.file.endsWith('.woff2')).file; +const font = await fetch(base + '/vendor/' + fontFile); assert.ok(font.headers.get('cache-control').includes('immutable')); +// Verify actual asset bytes, then slices against the independent local fixture. +const bytes = new Uint8Array(await font.arrayBuffer()); +assert.deepEqual(bytes, new Uint8Array(await readFile(new URL('../web/vendor/' + fontFile, import.meta.url)))); +if (font.headers.get('accept-ranges') === 'bytes') { + const size = bytes.length; + for (const [range, start, end] of [ + ['bytes=0-31', 0, 31], + ['bytes=7-', 7, size - 1], + ['bytes=-17', size - 17, size - 1], + ]) { + const partial = await fetch(font.url, { headers: { Range: range } }); + assert.equal(partial.status, 206); + assert.equal(partial.headers.get('content-range'), `bytes ${start}-${end}/${size}`); + assert.deepEqual(new Uint8Array(await partial.arrayBuffer()), bytes.slice(start, end + 1)); + } + const invalid = await fetch(font.url, { headers: { Range: `bytes=${size}-` } }); + assert.equal(invalid.status, 416); + assert.equal(invalid.headers.get('content-range'), `bytes */${size}`); +} async function client(room) { const ws = new WebSocket(base.replace(/^http/, 'ws') + '/ws?room=' + room); const queue = [];