Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions android/app/src/main/java/app/efir/android/LocalServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,9 +20,9 @@ class LocalServer(
) : NanoWSD(address, port) {
private val clients = ConcurrentHashMap.newKeySet<Client>()
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

Expand Down
28 changes: 24 additions & 4 deletions desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<Value> = 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))
Expand Down Expand Up @@ -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::<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::<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 {
Expand Down
23 changes: 20 additions & 3 deletions desktop/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use std::{
time::{Duration, UNIX_EPOCH},
};
use tokio::{
io::{AsyncReadExt, AsyncSeekExt},
net::TcpListener,
sync::{mpsc, watch},
};
Expand Down Expand Up @@ -339,10 +340,26 @@ async fn site(State(site): State<Site>, 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 {
Expand Down
28 changes: 28 additions & 0 deletions docs/native-resource-audit.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion mac/Sources/NativeSpeech.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ final class NativeSpeech {
private var retry: Task<Void, Never>?
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
Expand Down Expand Up @@ -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)
}
}
23 changes: 22 additions & 1 deletion tests/relay.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 = [];
Expand Down
Loading