3.1.0 — 2026-09-01
An intermediate release between 3.0.0 and the 4.0.0 split. It adds one new behaviour — the application now keeps a log — and otherwise closes findings from two audits, mine and an external review, preparing the ground for moving the core into its own process.
Highlights
- Logs. Levels, rotation, and a separate security log. Message texts are never written unless you explicitly ask for them.
- No shell, no bare names. The last
shell=Trueis gone; programs are launched by absolute path. - Speech no longer overlaps itself. One playback queue, one temporary file per answer.
- Commands left the interface thread. Typing a command no longer freezes the window while the model thinks.
- Nothing gets stuck. Test buttons, recognition failures and the command pipeline report what happened instead of going quiet.
Observability
Logging with levels. New core/logging_setup.py — DEBUG / INFO / WARNING / ERROR, rotating files (1 MB × 3) under %APPDATA%/RinaAssistant/logs/, level selectable in Settings → Diagnostics. Uncaught exceptions are recorded, including those in background threads — which previously killed the thread in silence and left the user looking at an action that simply never happened.
Message texts stay private. Only their length is logged unless "Log message texts" is deliberately switched on, so a log can be attached to a bug report without disclosing conversations. When the setting is on, the log says so on its first line.
A separate security log. security.log records what both audits had to reconstruct by hand: plugin installs and replacements, rejected plugin names, command imports (how many accepted, how many discarded), execution of power actions, requests to a non-local model address, and refusal of a dangerous action at the confirmation prompt.
Security
The last shell=True is gone. It lived in voice/commands.py, launching discord, calc, notepad, explorer and start chrome by bare name through the command interpreter. No user input reached it, so this was not command injection — but Windows searches the current directory, so a file placed next to the application would have run instead. Programs are now resolved to an absolute path (System32 → %SystemRoot% → PATH) and started without a shell. Verified by planting a decoy notepad.exe in the working directory: the real one still wins.
The browser fallback works again. start chrome could not survive removing the shell; if Chrome is not found, the default browser opens instead of nothing happening.
Store apps report honestly. Launching a UWP app always returned success, because explorer.exe says nothing about what became of shell:AppsFolder\<id> — and waiting for it is unsafe, since it becomes the desktop shell process when not already running. The identifier is now checked against the index before launching, and an unknown one fails without spawning anything.
A vanished program says so. Command targets and index entries are checked for existence before launching, and the answer names the cause — "Не нашла «foo.exe» — программу удалили или перенесли" — instead of "Не получилось выполнить команду". Bare names such as discord are deliberately left alone: Windows resolves them through the App Paths registry, and that is a legitimate way to write a command.
SECURITY.md. Private reporting through GitHub Security Advisories, plus an explicit description of the trust boundaries — most importantly that a plugin is ordinary Python with the application's rights, and there is no sandbox. Installing one is as consequential as running a downloaded program.
Reliability
Speech no longer overlaps itself. Playback went through no queue and three fixed temporary filenames (rina_gtts.mp3, rina_edge.mp3, rina_piper.wav), so two answers in quick succession overwrote each other's file and played at the same time. There is now a single playback worker, one uniquely named file per synthesis, deletion after playback, and a failed file no longer kills the worker.
The microphone no longer hears Rina. _speaking was one flag for the whole core, so with two overlapping answers the first to finish cleared it and the microphone opened under speech that was still playing. It is now a counter: the flag holds until the last speaker finishes.
Default settings are copied properly. dict(DEFAULTS) is a shallow copy, and more than a dozen defaults are mutable — plugin_settings, wake_words, action_hotkeys, app_aliases, program_folders, custom_commands. Changing a plugin setting wrote into the module-level defaults, so "reset" restored the already-modified value rather than the factory one.
Stores no longer lose writes. History, reminders, usage statistics and user commands all used read-modify-write: two threads read the same state and the second overwrote the first. All of them now run under one store-wide lock. Under load — four threads writing at once — 160 of 160 history entries, 60 of 60 reminders, and exactly 200 of 200 counter increments survive.
Test buttons come back. MicTester._worker had no try/finally: an exception left the button greyed out until restart. The model's "Check connection" button had the same shape, and http.client.HTTPException — which does not inherit from OSError — escaped the request handler entirely. Both now report the cause and return to a usable state.
Recognition failures are visible. _listen_worker had a finally but no except, so a failing engine cleared the indicator and killed the thread without a word.
Responsiveness
Commands run off the interface thread. Typed commands drove the entire pipeline in the GUI thread; with the language model enabled, that is several seconds of a frozen window. Commands now go through a single queue inside the core — the window stays live, ordering is preserved, and an exception in one command no longer strands every command behind it. Always-listen still waits for completion, because it must not listen while Rina is answering.
Hotkeys fire once. Holding a combination produces key auto-repeat, and every repeat was reported as a fresh activation. A 300 ms suppression window per combination fixes it.
Theme switching is safe. Pages were destroyed from inside the theme-change signal handler — while that signal was still being delivered to those same pages. The rebuild is now deferred to the next event-loop turn and guarded against re-entry. The tray menu is rebuilt on theme and language change instead of keeping the old palette and the old language, and its old actions are released rather than accumulated.
Plugins load after Qt exists. plugin_manager.discover() ran before QApplication was constructed. It only reads manifests today, but it also loads the code of enabled plugins — and a plugin touching Qt at that moment would have crashed the launch with nothing to tell the user.
Parsing
Compound durations. "Поставь таймер на 1 час 30 минут" was read as one hour, and the user found out ninety minutes later. All number-unit pairs are now summed: 5400 seconds for that phrase, 8130 for "2 часа 15 минут 30 секунд".
Percentages only when asked. Percentage parsing ran before the check that the phrase was addressed to the calculator at all, so ordinary speech containing "20 процентов от 3000" was answered with arithmetic. It now follows the same admission rule as the rest of the calculator: an explicit trigger, or a phrase that is nothing but the expression.
Decisions
Plugin trust boundary (ADR). Recorded for 4.0.0 as 4.0-H07: a plugin gets its own process and speaks to the core over the same protocol as the shell. It lands there rather than in 5.0.0 because H03 rewrites the Plugin API from scratch anyway, D02 designs the protocol anyway, and C04 builds the permission catalogue anyway — so a plugin becomes one more protocol client instead of a separate mechanism. Deferring would mean changing the Plugin API twice and migrating the bundled plugins twice. A full sandbox with resource limits was rejected as a separate research task: in-process sandboxing does not exist in Python, and OS-level limits can be layered on top of H07 later if needed.
Deliberately not done
- Moving all storage to SQLite, as the external review proposed. 5.0.0 introduces SQLite for memory and 4.0.0 migrates user data; doing that migration twice buys nothing. The races are fixed with a lock instead.
- An allow-list of directories for command targets. It would break portable programs, which is the case the mechanism was built for. Imported commands remain sanitised and disabled.
- Schema validation of event payloads. That is
4.0-D02and4.0-D04. Wrapping every event in a version envelope is the wrong shape: the version is agreed once at handshake, and plugins do not subscribe to the event bus at all.
Verification
- Block tests: security 26 checks, reliability 27, responsiveness 15, parsing 24, security log 12 — all passing.
- Regression: command pipeline, reminders, plugins, import/export, all 5 languages, all 5 themes — 0 failures.
- Translations: 0 untranslated strings.
On the external review
An external audit of 3.0.0 was reviewed alongside my own. Of its twelve findings, two were confirmed and are fixed here (the speech queue and the hotkey debounce), two were real but attributed to the wrong file, three were already fixed in 3.0.0, and the rest did not match the code — including a quoted fragment of settings_store.py that does not exist, and a claim of shell=True in voice/system_control.py, which contains none. Checking that particular claim is what surfaced the real shell=True in voice/commands.py. Its recommendations section was considerably stronger than its findings: the logging work and SECURITY.md in this release come from it.