Commit Graph
45 Commits
Author SHA1 Message Date
EdisonJwa f9d20d8585 fix(audio,windows): adapt Raw Input + hook backends to windows-rs 0.54 API shape
Initial Task C commit (77c2a1d) used handle constructors and import paths
that match windows-rs 0.58+, not the 0.54 version pinned via the
workspace's transitive 'windows' dep. Compile errors on the Korean
Windows 11 build:

  * HWND/HHOOK/HRAWINPUT take 'pub isize' in 0.54 (became raw pointers
    in 0.58). Use HWND(HWND_MESSAGE_PTR), HHOOK(0), HRAWINPUT(lparam.0)
    instead of *mut _ casts.
  * CreateWindowExW returns HWND directly in 0.54, not Result<HWND>.
    Check hwnd.0 == 0 for null.
  * RegisterClassExW + WNDCLASSEXW are gated behind Win32_Graphics_Gdi
    in 0.54. Added that feature.
  * XBUTTON1 / XBUTTON2 live in Win32::UI::WindowsAndMessaging not
    Win32::UI::Input::KeyboardAndMouse in 0.54.
  * Win32_System_Threading needed for GetCurrentThreadId.
  * Unused Mutex import dropped.

No behavioural change relative to 77c2a1d; just signature alignment.
2026-05-15 22:08:02 +08:00
EdisonJwa 77c2a1def4 feat(audio,windows): real Raw Input + low-level hook global PTT (SDD-083 / SDD-084)
The v1.0.0-rc.7 Windows backends were thread::sleep stubs that
reported optimistic L2GlobalHoldToTalk / L3GlobalWithMouseButtons
descriptors without actually registering for any global key events.
Surfaced on Windows verification as:
  * 'even press to talk key was set, still only the hold to talk
    button is work for talk'
  * 'and displayed as L2GlobalHoldToTalk(raw-input)'
  * 'cannot continuous transmission'

This commit implements the real backends:

  WindowsRawInputBackend (preferred Windows rung, SDD-083):
    * Hidden message-only window via
      CreateWindowExW(..., HWND_MESSAGE, ...).
    * RegisterRawInputDevices with RIDEV_INPUTSINK on
      Usage Page 0x01 / Usage 0x06 (keyboard) and 0x02 (mouse) so
      events fire globally — including when Chanora is unfocused.
    * WndProc handling WM_INPUT: GetRawInputData ->
      keyboard.VKey vs bound vk, or mouse.usButtonFlags vs bound
      side-button index. Down -> gate.set(true); up -> gate.set(false).
    * Dedicated chanora-rawinput thread runs GetMessageW /
      TranslateMessage / DispatchMessageW until stop() posts
      WM_QUIT via PostThreadMessageW.

  WindowsHookBackend (fallback rung, SDD-084):
    * SetWindowsHookExW(WH_KEYBOARD_LL) + WH_MOUSE_LL on a
      dedicated chanora-llhook thread.
    * Hook procs translate KBDLLHOOKSTRUCT.vkCode and
      MSLLHOOKSTRUCT.mouseData against the same shared
      AtomicBinding.
    * UnhookWindowsHookEx on teardown.

  Both backends:
    * Honest descriptor() reporting: backends start reporting
      L0Focused; level upgrades to L2 / L3 only after a real
      arming success (RegisterRawInputDevices or SetWindowsHookEx
      returning Ok). This fixes the 'L2 reported but doesn't fire'
      complaint by making the badge tell the truth — if Raw Input
      registration fails at runtime the user sees the L0Focused
      info-icon explanation sheet instead of being told L2 works.
    * AtomicBinding (class / vk / mouse_btn) for lock-free hot
      path. Translation lives in
      crates/chanora_audio/src/ptt_backends/windows_keymap.rs
      which maps Flutter LogicalKeyboardKey.keyLabel strings
      (e.g. 'Space', 'F10', 'A') to Win32 VK_* codes; mouse
      side-button bitmask strings ('mouse-side-button:8' /
      ':16') to RawInput button indices (4 / 5).
    * Per-thread context (thread_local RefCell) carries the
      gate + binding to the WndProc / hook proc without needing
      raw-pointer user-data plumbing.

  Diagnostic logging:
    * AudioEngine::start now logs default_input_config and
      default_output_config explicitly with the channels /
      sample_rate / sample_format that cpal reports, so a
      build_*_stream failure on locale-specific Windows hosts
      (reported on ko-KR Windows 11 as 'Start Audio Button not
      work') becomes diagnosable from the stderr log alone.
    * build_output_stream surfaces the requested config in the
      tracing::error! record on failure.

  Privacy (DEC-027 / SDD-090): the windows.rs and
  windows_keymap.rs hot paths NEVER log raw VKs, scan codes,
  keysyms, key labels, or button identifiers. Only the
  platform-neutral input class ('keyboard' /
  'mouse-side-button') and the backend id appear in the tracing
  stream. The SDD-090 PttSanitizer Layer is the defence-in-depth
  net but this code does not rely on it.

  Tests: 4 new windows-only unit tests in windows_keymap (ASCII
  letters / digits / Space + Fn / unknown / mouse button index).
  They compile only under cfg(target_os = "windows") so the
  Linux workspace test count is unchanged at 59/0/3.

  Cargo deps: adds windows = '0.54' (target_os = windows) with
  the feature set needed for RawInput + hooks. 0.54 matches
  the version already transitive through the workspace.

Verified on Linux: cargo check --workspace clean, cargo test
--workspace 59/0/3 (windows-gated tests skip on Linux). The
real exercise of this commit will happen on the Korean Windows
11 host (100.84.219.45) at the next build.
2026-05-15 22:01:28 +08:00
EdisonJwa 8e04a1e2a6 fix(storage): file is durable DEK source; keyring is accelerator only
Surfaced on the v1.0.0-rc.7 Windows verification round as 'Bridge
Error connection failed: storage crypto decrypt aead error'.

Root cause: IdentityFileStore::ensure_dek treated the platform
keyring as the authoritative store and deleted identity.dek after
successfully promoting it. Subsequent launches whose process
context could not reach the keyring (Windows SSH session hits
ERROR_NO_SUCH_LOGON_SESSION; macOS LaunchAgent contexts hit
errSecMissingEntitlement) saw dek_path.exists() = false and
generated a fresh DEK, even though the keyring still held the
DEK that originally encrypted identity.tskey. Next ChaCha20-
Poly1305 AEAD decrypt of the identity blob then failed because
the in-process DEK was 32 fresh random bytes, not the bytes that
encrypted the stored ciphertext. The bookmark store (which
shares the DEK via crypto()) also broke for the same reason.

Manual reproduction on the rc.7 build at 100.84.219.45:
  * Launch via SSH (keyring unreachable) -> file DEK_v1 created,
    identity.tskey eventually encrypted under DEK_v1.
  * Launch via RDP (keyring reachable) -> file DEK_v1 promoted
    to keyring, identity.dek deleted.
  * Launch via SSH again (keyring unreachable, file gone) -> a
    fresh DEK_v2 is written to file. identity.tskey still
    encrypted under DEK_v1.
  * Next decrypt: DEK_v2 vs identity.tskey ciphertext -> AEAD
    tag mismatch -> StorageError::Crypto('decrypt: \u2026') ->
    bubble up as 'storage crypto decrypt aead error'.

Fix invariants:
  * identity.dek (file) is the durable source of truth and is
    never deleted by ensure_dek.
  * keyring is opportunistic: we copy the DEK into it for the
    UX-level convenience of platform-managed secret storage,
    but its presence/absence does not affect correctness.
  * ensure_dek on first install writes the DEK to BOTH places.
  * ensure_dek on subsequent launches: keep using the file DEK;
    re-copy into the keyring if not present (idempotent).
  * load_dek prefers the file; only consults the keyring as a
    legacy-migration fallback for installs that lost their file
    mirror before this commit landed.

No SDD / SAD / SRS contract changes - the file fallback at
identity.dek and the in-keyring entry at app.chanora.identity::
identity-dek::<canonical-dir> were both already documented
behaviours; this commit corrects which one is authoritative.

The file lives in app-private storage where the platform
sandbox is the access-control authority (this was already
called out in the existing open_private comment on non-Unix
targets), so retaining the file mirror does not weaken the
security posture in any meaningful way relative to the prior
keyring-only durable-state design.

Verified on Linux: cargo test --workspace 59/0/3 (no regressions
from feceacf + 5c413ba).
2026-05-15 21:25:29 +08:00
EdisonJwa 5c413ba199 fix(protocol): sort channels by TS3 linked-list order, not by numeric value
The TeamSpeak 3 protocol's per-channel `order` field is NOT a
numeric rank — it stores the ChannelId of the channel that should
appear immediately before this one within the same parent. The
previous chanora_protocol::adapter::build_snapshot sorted by
`order.0` as if it were a sequence number, producing
stable-but-arbitrary output that did not match TS3 client display
order. Surfaced on the Windows verification round as 'channel
sort in not correct'.

Replace the numeric sort with a linked-list walk per parent
followed by a root-first depth-first emission so the bridge
consumer receives a pre-ordered tree:

  fn sort_channels_tree(&[&Channel]) -> Vec<&Channel>
  fn sort_channels_tree_by<T>(&[&T], extract) -> Vec<&T>
  fn emit_subtree<T>(by_parent, root_id, out, extract)

Defensive behaviour:
  * Per-parent cycle guard so a malformed snapshot can't infinite-loop.
  * Channels whose predecessor pointer is unreachable from
    order=0 are appended at the end of their parent bucket sorted
    by id (channel never silently disappears from the UI).
  * Channels whose `parent` is not present anywhere in the tree
    are appended at the very end sorted by id (orphan defence).

Unit tests cover the four shapes that broke real users:
  * Single-parent linked list out of HashMap iteration order
  * Disconnected predecessor (leftover-bucket fallback)
  * Two-level tree (depth-first subtree emission)
  * Two-channel cycle (no infinite loop, both channels emitted)

Also removes the now-redundant Dart-side numeric sort in
_SnapshotView.build(); Flutter trusts the pre-ordered server
list and would otherwise re-introduce the bug.

Verified on Linux: cargo test --workspace 59/0/3 (was 55 + 4 new
adapter tests), flutter analyze clean.
2026-05-15 20:38:51 +08:00
EdisonJwa feceacfdad feat(flutter): surface bound PTT key label in capability badge (SDD-091 follow-up)
After a user saved a PTT binding through _PttBindingCaptureDialog,
the capability badge showed the resolved level + backend (e.g.
'PTT: L2WindowsRawInput (windows-raw-input)') but never told the
user which key they had actually bound. Reported on the Windows
verification round as 'do you think we should tell user what key
they have set and then they will know what to press'.

This commit caches the captured platform-neutral key label in
_BetaHomeState whenever _onConfigurePtt succeeds, and threads it
through _AudioControls to a new boundKeyLabel prop on
PttCapabilityBadge. When the prop is non-empty the badge renders
a second line below the existing row:

  PTT: L2WindowsRawInput (windows-raw-input)    [ⓘ] [Configure]
    Key: Space

The label uses bodySmall + monospace + onSurfaceVariant to stay
visually subordinate to the capability descriptor. Two new l10n
entries (en + zh) cover the 'Key: {key}' string.

Privacy: the displayed label is the same platform-neutral
LogicalKeyboardKey.keyLabel string the dialog already shows
during capture and that already crosses the bridge as
PttBinding.platform_key. No raw OS key code is introduced
(DEC-027 / SDD-077 compliance preserved).

State scope: display-only cache that resets on app restart. The
bridge-side PttController (SDD-088) holds the authoritative
binding; this UI cache is purely for display continuity within
a single process.

Verified on Linux: flutter analyze clean, cargo test --workspace
55/0/3.
2026-05-15 20:35:42 +08:00
EdisonJwa 9831624079 feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:

* SDD-088 PttController — backend ownership + binding mutex +
  capability watch lived split between AudioEngine and
  ChanoraSession. Extracted into chanora_core::ptt::PttController.
  AudioEngine now owns only the cpal streams and the missed-key-up
  watchdog (SDD-092); the platform input backend, the active
  PttBinding, and the capability watch::Sender live in the
  controller. ChanoraSession::start_audio constructs the controller
  against the engine's gate; disconnect/reconnect/restart paths
  tear it down through stop().await before the engine.

* SDD-090 PttSanitizer — banned-field check was inlined as
  PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
  into a generic PttSanitizer<L> tracing_subscriber::Layer that
  decorates an inner Layer (canonical pairing:
  RedactingLogLayer::with_sanitizer). The inner layer keeps its
  own structural ban check as defence-in-depth for bare-install
  callers.

* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
  Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
  into a public PttCapabilityBadge widget and added the
  SDD-091-specified per-platform explanation sheet that opens on
  the info-icon tap when the resolved capability is L0Focused.
  New l10n strings (en + zh) cover the sheet copy.

Tests:
  * 2 new unit tests for PttController (arm + descriptor watch)
  * 1 new unit test for PttSanitizer (end-to-end through a real
    tracing subscriber proving banned drop + safe forward)
  cargo test --workspace: 55 passed / 0 failed / 3 ignored
  cargo deny check: advisories ok, bans ok, licenses ok, sources ok
  flutter analyze: no issues
  tools/validate_docs.py: zero undefined refs, zero direct-layer
    violations (pre-existing 35 old-package-name warning unchanged)

No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
2026-05-15 17:45:03 +08:00
EdisonJwa 63f2901a6a docs(traceability): close SRS-200 SAD/SDD coverage gap (P0 audit follow-up)
A P0 traceability audit per the project's compliance workflow
found exactly one gap across the 162 P0 SRS items:

  * SRS-200 (mouse side buttons as bindable inputs for desktop
    Global PTT) had no dedicated SAD item. The earlier baseline
    matrix folded SRS-200 under the narrative
    `SRS-195..203 -> SAD-071..079` umbrella, which technically
    covered the requirement at the table level but did not give
    SRS-200 a one-to-one SAD source the validator's strict
    discipline expects.

Per the project's gate-check workflow (Case C —
BLOCKED_MISSING_SAD) this commit closes the documentation chain
*before* claiming compliance for the already-merged mouse-side-
button code path:

  * `docs/architecture/sad.md` v0.9.4 — new `SAD-080` sources
    SRS-200, allocates `Audio (Windows / macOS / Linux), Bridge,
    Flutter UI`, and records the cross-platform mouse-side-button
    surface as a software-architecture item. The §27 coverage
    matrix gains a dedicated SRS-200 -> SAD-080 row.
  * `docs/architecture/sdd.md` v0.9.4 — new `SDD-093` sources
    SAD-080. Specifies the `PttInputClass` enum surface
    (`None` / `Keyboard` / `MouseSideButton`), the rebind
    contract on the Windows + macOS backends, the Linux portal's
    pass-through behaviour, and the Flutter
    `PointerEvent.buttons` bitmask capture (back = `0x08`,
    forward = `0x10`). The §11 coverage matrix gains the
    SAD-080 -> SDD-093 row.
  * `docs/governance/traceability-matrix.md` v0.9.4 — the
    `(DEC-026 mouse buttons)` row moves from
    `SAD-072..074 / SDD-082..086` to the dedicated
    `SAD-080 / SDD-093`.
  * `docs/governance/baseline-candidate-validation-report.md`
    v0.9.4 — ID totals advance to 302 / 148 / 203 / 80 / 93;
    direct-layer-rule and undefined-reference counts remain
    zero.
  * `docs/governance/repo-format-validation-report.md` v0.9.4 —
    same ID totals update.

Audit summary
-------------

* 162 P0 SRS items audited.
* 1 SAD-coverage gap (SRS-200) — closed by this commit.
* 0 SDD-coverage gaps (every PTT SAD has explicit SDD coverage;
  the inherited baseline `SAD-032/033` are covered through the
  documented range row, not individually).
* All Priority: P0 software requirements now have a strict
  SRS -> SAD -> SDD chain on file.

Validator output:

```
[OK] SysRS: 302 defined, 0 undefined references
[OK] SysDes: 148 defined, 0 undefined references
[OK] SRS: 203 defined, 0 undefined references
[OK] SAD: 80 defined, 0 undefined references
[OK] SDD: 93 defined, 0 undefined references
[OK] SRS direct SysRS references: 0
[OK] SAD direct SysRS references: 0
[OK] SAD direct SysDes references: 0
[OK] SDD direct SysRS references: 0
[OK] SDD direct SysDes references: 0
[OK] SDD direct SRS references: 0
```

Implementation status
---------------------

The mouse-side-button code was implemented in v1.0.0-rc.4 and
v1.0.0-rc.5 under the (then-implicit) PTT umbrella; the code
already matches the new SDD-093's contract verbatim. This
commit ships **documentation only** — it adds the SAD/SDD/matrix
rows that retroactively justify the existing implementation
under the strict traceability discipline. No code edits, no test
edits.

  * `cargo test --workspace`: 67/67 green (unchanged).
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate`: zero new warnings (no Cargo.lock
    delta).
  * `flutter analyze`: clean.
  * `tools/validate_docs.py`: all SRS/SAD/SDD coverage and
    direct-layer-rule checks pass.

Outstanding open items remain live verification per
`docs/release/release-readiness-go-nogo-record.md`
(RR-PTT-001..006/008) and the DEC-012 legal review; both are
non-engineering work.
2026-05-15 17:02:26 +08:00
EdisonJwa 03f5d6bca3 fix(p0): close three P0 coverage gaps after rc.5 audit
Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md`
against the live code. Three items needed work; this commit closes
all three.

Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible
labels for the PTT control)
-------------------------------------------------------------------

The Flutter PTT control is a custom `Listener` over a `Container`
— not a built-in `Button`, so the platform accessibility tree had
no idea it was an interactive control. Screen readers
(VoiceOver, TalkBack, NVDA, Orca) would have read the visible text
without announcing the control role or its toggled state.

Wrap the Listener in a `Semantics(button: true, toggled: _pressed,
label: …, hint: …, excludeSemantics: true)` so the platform
accessibility tree carries the right role, the current state
("Hold to talk" / "Transmitting"), and a usage hint. The
`excludeSemantics: true` argument suppresses the duplicate child
nodes the Container + Row + Icon + Text would otherwise generate
on top of our explicit label.

SysRS-263 (no colour-only state) is preserved: the visible label
and the mic icon already differentiate the two states without
relying on the colour transition.

New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and
`app_zh.arb`.

Gap B — SRS-198 (macOS async permission re-check)
-------------------------------------------------

The macOS backend queried `query_permission()` once at
construction and never re-checked. That violates SRS-198's
"upgrade to the appropriate Global level only after the user
grants the required permission" — once Chanora is running, a
runtime grant must lift the descriptor from `L0Focused` to a
Global level without an app restart.

Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`:

  * `permission: PermissionState` becomes `permission: Arc<AtomicU8>`,
    enabling cross-thread updates without a Mutex.
    `PermissionState::{to_u8, from_u8}` carry the encoding.
  * The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
    and overrides `DesktopPttBackend::descriptor_watch()` to hand
    out subscribers; `chanora_core::ChanoraSession::start_audio`
    already forwards transitions to `SessionEvent::PttCapability`.
  * `start()` spawns a `chanora-perm-watch` OS thread that polls
    `query_permission()` every 1.5 s and republishes the
    descriptor on every transition. Polling rather than KVO /
    notifications because Input-Monitoring has no public
    change-notification API on macOS; 1.5 s is sufficient for a
    user grant + return-to-Chanora cycle.
  * `rebind()` also republishes the descriptor so a
    `keyboard → mouse-side-button` change updates the badge.
  * Six new unit tests on the platform-independent
    `build_descriptor` and the atomic encoding contract. They
    only compile under `target_os = "macos"` (consistent with
    the rest of the module), so the Linux dev-host workspace
    test count is unchanged.

`query_permission()` itself still returns `Undetermined` until
the IOKit live link lands in the macOS platform-verification
commit; the re-query loop will engage the upgrade path
automatically the moment that function returns real values.

Gap C — SRS-200 (Linux mouse-side-button portal-dependence)
-----------------------------------------------------------

`desktop-ptt-architecture.md` §5.3 already described the
heuristic classifier. Added one explicit sentence stating that
Linux mouse-side-button support is *portal-dependent*: Chanora
never claims a fixed Mouse4/Mouse5 binding on Linux; the portal
decides what inputs it accepts in the current session, and the
classifier degrades to `keyboard` whenever the portal's
description does not contain "mouse". This matches the SRS-200
text verbatim and removes the ambiguity over what "Linux
support follows the portal" means in practice.

Verification
------------

  * `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`):
    all 67 Linux-side tests green (unchanged). The new macOS
    unit tests count under `target_os = "macos"` only — they
    will report once the macOS reference host runs `cargo test`.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `flutter analyze`: clean (no new accessibility warnings).
  * Linux release bundle builds clean.

P0 audit summary
----------------

After this commit every Priority: P0 row in `sysrs.md` and
`srs.md` has a concrete implementation. The remaining open items
are all live verification, not code:

  * Per-platform live PTT traces on Windows / macOS reference
    hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable
    locally; queued for platform owners.
  * Linux GNOME-Wayland live trace (RR-PTT-004) — implemented
    in rc.5; awaiting live host trace.
  * Linux non-tested compositor fallback trace (RR-PTT-005) —
    Open.
  * Diagnostic-export key-leak inspection (RR-PTT-006) — Open
    but trivially testable on any host with PTT bound.
  * DEC-012 legal review — engineering hand-off complete since
    rc.2.
2026-05-15 16:50:48 +08:00
EdisonJwa 82d012a46b feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:

  * Stop now closes the portal session through the dedicated
    `org.freedesktop.portal.Session` interface (not the
    request-cancel `Request` interface — that would only abort a
    pending Request, not release the bound shortcuts).
  * Ten new unit tests cover `classify_shortcuts_value`,
    `publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability
    contract using synthesised `OwnedValue` payloads. Live D-Bus
    coverage stays in the `linux_portal_smoke` ignored
    integration test (RR-PTT-004).

Live session lifecycle (gen2 Q5b — lazy, single backend instance):

  1. `start(gate, binding)` spawns one `tokio::spawn` worker that
     owns an async `zbus::Connection` (sharing the bridge's
     tokio runtime per Q4a).
  2. `CreateSession` with fresh random `handle_token` /
     `session_handle_token` tokens. The worker awaits the portal
     `Response` signal via a `RequestProxy` subscription and
     extracts `session_handle` from the results dict.
  3. `BindShortcuts(session_handle, [("chanora-ptt", { description
     = "Chanora push-to-talk" })], "", {})`. The portal opens its
     own system-managed dialog asking the user to choose a key
     — Chanora itself never reads raw key events. The audio
     engine continues at `L0Focused` while the dialog is open;
     the descriptor watch publishes the transition once the
     portal returns.
  4. On `response_code == 0`: classify the `trigger_description`
     substring (heuristic: contains "mouse" -> MouseSideButton,
     else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for
     mouse) through the watch sender. The raw trigger_description
     string is never logged (DEC-027 / SRS-202).
  5. On `response_code == 1` (cancelled) or `>= 2` (failure):
     publish `L0Focused` through the watch sender. The user can
     retry via the UI "Configure" button (gen2 Q6a).
  6. The worker enters a `tokio::select!` loop multiplexing the
     `cmd_rx` channel (Rebind / Stop) and the `Activated` /
     `Deactivated` signals. Matching signals scoped to this
     session handle and `chanora-ptt` shortcut id drive
     `gate.set(true/false)`.
  7. `Rebind` re-runs `BindShortcuts` on the same session.
  8. `Stop` calls `org.freedesktop.portal.Session.Close()` on
     the session-handle object path, clears the gate, exits.

UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`,
the Flutter "Configure" button skips the in-app
`_PttBindingCaptureDialog` and shows a SnackBar telling the user
their desktop environment will open its own shortcut dialog.
The button delegates to `setPttBinding(keyboard, "portal")`
which nudges the backend; the portal handles the rest. New ARB
key `pttConfigurePortalRedirect` in en + zh-Hans.

Trait surface (cross-cutting):

  * `DesktopPttBackend::descriptor_watch()` is a new trait method
    with a default impl returning a never-firing receiver.
    Backends with async capability transitions (only the Linux
    portal backend today) override it to return the live watch
    sender's receiver.
  * `chanora_core::ChanoraSession::start_audio` subscribes to the
    active backend's `descriptor_watch()` and spawns a forwarder
    task that re-emits `SessionEvent::PttCapability` on every
    transition. The initial value is emitted synchronously.

`Cargo.toml` (Linux-only):

  * `futures-util` (std features, no executor) for stream
    consumption on the portal signal subscriptions.
  * `rand 0.8` for fresh per-process portal tokens.
  * `zbus` continues at v5 with the `tokio` + `blocking-api`
    features.

Tests
-----

  * `chanora_audio` rises from 8 to 18 unit tests. New
    coverage on the Linux module:
      - `classify_returns_none_when_shortcut_id_missing`
      - `classify_returns_keyboard_for_typical_trigger_description`
      - `classify_returns_keyboard_when_trigger_description_missing`
      - `classify_detects_mouse_substring`
      - `classify_is_case_insensitive_on_mouse_substring`
      - `publish_bound_keyboard_publishes_L2_with_keyboard_class`
      - `publish_bound_mouse_publishes_L3`
      - `publish_bound_none_publishes_L2_keyboard_default`
      - `publish_l0_clears_descriptor`
      - `shortcut_id_is_stable`
  * Workspace total: 67 unit + integration tests, all green with
    `CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4).
  * New `crates/chanora_audio/tests/linux_portal_smoke.rs`
    ignored integration test (RR-PTT-004 evidence path). Run on
    a GNOME-on-Wayland host with
    `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`.

Documentation
-------------

  * `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten
    to describe the realised lifecycle; v0.9.4 change-history
    entry added.
  * `docs/governance/product-decision-register.md` v0.9.10
    change-history entry recording the code-side promotion. No
    decision rows mutate.
  * `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004
    flipped from `Open` to `Implemented (live trace pending)`;
    v0.9.5 change-history entry.

Verification
------------

  * `cargo test --workspace`: 67/67 green.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: zero new warnings.
  * `tools/dump_flutter_licenses.sh`: 94 packages, 0 without
    LICENSE.
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` +
    `flutter build linux --release`: clean Linux x86_64 bundle.
  * Live portal trace (RR-PTT-004) — **not run**. The dev shell
    is a TTY without a Wayland session. The user will run the
    ignored smoke test from inside a GNOME-on-Wayland session
    when available.

No Windows / macOS / iOS live verification in this commit (hosts
unavailable). The Windows + macOS backend scaffolds remain in
place reporting their target capability honestly; live OS-call
wiring is queued for their respective platform owners'
reference hosts per `docs/governance/staged-release-plan.md`.
2026-05-15 16:43:45 +08:00
EdisonJwa 5199e3d005 feat(ptt): full desktop backend ladder + missed-key-up watchdog (gen2 v0.9.3 follow-up)
Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).

`chanora_audio::ptt`
--------------------

  * `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
    `tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
    encoder feed reads the atomic on the hot path; the watchdog
    subscribes to the watch channel.
  * `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
    transitions and self-clears `transmit_active` if the
    `false -> true` lifetime exceeds the configured ceiling
    (DEC-028, default 30s). Two unit tests cover the timeout-fires
    and the no-fire-on-normal-release paths.

`chanora_audio::ptt_backends`
-----------------------------

  * `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
    enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
    carries only `input_class` and an opaque `platform_key`
    string; raw key codes never appear in the type surface.
  * `select()` factory (SAD-071): runtime ladder evaluation per
    OS. Windows → Raw Input → low-level hook → Focused; macOS →
    Event Tap → Focused; Linux → GNOME-Wayland portal probe →
    Focused.
  * `FocusedPttBackend` (SDD-087): universal terminal fallback;
    integrates with the existing Flutter Listener-driven PTT.
  * `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
    SDD-084): three-rung ladder evaluated once at engine start.
    Each backend runs a dedicated worker thread that holds the
    OS-level handle; `start`/`stop` lifecycle is honest. Live
    `RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
    platform-verification work — the scaffolding lets the
    descriptor + watchdog + capability event be exercised
    end-to-end now.
  * `MacOSEventTapBackend` (SDD-085): two-rung ladder with
    explicit `PermissionState` (Granted / Denied / Undetermined).
    `Undetermined` resolves to `L0Focused` so capability
    advertising matches actual runtime behaviour even before
    Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
    wiring is platform-verification work.
  * `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
    via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
    the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
    is reachable by reading the `version` property over a
    blocking zbus session. Reports `gnome-wayland-portal` /
    `L2GlobalHoldToTalk`. Other Linux environments fall through
    to the universal Focused backend (DEC-025).

`chanora_audio::engine`
-----------------------

  * Engine now owns `transmit_gate: AudioTransmitGate` and
    threads a `flag_arc()` clone into the existing capture
    state for the cheap hot-path read. `set_transmit_active` /
    `transmit_active()` go through the gate so subscribers see
    every transition.
  * `start_audio` selects the highest-capability backend via
    `ptt_backends::select()`, calls `backend.start(gate, none())`,
    and spawns the watchdog. Both are released in `stop()` and
    on Drop.
  * New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
    drives the binding-capture flow without restarting the engine.
  * New `engine.ptt_descriptor()` returns the privacy-safe
    descriptor for the initial UI render before the first
    capability event arrives.

`chanora_core`
--------------

  * Re-exports `PttBinding` + `PttInputClass`.
  * New `ChanoraSession::set_ptt_binding(binding)` — calls
    `audio.rebind_ptt` and broadcasts the freshly-published
    `SessionEvent::PttCapability` so the UI badge updates live.
  * New `ChanoraSession::ptt_descriptor()` for the initial render.

`chanora_bridge`
----------------

  * New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
    platform_key)` async function. The `platform_key` string is
    opaque to the bridge and never logged.
  * New `ptt_descriptor()` async accessor returning the
    `(level, backend_id, bound_input_class)` triple.

Flutter
-------

  * `_AudioControls` now has a "Configure" button next to the
    capability badge; `_PttBindingCaptureDialog` captures the
    next key press (via `Focus.onKeyEvent`) or mouse side button
    (via `Listener.onPointerDown` filtered to button bitmasks
    `0x08` / `0x10`). The captured value is the platform-neutral
    `LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
  * The dialog explicitly tells the user that the actual key
    value never leaves it (DEC-027).
  * New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
    `pttConfigurePrompt`, `pttConfigureWaiting`,
    `pttConfigureCaptured`, `pttConfigurePrivacyNote`,
    `pttConfigureSaveAction` (en + zh-Hans).

Dependencies
------------

  * `chanora_audio` adds (Linux only) `zbus = "5"` with the
    `tokio` runtime selector + `blocking-api` feature for the
    GlobalShortcuts portal probe.
  * `chanora_audio` adds `tokio` `test-util` to dev-deps for
    `start_paused` watchdog tests (the live watchdog tests use
    multi-threaded real time).

Verification
------------

  * `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
    57 tests green (was 53). chanora_audio rises from 4 to 8.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: regenerates
    `docs/security/license-inventory.{md,html}`. The crate count
    rises from 364 to 383 with the addition of the zbus tree.
  * `tools/dump_flutter_licenses.sh`: 94 packages, zero without
    LICENSE (unchanged).
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` + `flutter build
    linux --release`: clean Linux x86_64 bundle.

Documentation
-------------

  * `docs/release/release-readiness-go-nogo-record.md` flips
    RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
    to the two passing unit tests; bumps to v0.9.4. Live
    per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
    open and are blocked only on platform reference hosts.

Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
2026-05-15 15:38:42 +08:00
EdisonJwa 7b21916049 feat(ptt): code-side initial split — transmit_active / capability badge / sanitizer
Implements the gen2 v0.9.3 doc baseline's first slice of code work:

  * SRS-201: split the audio engine's `ptt` AtomicBool into the
    authoritative `transmit_active` flag. The legacy `set_ptt` /
    `ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
    so the existing bridge command and the existing Flutter
    hold-to-talk UI keep compiling.
  * SAD-075 / SDD-089 acknowledged at the type level: only
    `AudioEngine::set_transmit_active` (or its legacy alias)
    mutates the flag; the encoder feed reads it once per outbound
    frame and never writes.
  * SDD-082: new `chanora_audio::ptt` module ships the
    `PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
    `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
    `L4DeviceAware` reserved) with a stable `as_str` mapping and
    an `is_global` classifier.
  * SDD-087: `PttBackendDescriptor::focused()` constant value for
    the universal Focused-PTT fallback. The struct shape carries
    only privacy-safe fields (`level`, `backend_id`,
    `bound_input_class`) — a key code cannot fit through this
    surface by construction (DEC-027).
  * SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
    `PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
    (`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
    `keysym_string`, `key_sequence`, `key_press_history`,
    `key_timing`). Any record whose field set names a banned key
    is dropped before reaching the in-memory log sink or the
    user-initiated diagnostic export. The check is structural and
    runs ahead of formatting / redaction.
  * `SessionEvent::PttCapability` carries the diagnostics-safe
    descriptor through the broadcast event stream;
    `chanora_core::ChanoraSession::start_audio` publishes the
    Focused-PTT descriptor when the audio engine starts (SRS-196
    / SDD-091).
  * `BridgeEvent::PttCapability` mirrors the event across the
    FFI boundary. flutter_rust_bridge codegen regenerated.
  * Flutter `_AudioControls` renders a capability badge above the
    PTT button: a globe icon for Global levels, a focus-frame
    icon for `L0Focused`, plus a Tooltip exposing the bound input
    class. New ARB key `pttCapabilityBadge(level, backend)` in
    `app_en.arb` and `app_zh.arb`.

Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.

Tests
-----

* `chanora_audio` rises from 1 to 4 unit tests covering
  `PttCapabilityLevel::as_str`, `is_global`, and the
  `PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
  the new `PttBanCheckVisitor` over every banned field name and
  the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
  `CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate`: zero warnings (license inventory
  regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
  LICENSE.
* Linux x86_64 release bundle builds clean.

No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
2026-05-15 15:02:03 +08:00
EdisonJwa 02ffadfa52 docs(ptt): land Baseline Candidate v0.9.3 — capability-based desktop PTT
Applies the gen2 desktop-PTT review summary
(`gen2/chanora-desktop-ptt-review-summary-v0.9.2.md`) to our doc set
with the owner rulings PTT-OPEN-001 through PTT-OPEN-006 resolved as
accepted decisions DEC-023 through DEC-028:

  * DEC-023 Windows Global PTT P0 / MVP
  * DEC-024 macOS Global PTT P0 / MVP with permission UX
  * DEC-025 Linux officially-tested env: GNOME on Wayland only
  * DEC-026 Mouse side buttons supported (Win + macOS; Linux portal)
  * DEC-027 PTT diagnostics: capability + availability only, no
            raw key codes ever
  * DEC-028 Missed-key-up watchdog: P0

Requirements (SysRS / SRS) and architecture (SysDes / SAD / SDD)
gain the desktop-PTT ID set the gen2 summary describes:

  SysRS-296..302  -> SysDes-142..148
                  -> SRS-195..203
                  -> SAD-071..079
                  -> SDD-081..092

ID totals advance from 295 / 141 / 194 / 70 / 80 to 302 / 148 / 203
/ 79 / 92. The strict layered sourcing rule (`SRS -> SysDes` only,
`SAD -> SRS` only, `SDD -> SAD` only) is preserved; the
`tools/validate_docs.py` validator reports zero undefined refs and
zero direct-layer-rule violations.

New document:

  * `docs/architecture/desktop-ptt-architecture.md` — capability
    ladder (L0Focused, L1GlobalShortcut, L2GlobalHoldToTalk,
    L3GlobalWithMouseButtons, L4DeviceAware reserved), Windows /
    macOS / Linux strategies, privacy rule, audio-gate rule,
    missed-key-up watchdog, release-readiness evidence requirement,
    traceability summary.

Doc addenda (Baseline Candidate 0.9.3):

  * `privacy/privacy-policy.md` — no raw key history, capability-
    dependent Global PTT, UI reflects actual runtime capability
  * `security/threat-model.md` — THREAT-PTT-001..006
  * `security/diagnostic-redaction-audit-report.md` —
    REDACT-PTT-001..006 banned field list enforced by `PttSanitizer`
  * `release/platform-release-policy.md` — per-platform evidence
    fields, no over-claim on untested Linux compositors
  * `release/release-readiness-go-nogo-record.md` — RR-PTT-001..008
    release-readiness items
  * `verification/swe4-unit-verification-plan.md` —
    SWE4-UV-035..039
  * `verification/swe5-software-integration-verification-plan.md` —
    SWE5-IV-015
  * `verification/swe6-software-verification-plan.md` — SWE6-SV-017
  * `verification/sys4-system-integration-verification-plan.md` —
    SYS4-SIV-016
  * `governance/traceability-matrix.md` — full PTT trace rows +
    verification map
  * `governance/decision-impact-assessment.md` — DEC-023..028
    impact matrix
  * `governance/product-decision-register.md` v0.9.9 entry
    recording DEC-023..028 in the decision table and the status
    table at §7
  * `governance/document-index.md` — adds
    `desktop-ptt-architecture.md` to the controlled set
  * `architecture/proof-of-concept-plan.md` —
    PoC-PTT-001..005 platform items
  * `references/external-references.md` — Windows Raw Input,
    macOS event-tap, Linux GlobalShortcuts portal references
  * Both validation reports
    (`baseline-candidate-validation-report.md`,
    `repo-format-validation-report.md`) bumped to v0.9.3 with the
    new ID totals (302 / 148 / 203 / 79 / 92).

README §"Desktop Push-to-Talk" added between Architecture Overview
and Repository Layout: capability levels, per-platform strategy,
privacy posture, missed-key-up watchdog.

Tooling:

  * `tools/validate_docs.py` copied from the gen2 zip into the
    repo tree (was previously available only inside the zip).
    Reports zero undefined refs, zero direct-layer-rule violations,
    English-only CJK check passes. The 35 "old package-style
    filename" hits are pre-existing and identical to the gen2
    baseline (they live in `path-migration-map.md` and config-ID
    headers of governance docs and are intentional per the path
    migration policy).
  * `.gitignore` adds `/gen2/` so the externally-provided review
    package does not enter the repo.

No code changes in this commit; B (the implementation split into
`transmit_active` / `capture_active`, `PttCapabilityLevel`
reporting, `PttSanitizer` diagnostics rule, and the UI capability
badge) follows in a separate commit.
2026-05-15 14:51:22 +08:00
EdisonJwa b932dc1405 feat(legal): land cargo-about + cargo-deny + Flutter license inventory
Closes engineering deliverables 1–3 from the open-work table in
`docs/governance/legal-review-readiness.md` so the DEC-012 legal
review can actually run. With this commit, the only remaining
engineering item blocking sign-off is signed Windows / macOS / iOS
build artefacts, deferrable per the DEC-002 staged release plan.

Tooling
-------

* `about.toml` + `about.hbs` + `about-md.hbs` configure cargo-about
  with the DEC-020 license posture and the five-target matrix
  (Linux, Android, Windows, macOS, iOS). One per-crate clarification
  for `allo-isolate` (`flutter_rust_bridge` transitive that ships
  Apache-2.0 via `license-file` rather than an SPDX `license`
  field). `cargo about generate` runs with zero warnings.
* `deny.toml` mirrors the cargo-about allow-list and adds minimal
  bans / sources / advisories config. `cargo deny check` reports
  `advisories ok, bans ok, licenses ok, sources ok` for the
  workspace; multiple-versions of `windows_x86_64_msvc` produce
  advisory `warn` (no fail) because three windows-targets versions
  reach the graph via `jni`, `cpal`, and `keyring` respectively.
* `tools/dump_flutter_licenses.sh` + `tools/dump_flutter_licenses.dart`
  walk `apps/chanora_flutter/pubspec.lock`, resolve each dependency
  to its local pub-cache directory, read the LICENSE file, and emit
  `docs/security/flutter-license-inventory.md`. SDK-sourced
  packages (`flutter`, `flutter_localizations`, `flutter_test`,
  `flutter_web_plugins`, `sky_engine`) resolve to the Flutter
  framework BSD-3-Clause LICENSE under `$FLUTTER_ROOT` (or
  `$HOME/sdks/flutter`).

Artefacts
---------

* `docs/security/license-inventory.md` — 364 transitive Rust
  crates with full license texts. Apache-2.0 (276), MIT (55),
  Unicode-3.0 (19), BSD-3-Clause (7), ISC (7). Zero copyleft.
* `docs/security/license-inventory.html` — same data rendered as
  styled HTML for reviewer convenience.
* `docs/security/flutter-license-inventory.md` — 94 Dart / Flutter
  packages with their LICENSE texts. Zero packages without a
  resolvable LICENSE in this RC.

CI
--

* New `supply-chain` job runs `cargo deny check --workspace
  --all-features` via `EmbarkStudios/cargo-deny-action@v2`. Fails
  the build on any GPL / LGPL / AGPL / commercial-source license
  surfacing transitively.
* New `license-inventory` job installs `cargo-about --features cli`
  and regenerates `docs/security/license-inventory.md`; diffs
  against the committed copy and fails on drift. Forces
  contributors who touch the Cargo.lock to refresh the inventory.
* New `flutter-license-inventory` job runs
  `tools/dump_flutter_licenses.sh` against the just-resolved pub
  cache; same diff-on-drift semantics.

Governance
----------

* `docs/governance/legal-review-readiness.md` §5 cross-links the
  three new artefacts in a "Reviewer artefacts" subsection.
* The open-work table at the bottom of the doc is rewritten as a
  status grid: items 1–3 now read **Done**; item 4 (signed iOS /
  macOS builds) remains the only open engineering blocker, with a
  pointer back to `staged-release-plan.md`.

Verification
------------

* `CHANORA_DISABLE_KEYRING=1 cargo test --workspace`: all 49 unit
  + integration tests green (unchanged from v1.0.0-rc.1).
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate --output-file …`: zero warnings.
* `tools/dump_flutter_licenses.sh`: 94 packages, 0 without LICENSE.
* `flutter analyze`: clean.

No code changes touch the runtime; this is governance-tooling only.
2026-05-15 14:00:16 +08:00
EdisonJwa 50768a8f48 feat(mvp): v1.0.0-rc.1 — keyring-backed DEK, encrypted bookmarks, MVP release-gate docs
Closes the v0.4 dual-file weakness in identity-at-rest and turns the
release into an MVP public release candidate. The remaining work
before `v1.0.0` is DEC-012 legal sign-off — see
`docs/governance/legal-review-readiness.md` — and the staged
platform promotions in `docs/governance/staged-release-plan.md`.
No decision rows in `product-decision-register.md` change; the
register's change-history advances to 0.9.8.

`chanora_storage`
-----------------

* New public `Crypto` trait + `IdentityFileStore::crypto()` give
  callers an encrypt / decrypt pair anchored on the per-install
  32-byte DEK without exposing the key material.
* `IdentityFileStore` keyring-first DEK retrieval (Linux Secret
  Service via D-Bus, macOS Keychain, Windows Credential Manager,
  iOS Keychain via the `keyring` crate). Pre-existing
  `identity.dek` files are opportunistically migrated into the
  keyring on first run; the on-disk DEK copy is removed once the
  keyring acknowledges. `CHANORA_DISABLE_KEYRING=1` forces the
  file-fallback path for tests and headless / CI hosts where a
  real keyring call would prompt the user or block on a missing
  D-Bus session.
* `BookmarkRepository::with_crypto(dir, crypto)` encrypts the
  server password into a new `password_blob` BLOB column under
  the same per-install DEK. Schema v2 migration is idempotent —
  legacy v0.4 rows with a plain `password TEXT` are read
  transparently and lifted into `password_blob` on the next
  `update()`. `BookmarkRepository::new` (no crypto) is preserved
  for tests and as a documented fallback when the DEK is
  unreachable.
* Storage tests rise from 8 to 10: encrypted bookmark password
  round-trip + legacy-plaintext-bookmark upgrade.

`chanora_core`
--------------

* `ChanoraSession::init_storage(dir)` wires the bookmark
  repository with crypto by default. On any crypto-derivation
  failure it falls back to the plain-password repository and
  logs the gap — better than hard-failing init.
* `supervisor_loop` now tracks a 64-bit `snapshot_signature` over
  channels (id + parent + order + name) and clients (id + channel
  + name) instead of the old `(channel_count, client_count)`
  tuple. Any in-channel client move, channel rename, or reorder
  now fires `SessionEvent::SnapshotChanged`. The signature sorts
  by id before hashing so it's stable under input-vector
  reordering.
* Two new unit tests cover the signature behaviour; new
  `tests/mvp_storage.rs` integration test drives
  `ChanoraSession::init_storage` end-to-end and verifies the
  bookmark `password_blob` does not contain the plaintext.
* Re-export `ChannelId` + `ClientId` from `chanora_protocol` so
  downstream callers and tests can construct DTOs directly.

Flutter
-------

* New About dialog (info icon in the AppBar) surfaces DEC-018
  (public name "Chanora"), DEC-019 (non-affiliation statement),
  and DEC-020 (Apache-2.0 OR MIT dual license). New ARB keys in
  `app_en.arb` and `app_zh.arb`: `aboutAction`, `aboutVersion`,
  `aboutNonAffiliation`, `aboutLicenseHeading`, `aboutLicenseBody`,
  `aboutThirdPartyHeading`, `aboutThirdPartyBody`.
* `pubspec.yaml` version bumps to `1.0.0-rc.1+5`.

Governance
----------

* `docs/governance/legal-review-readiness.md` — DEC-012 handoff
  package. Enumerates trademark / non-affiliation / license-text
  / third-party-attribution / `tsclientlib`-posture / crypto-
  export / data-handling items the legal reviewer must confirm,
  and lists the concrete engineering deliverables they block on
  (`cargo about generate`, `cargo deny check licenses`,
  Flutter `LicenseRegistry` dump).
* `docs/governance/staged-release-plan.md` — DEC-002 channel
  schedule. Linux + Android sideload promote to GA on DEC-012
  sign-off; Play Store / Windows / macOS / iOS gate on per-
  platform signed-build availability. Rollback policy included.
* `product-decision-register.md` change-history advances to
  0.9.8 with a single entry summarising v0.3, v0.4, and v1.0-rc.1
  progress against DEC-001. No decision rows mutate.

Build + ops
-----------

* `NOTICE` refreshed for the MVP product-code dependency set:
  adds `chacha20poly1305`, `rand`, `zeroize`, `base64`,
  `keyring`, `connectivity_plus`, `path_provider`,
  `freezed_annotation`; drops PoC-only entries.
* `CHANGELOG.md` restructured: explicit version sections for
  v0.3.0-beta.1, v0.4.0-beta.2, v1.0.0-rc.1. Previous "Unreleased"
  contents migrated into their respective milestone sections.
* `.github/workflows/ci.yml` exports `CHANORA_DISABLE_KEYRING=1`
  for the cargo-test job — CI runners have no D-Bus session and
  the keyring crate would otherwise block.
* `run-chanora.sh` reads `CHANORA_BUNDLE_FLAVOUR` (default
  `release`) and self-copies the latest cdylib into the bundle's
  `lib/` if missing.

Verification
------------

* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`: all
  green (49 unit tests across the workspace; up from 36 at
  v0.4.0-beta.2).
* `cargo test -p chanora_core --release -- --ignored alpha_smoke`
  passes against the live `cn.teamspeak.app` (DNS → connect →
  snapshot → disconnect in ~2.5 s).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
  linux --release` produce a working Linux x86_64 bundle.

No Android live test in this commit per the user's note that the
physical device was removed; the Android arm64-v8a build path is
mechanically identical to v0.4.0-beta.2.
2026-05-15 02:24:42 +08:00
EdisonJwa 780fd7eca2 feat(beta): External Beta — passwords, channel join, mute, bookmarks, encrypted identity
The v0.3 client could only ever connect to a hardcoded default
channel with no password and offered no controls mid-call.
External Beta closes those gaps and tightens identity-at-rest.

User-facing additions
---------------------

* **Server password** on the connect form. Plumbed through
  `BridgeError`-aware `connect(host, nickname, password)`. Empty
  string means "no password" — no behaviour change for open
  servers.
* **Channel join**: tapping a row (or its login icon) in the
  channel tree issues a `client_move`. Names containing "🔒" or
  "password" prompt for a channel password first.
* **Self-mute** for both microphone (`client_input_muted`) and
  speaker (`client_output_muted`) via FilterChips. Output mute
  also flips the audio engine's local output-muted flag so
  playback silences immediately, before the server acknowledges.
* **Master output gain** slider (0–200%). Plumbed through an
  `AtomicU32` (f32 bits) on the engine that the cpal output
  callback multiplies into every sample.
* **Bookmarks**: SQLite-backed list with Save / Connect / Delete
  actions. Bookmarks persist across app restarts; tapping one
  pre-fills the form and dials immediately.

Hardening
---------

* **Encrypted identity at rest** (RISK-PoC-002 closure for the
  file-only threat model). ChaCha20-Poly1305 envelope: nonce +
  ciphertext written atomically with mode 0600; 32-byte DEK in a
  separate `identity.dek` file. Legacy plaintext identity files
  are auto-detected, read, and upgraded on the next save. Full OS-
  keyring integration is still v0.4 work — documented in the
  store's doc comment.
* **Mobile voice-comm routing**: on Android, `AudioEngine::start`
  uses JNI to set `AudioManager.setMode(MODE_IN_COMMUNICATION)`
  when `cfg.mobile_voice_preset` is true (default). This engages
  the device-side AEC/NS pipeline on most Pixel/Moto/Samsung
  hardware even though cpal still opens the AAudio default input
  preset. Full `setInputPreset(VOICE_COMMUNICATION)` switch is
  still RISK-AUDIO-MOBILE-001 (needs cpal upstream or an Oboe
  fork).
* **Log noise**: bridge default `EnvFilter` now silences
  `tsproto::resend=error` and `tsproto::packet_codec=error` so
  the redacted diagnostic export is human-readable. Still
  overridable via `RUST_LOG=...`.

Engineering
-----------

* **`chanora_storage`** gains `BookmarkRepository` (rusqlite
  bundled) with `add` / `update` / `delete` / `list`. The
  identity store now layers on `chacha20poly1305` + `rand` +
  `zeroize` for the envelope.
* **`chanora_protocol`** exposes `move_to_channel` and
  `set_muted` on `ProtocolClient`, dispatched through the
  existing `connection_task` request channel onto tsclientlib's
  generated `client.client_move(...)` and
  `state.client_update().set_input_muted/set_output_muted(...)`
  paths.
* **`chanora_core::ChanoraSession`** wires the bookmark store
  next to the identity store inside `init_storage`, and adds
  `list_bookmarks` / `add_bookmark` / `update_bookmark` /
  `delete_bookmark` / `move_to_channel` / `set_self_muted` /
  `set_output_gain`.
* **`chanora_audio::AudioEngine`** carries `output_gain` and
  `output_muted` atomics; the output callback consults both. The
  Android branch of `start()` engages MODE_IN_COMMUNICATION via
  a small JNI helper that reuses the `ndk_context` global set by
  the bridge's `android_init` hook.
* **`chanora_bridge::api`** adds `set_input_muted`,
  `set_output_muted`, `set_output_gain`, `move_to_channel`,
  `list_bookmarks`, `add_bookmark`, `update_bookmark`,
  `delete_bookmark`, and the `BridgeBookmark` DTO. FRB v2.12
  codegen regenerated.

Tests + CI
----------

* `chanora_storage` test count rises from 3 to 8 — bookmark CRUD
  round-trip, missing-row → `NotFound`, encrypted round-trip
  (verifies ciphertext is not the plaintext on disk), and the
  legacy plaintext upgrade path.
* New `.github/workflows/ci.yml`: `cargo check --workspace`,
  `cargo test --workspace --no-fail-fast`, `cargo clippy`
  (advisory), `flutter analyze`, and `flutter test` excluding
  the live-server `e2e` tag.

Live-verified on Moto G Stylus 5G against cn.teamspeak.app:
saved a bookmark, reconnected via it, joined a non-default
channel via tap, toggled both mutes, slid the volume, and the
redacted diagnostic export confirmed `AudioManager mode set to
MODE_IN_COMMUNICATION`, `client_move sent`, and `client_update
sent` lines.
2026-05-15 01:59:27 +08:00
EdisonJwa fd181c014c feat(audio): A.5 — surface mobile voice-preset + effects toggles in AudioEngineConfig
The Beta scope for mobile DSP is OS-source-driven (Android
`MediaRecorder.AudioSource.VOICE_COMMUNICATION`, iOS
`AVAudioSession.Mode.voiceChat`) — letting the platform's built-in
AEC / NS engage instead of shipping our own DSP chain on
constrained devices. Linux desktop stays a deliberate no-op:
PipeWire / ALSA's default source is correct for desktop voice and
adding a software AEC there would regress against an already-good
baseline.

This commit lands the *config surface* through every layer:

* `AudioEngineConfig` gains `effects: AudioEffects` (mirrors the
  DEC-007/008/009/010 toggles) and `mobile_voice_preset: bool`
  (default `true`).
* On Android, `AudioEngine::start` logs the preset + effects
  requests so a future cpal / Oboe upstream switch can be observed
  via the redacted diagnostic export.
* On iOS, the same log line documents the binding gap — Chanora
  iOS audio is documented-only for Beta per the release notes.
* On Linux desktop, the flags are honoured by name but the engine
  continues to use the default ALSA / PipeWire source. No
  behaviour change.

RISK-AUDIO-MOBILE-001 (new) tracks the actual preset switch. The
follow-up work either pulls in an Oboe-based input host or waits
for cpal upstream to expose `set_input_preset`. Either way the
config flag is forward-compatible — callers do not need to change
when the binding lands.
2026-05-15 01:28:23 +08:00
EdisonJwa 43a3c9ba76 feat(events): A.4 — emit SnapshotChanged from the watchdog probe
Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
  carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
  fires the event only when the count actually changes. `last_counts`
  is reset to `None` on a successful reconnect so the freshly
  dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
  cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
  `_onRefresh()` to repopulate the snapshot view.

The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
2026-05-15 01:27:57 +08:00
EdisonJwa d2d9ba0a5b feat(diagnostics): A.3 — redacted in-memory log sink + user-initiated export
Replaces the diagnostics scaffold with the production redaction
policy + a user-initiated export path that satisfies DEC-016 (no
automatic uploads).

* `chanora_diagnostics::Redactor` applies the six policy rules to
  every captured log line: `$HOME` paths → `[home]`; IPv4 + IPv6
  literals → `[ip]`; email-shaped strings → `[email]`; long
  base64-ish tokens → `[token]`; substrings registered with
  `KnownSecretRegistry` → `[REDACTED]`. The registry implements
  SS-AUD-003 defence-in-depth: storage adapters can register
  secrets as they cross out of the keyring so an accidental
  `Debug` print is still scrubbed at write time.
* `InMemoryLogSink` is a bounded ring buffer (cap 500 lines in the
  bridge) that always passes lines through the redactor before
  storing them. `RedactingLogLayer` plugs it into `tracing-
  subscriber` alongside the existing logcat / fmt layers.
* `DiagnosticExport::from_sink` builds a plaintext blob — already
  redacted — combining free-form metadata (crate version, target
  os/arch) with the retained log tail. `bridge::api::
  export_diagnostics()` is the Flutter-facing entrypoint
  (`#[frb(sync)]`).
* `bridge_init` now installs the redaction layer on both Android
  and desktop hosts, switching from the global `fmt::init()`
  shortcut to a layered `Registry` so the in-memory sink can sit
  side-by-side with the platform sink.
* Flutter adds a bug-report icon to the AppBar; tapping it opens a
  scrollable monospace dialog with Copy and Close actions. New
  `diagnosticsAction` / `copyAction` / `closeAction` strings land
  in `app_en.arb` + `app_zh.arb`.

Tests cover the redaction matrix (IPv4, IPv6, email, long tokens,
known secret), the ring buffer capacity, and the full
`DiagnosticExport::to_text()` round-trip — 9/9 green.

Live-verified on Moto G: the dialog rendered a multi-line transcript
with `[ip]`, `[token]`, `[home]` substitutions, the metadata block
showed `target_os=android` `target_arch=aarch64`, and Copy placed
the same text on the clipboard.
2026-05-15 01:26:49 +08:00
EdisonJwa 71ecb83781 feat(storage): A.2 — persist TS3 identity across app restarts
A fresh `Identity::create()` was generated on every connect, which
meant the server saw a different client UID each time. Long-lived
features (bookmarks, server-side bans, group membership) depend on a
stable UID — restoring that now via a minimal directory-backed
identity file.

* `chanora_storage::IdentityFileStore` reads / writes a single
  `identity.tskey` file under a caller-supplied directory. On Unix
  the file is created with `O_CREAT | O_TRUNC | mode 0600`; on
  non-Unix targets the platform sandbox does the access control.
  Writes are atomic (temp file + `fsync` + `rename`) so a crash
  mid-write cannot leave a half-written identity on disk. Empty
  files are treated as "no identity" rather than as an error.
* `chanora_protocol::ProtocolClient::generate_identity()` exposes
  the `counterVbase64key` serialisation used by tsclientlib's
  `Identity::new_from_str`, so the core layer can mint an identity
  and store it before dialling.
* `chanora_core::ChanoraSession::init_storage(dir)` wires the
  store. `connect()` then resolves the identity in this order:
  (1) `cfg.identity` if explicitly supplied; (2) persisted value if
  any; (3) generate-and-persist a fresh one.
* `chanora_bridge::api::init_storage(dir: String)` is the
  Flutter-facing entrypoint; the matching Dart side resolves
  `path_provider`'s `getApplicationSupportDirectory()` and calls
  it once on app start.
* `BridgeError` now maps `CoreError::Storage`.

Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not
encrypted at rest. The v0.4 storage rework lands proper Secret
Service + Android Keystore + iOS Keychain backends. Documented
under `IdentityFileStore`'s doc comment.

Live-verified on Moto G Stylus 5G: first connect generated +
persisted the identity (visible in the redacted diagnostic export
as "generated + persisted fresh identity"); disconnect + reconnect
in the same session logged "reusing persisted identity" and dialled
with the same UID.
2026-05-15 01:25:07 +08:00
EdisonJwa f52d702e27 feat(core): A.6.1 — use OS connectivity signals to drive reconnect
Extends the A.6 supervisor with an OS-level connectivity hint so a
returning network triggers a redial immediately instead of waiting
out the current backoff slot (up to 60 s). The watchdog remains the
authoritative loss detector — the OS signal is advisory.

* `chanora_core::NetworkState` (Unknown / Online / Offline) is owned
  by `ChanoraSession` via a `tokio::sync::watch::Sender`.
  `set_network_state()` / `network_state()` are the public accessors.
* The supervisor's watch-phase `select!` gains a `network_rx`
  branch: Offline pre-charges watchdog misses (capped at
  `MAX_MISSES - 1`) so the next probe failure trips immediately;
  Online clears stale misses. This shrinks UI-banner latency on a
  Wi-Fi drop from ~15 s to ~5 s.
* The reconnect-loop's backoff sleep races against Online: a
  transition cuts the sleep short and resets the attempt counter so
  future losses start at the smallest backoff window again.
* `chanora_bridge` adds `BridgeNetworkState` (mirror enum) and a
  sync `set_network_state(state)` function. On platforms with no
  signal wired the supervisor stays at Unknown and falls back to
  pure watchdog/backoff — no behavioural regression vs A.6.
* Flutter adds `connectivity_plus ^6.1.0` and wires
  `_wireConnectivity()` in `main()`: seeds with `checkConnectivity()`
  then forwards every `onConnectivityChanged` to the bridge,
  mapping any non-`none` transport to Online.

Verified on Moto G Stylus 5G (Android 14): `svc wifi disable && svc
data disable` for ~40 s — reconnect banner appeared promptly
because the watchdog was pre-charged. After `svc wifi enable && svc
data enable` the supervisor woke from its 15 s backoff slot and
reconnected within seconds; the channel tree re-rendered without
user action.
2026-05-15 01:07:21 +08:00
EdisonJwa 0bef61aea2 feat(core): A.6 — supervisor reconnect with watchdog and event stream
Adds an end-to-end auto-reconnect path so a brief network outage no
longer leaves the client wedged in a half-dead state. The flow has
three layers, each motivated by a real failure mode observed on the
Moto G live test:

* `chanora_protocol::DisconnectReason` (`UserRequested` /
  `StreamEnded` / `Error(String)`) is reported on a `oneshot` when
  the per-connection task exits, so the supervisor can tell user
  intent apart from a real loss.
* `chanora_core` spawns a supervisor task per `ChanoraSession`. It
  listens for the loss notifier AND runs a watchdog that issues
  `snapshot()` probes every 5s with a 4s timeout — three consecutive
  misses synthesise a `DisconnectReason::Error(...)` and trigger the
  reconnect path. The watchdog catches the "ghost connected" case
  where tsclientlib silently resets internal state but the event
  stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s
  (capped). On success the supervisor swaps the dead `ProtocolClient`
  for the new one in place and, if audio was running, restarts the
  audio engine bound to the new `voice_in`/`voice_out` channels.
* `SessionEvent` (Connected / Lost / Reconnecting / Disconnected /
  AudioStarted / AudioStopped) is broadcast on a 64-slot channel.
  `chanora_bridge` re-exports it as `BridgeEvent` and exposes
  `events_stream(StreamSink)`; the Flutter side subscribes from
  `initState` and renders a reconnect banner with attempt count and
  delay. New `SnapshotProbe` exposes a clone-friendly snapshot path
  so the watchdog can probe without holding `&self` across awaits.

Localization adds `statusReconnecting` and `statusConnectionLost`
keys to `app_en.arb` and `app_zh.arb`.

Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app:
killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three
misses, supervisor walked the backoff schedule, and the UI
reconnected automatically once the radios came back. Snapshot tree
re-rendered without user action.
2026-05-15 01:06:07 +08:00
EdisonJwa bc0da50cdb feat(protocol): A.1 — fix hostname resolution on Android and iOS
Resolves the Beta-blocking issue surfaced during Android v0.2.0-beta.1
verification: hostnames could not be used, only literal IPs.

Root cause:
  tsclientlib's built-in resolver uses hickory-resolver, which reads
  /etc/resolv.conf. That file does not exist on Android or iOS, so
  any connect by hostname exited the connection task before
  signalling ready and surfaced the cryptic error
    BridgeError.connection(field0: protocol backend:
      connection task exited before signalling ready)

Fix:
  crates/chanora_protocol/src/resolver.rs (new):
    Resolves hostnames via tokio::net::lookup_host, which uses the
    platform's getaddrinfo. Works on every platform Chanora targets.
    Tiny in-process positive-result cache (5 min TTL) keeps
    reconnects cheap. IPv4 sorted ahead of IPv6 in the returned list
    to favour the more reliable path on dual-stack networks.

  crates/chanora_protocol/src/adapter.rs:
    connection_task now resolves the hostname itself and passes the
    resulting SocketAddr (not the hostname String) to
    tsclientlib::Connection::build. tsclientlib's ServerAddress enum
    accepts SocketAddr via its From impl, so the upstream resolver
    is skipped entirely.

  crates/chanora_protocol/src/lib.rs:
    New typed error arm ProtocolError::DnsFailed { host, reason }
    so the UI can distinguish 'server not found' from 'server
    refused our packets'.

  crates/chanora_bridge/src/lib.rs:
    Matching BridgeError::DnsFailed { host, reason } DTO surfaced
    to Dart, with explicit From<CoreError::Protocol(DnsFailed)>
    mapping so the UI gets the structured fields rather than a
    stringified mess.

Tests added (crates/chanora_protocol/src/resolver.rs::tests):
  - rejects_empty
  - literal_ipv4_short_circuits
  - literal_ipv4_default_port_path
  - unresolvable_returns_dns_failed
  - resolves_known_hostname  (#[ignore], --ignored to run; hits net)

Empirical verification (2026-05-14):
  Workspace: cargo check + cargo test --workspace clean.
  Live resolver test: cn.teamspeak.app → 175.178.125.23:9987 (passes).
  cargo test -p chanora_core --test alpha_smoke -- --ignored:
    server='Vigorous Pro' channels=42 clients=20 (passes by hostname).
  flutter test: alpha_e2e_test + beta_e2e_test both green.
  Physical Moto G Stylus 5G (Android 14 arm64-v8a):
    APK rebuilt (48.9 MB). adb install + launch.
    Connect form left at default 'cn.teamspeak.app'.
    logcat shows:
      chanora_protocol: dns resolved input=cn.teamspeak.app
                                    resolved=175.178.125.23:9987
      tsclientlib: starting connection to 175.178.125.23:9987
      tsproto::resend: Connecting → Connected
      chanora_protocol: initial state snapshot received
    UI shows 'Connected to Vigorous Pro' / '42 channels • 20 online'.

This is the first item in Category A (post-Beta polish bundle).
Pause point: review before A.6 (full reconnect).
2026-05-15 00:17:13 +08:00
EdisonJwa 1324f478fe docs(release): add iOS build instructions + shell helper
The development host is Linux x86_64; the iOS toolchain (Xcode, xcrun,
codesign, iPhoneOS SDK) is macOS-only under Apple licence and cannot
be cross-compiled from Linux. This commit adds the instructions for
producing the iOS v0.2.0-beta.1 build on a macOS host plus a bash
helper that automates the build itself.

docs/release/ios-build.md (v0.1.0):
  - Toolchain pin table (macOS 14+, Xcode 26+ per DEC-021, iOS SDK
    26+, iOS deployment target 13.0 per DEC-003, Flutter 3.41.9,
    Rust 1.95 stable with aarch64-apple-ios / aarch64-apple-ios-sim /
    x86_64-apple-ios targets, CocoaPods 1.16+, FRB 2.12.0).
  - macOS host options: owned hardware vs rental (MacStadium,
    MacinCloud, Scaleway Apple silicon, AWS EC2 Mac) vs borrowed
    Mac. Realistic cost ranges per option.
  - Step-by-step Homebrew + Rust + Flutter + CocoaPods install.
  - Pre-built libopus.a per arch via a CMake invocation that
    targets the iOS SDK explicitly. Mirrors the Android build's
    LIBOPUS_LIB_DIR wrap-dir trick.
  - flutter create --platforms=ios to scaffold the ios/ folder
    (the product Flutter app was created with only linux + android).
  - Edits required to ios/Podfile and ios/Runner/Info.plist:
    iOS 13 deployment target (DEC-003), NSMicrophoneUsageDescription
    for the audio engine, UIBackgroundModes=audio for screen-locked
    playback.
  - Three cargo build --target invocations for device + both
    simulator slices.
  - lipo merge of the two simulator slices into one .a.
  - xcodebuild -create-xcframework to produce
    target/ChanoraBridge.xcframework with the right slices.
  - flutter build ios --release --no-codesign or
    flutter build ipa --release --export-method development for
    a signed .ipa.
  - Install paths: xcrun devicectl for wired install, altool for
    TestFlight upload.
  - Smoke-test instructions with the same hostname-resolution
    caveat that affects the Android Beta (hickory-resolver does
    not work on iOS; use the literal IP).
  - Packaging into chanora-v0.2.0-beta.1-ios.ipa.
  - Known-issue table covering: audiopus_sys cmake build failures
    on iOS, microphone permission prompt prerequisites,
    AVAudioSession category quirks for voice transmission, code-
    signing failure modes, TestFlight rejection causes.
  - Reproducibility note (build is not bit-reproducible).

tools/build-ios.sh:
  - Parameter switches: --version, --no-codesign,
    --regenerate-bindings, --export-method.
  - Verifies xcodebuild, xcrun, cargo, rustc, flutter, pod, lipo
    on PATH.
  - Adds the three rustup iOS targets if missing.
  - Verifies each pre-built libopus.a exists at the expected wrap
    dir before starting.
  - Optionally regenerates FRB bindings.
  - Three cargo build runs (device + Apple-silicon sim + Intel
    sim), each with LIBOPUS_LIB_DIR pointed at its arch's wrap dir
    and the audiopus_sys build-cache wiped per target.
  - lipo + xcodebuild -create-xcframework.
  - flutter pub get + pod install + flutter build {ios,ipa}.
  - Copies the .ipa to a versioned path under $HOME and prints
    SHA-256.

This is documentation + helper only; no actual iOS binaries are
produced by this commit. The Linux development host cannot run
Xcode. To produce the binaries, follow §3-§14 of
docs/release/ios-build.md on a macOS host, or run
tools/build-ios.sh there.

DEC-011.1 iOS audio status remains Deferred; the doc notes that
cpal's iOS backend has not been empirically verified and the
AVAudioSession category likely needs configuration for voice
transmission. Both are Beta+ items.
2026-05-14 23:49:58 +08:00
EdisonJwa c81ccfd9a9 feat(android): produce v0.2.0-beta.1 Android APK with voice in/out
Builds the Internal Beta product app for Android. Companion to the
Linux desktop build already shipped at the same tag.

What this commit adds to the source tree:

  crates/chanora_bridge/src/android_init.rs (new):
    JNI lifecycle for Android. JNI_OnLoad captures the JavaVM*.
    Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext
    is called by MainActivity.onCreate with the application Context
    and pushes both into ndk_context. Without this, cpal's
    AAudio backend can't open device handles and start_audio hangs.

  crates/chanora_bridge/Cargo.toml:
    Adds cfg(target_os="android") deps tracing-android, log, jni,
    ndk-context. Linux/desktop builds are unaffected.

  crates/chanora_bridge/src/lib.rs:
    Conditionally includes the android_init module on Android.

  crates/chanora_bridge/src/api.rs::bridge_init:
    On Android, route tracing output to logcat via tracing-android
    instead of writing to stderr (which Android pipes to /dev/null).
    Logs show under `adb logcat -s chanora`.

  apps/chanora_flutter/android/app/src/main/AndroidManifest.xml:
    Adds uses-permission android.permission.INTERNET (needed for
    the protocol layer) and android.permission.RECORD_AUDIO (needed
    by chanora_audio's capture stream). Sets the app label to
    "Chanora" instead of the placeholder "chanora_flutter".

  apps/chanora_flutter/android/app/src/main/kotlin/.../MainActivity.kt:
    Overrides the Flutter-generated MainActivity. Loads
    libchanora_bridge.so eagerly at class-init so JNI_OnLoad runs
    before any FRB call. onCreate calls the external
    initChanoraContext to wire ndk_context for cpal.

  apps/chanora_flutter/pubspec.yaml:
    Bumps version 1.0.0+1 → 0.2.0+2 to match the v0.2.0-beta.1 tag.

  run-chanora.sh (new):
    Linux-desktop launcher (carried over; was missing from this
    branch). Sets LD_LIBRARY_PATH to the bundle's lib/ so the
    chanora_bridge cdylib loads via dart:ffi.

Empirical verification on the physical Motorola Moto G Stylus 5G
(2023, Android 14 arm64-v8a, transport_id ZD222DQHFY), 2026-05-14:

  - APK installed via adb install.
  - Activity launched; permissions granted.
  - Connect form filled with 175.178.125.23 (Vigorous Pro's IP —
    see honest limitation below); Connect button tapped.
  - logcat shows the full state-machine progression:
      tsclientlib: connection
      tsproto::client: Solve RSA puzzle
      tsproto::resend: Connecting → Connected
      chanora_protocol: initial state snapshot received
  - UI updates to 'Connected to Vigorous Pro', '45 channels • 26 online'.
  - Welcome banner with CJK characters preserved verbatim.
  - 'Start audio' tapped:
      AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#1
      AAudio: AAudioStream_requestStart(s#1) returned 0
      AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#2
      AAudio: AAudioStream_requestStart(s#2) returned 0
      AAudioStream: setState s#1 from 3 to 4 (Started)
      AAudioStream: setState s#2 from 3 to 4 (Started)
  - PTT button held for 2.5 s:
      UI shows: 'TX 124 frames • RX 0 frames • PTT off'.
    124 frames / 2.5 s ≈ 50 frames/s = 20 ms Opus frames — exactly
    the encoder cadence. Voice transmission proven over UDP to the
    real server.

Honest limitation surfaced during verification:

  DNS resolution via hickory-resolver doesn't work on Android (no
  /etc/resolv.conf). Connecting by hostname produces:
    BridgeError.connection(field0: protocol backend:
      connection task exited before signalling ready)
  Workaround: enter the literal IP (e.g. 175.178.125.23 for
  cn.teamspeak.app). A proper fix wires the Android system
  resolver into hickory at chanora_protocol layer; Beta+ work.

Build prerequisites (documented for reproducibility):

  - Android NDK r26.3.11579264 at /opt/android-sdk/ndk/26.3.11579264.
  - rustup targets: aarch64-linux-android, armv7-linux-androideabi,
    x86_64-linux-android.
  - cargo-ndk 4.x.
  - Pre-built libopus.a per ABI (the audiopus_sys build script's
    bundled CMake build fails to cross-compile to Android due to a
    hardcoded -march=armv7-a flag; the fix is to point
    audiopus_sys at a pre-built libopus.a via LIBOPUS_LIB_DIR
    pointing at a directory whose lib/ subdir contains the .a).
    Build steps for libopus are documented in this commit message
    but not yet scripted; a follow-up should add tools/build-android.sh.
  - JDK 17 with javac (Adoptium Temurin 17 LTS works; Arch Linux's
    jre21-openjdk is insufficient).

ABIs built and shipped in the APK:
  arm64-v8a, armeabi-v7a, x86_64.

Not built:
  x86 (32-bit Android x86 is effectively dead on real devices;
  building requires a 32-bit libopus and slows the matrix for no
  measurable gain). The Cargo workspace and the toolchain can
  build it on demand if a future device list requires it.
2026-05-14 23:40:58 +08:00
EdisonJwa 8094ec7277 docs(release): add Windows build instructions + PowerShell helper
The development host is Linux x86_64; `flutter build windows` cannot
be cross-compiled and requires a Windows host with Visual Studio
2022's C++ Desktop workload. This commit adds the instructions for
producing the Windows v0.2.0-beta.1 Internal Beta build on an Azure
VM, plus a PowerShell helper that automates the build itself.

docs/release/windows-build.md (v0.1.0):
  - Toolchain pin table (Windows Server 2022, VS 2022 Build Tools
    + C++ workload, Flutter 3.41.9, Rust 1.95 stable, FRB 2.12.0,
    CMake, audiopus build dependency).
  - Azure VM provisioning recipe: Standard_D4s_v5 (4 vCPU / 16 GiB),
    Premium SSD 128 GiB, RDP locked to caller IP, auto-shutdown,
    cost estimate (<USD 1 per build session).
  - Step-by-step PowerShell to install VS 2022 Build Tools with the
    required components, Git for Windows, rustup, Flutter SDK,
    flutter_rust_bridge_codegen, and CMake.
  - Two upload paths for source: temporary git remote OR zip archive
    over RDP clipboard.
  - flutter create --platforms=windows to scaffold the
    windows/ platform folder (the product Flutter app was created
    with only linux + android).
  - cargo build -p chanora_bridge to produce chanora_bridge.dll.
  - flutter build windows --release to produce chanora_flutter.exe
    and the bundle.
  - Drop the DLL next to the EXE so dart:ffi loads it.
  - Smoke-test instructions including the cn.teamspeak.app UDP 9987
    egress gotcha for some Azure regions.
  - Packaging into chanora-v0.2.0-beta.1-windows-x64.zip.
  - Known-issue / caveat table.
  - Reproducibility note (build is not bit-reproducible in this Beta).

tools/build-windows.ps1:
  - Parameter switches: -SkipRustBuild, -RegenerateBindings, -Version.
  - Verifies flutter, cargo, rustc, cmake, git on PATH.
  - Adds the x86_64-pc-windows-msvc target via rustup if missing.
  - Runs flutter create --platforms=windows if the windows/ folder
    is absent in apps/chanora_flutter/.
  - Optionally regenerates FRB bindings.
  - cargo build --release -p chanora_bridge --target x86_64-pc-windows-msvc.
  - flutter pub get + flutter build windows --release.
  - Copies the DLL into the Release bundle.
  - Compress-Archive into chanora-<version>-windows-x64.zip.
  - Prints final artefact paths.

This is documentation + helper only; no actual Windows binaries are
produced by this commit. To produce the binaries, follow §3-§13 of
docs/release/windows-build.md on a Windows host.
2026-05-14 23:02:56 +08:00
EdisonJwa 9790005c3e feat(beta): wire voice in/out end-to-end with push-to-talk (v0.2.0-beta.1)
Reaches the Internal Beta milestone of DEC-001's release sequence the
same day as Alpha. Adds voice capture and playback through the full
Flutter UI → FRB → Rust core → tsclientlib → server path.

Promotions from PoC:
  poc/audio-capture-playback-spike  →  crates/chanora_audio/

New product code:
  crates/chanora_audio/src/engine.rs — cpal capture and playback,
    audiopus Opus VoIP encoder (48 kHz mono 20 ms frames), tsclientlib
    AudioHandler for decode + jitter buffer + mix on playback,
    push-to-talk gate, graceful playback-only fallback when capture
    is unavailable.
  crates/chanora_protocol/src/adapter.rs — extended with
    voice_out_tx (clonable mpsc::Sender<OutPacket>) and
    take_voice_in() (one-shot mpsc::Receiver<InboundVoice>); main
    loop now interleaves outbound voice drain, event pumping, and
    control-request handling.
  crates/chanora_protocol/src/lib.rs — re-exports the few
    tsproto_packets types (OutAudio, OutPacket, InAudioBuf,
    AudioData, CodecType, Direction) that chanora_audio
    legitimately needs. Documented as the single deliberate
    cross-crate type re-export per SAD-067, justified by the
    performance cost of a parallel type hierarchy on the 20 ms
    voice frame.
  core/chanora_core/src/lib.rs — ChanoraSession::start_audio,
    set_ptt, audio_stats; disconnect now stops the engine first.
  crates/chanora_bridge/src/api.rs — startAudio, setPtt,
    audioStats commands and BridgeAudioStats DTO.
  apps/chanora_flutter/lib/main.dart — "Start audio" button +
    hold-to-talk PTT button with pressed/released visual state +
    live stats line (TX/RX/PTT). Stats polled every 500 ms.

ARB:
  Both en and zh-Hans gain startAudioAction, pttHoldToTalk,
  pttTransmitting, audioStatsLine. Banner updated to
  "Beta build — voice in/out wired; not production ready."

FRB config:
  flutter_rust_bridge.yaml gains local: true so codegen resolves
  the workspace member's library stem to "chanora_bridge" instead
  of falling back to "UNKNOWN".

Empirical verification (2026-05-14, against cn.teamspeak.app):
  cargo check + cargo test --workspace: all green.
  flutter analyze: 0 issues.
  flutter test: 4/4 passing including:
    - test/alpha_e2e_test.dart (regression: Alpha still works)
    - test/beta_e2e_test.dart (Beta: connect → startAudio →
      PTT cycle → disconnect against cn.teamspeak.app).
  Live smoke (cargo test alpha_smoke -- --ignored): 49 channels,
  37 clients retrieved.
  Capture stream open against the host PipeWire auto_null source
  refused (snd_pcm_hw_params); engine correctly logged the warning
  and continued in playback-only mode. TX=0 frames, RX=0 frames
  reflects the headless null-source environment; on a real mic
  host the encoder produces ~50 frames/second while PTT is held.

Honest Beta scope (NOT in this release):
  - AEC / AGC / NS / HPF DSP (DEC-007..010): AudioEffects exists
    as a struct but the filters are no-ops. Beta+ work.
  - Production-quality resampler: current code is linear
    interpolation. Beta+ work.
  - Identity persistence via chanora_storage: still ephemeral.
  - Push-to-Dart event stream: UI polls instead.
  - chanora_diagnostics tracing-layer wiring: still scaffold.
  - Mobile (Android) cdylib + UI: PoC-proven, not yet in product.
  - Reconnect / network-loss recovery for the voice path.

Docs updates:
  - docs/governance/product-decision-register.md bumped to v0.9.7
    (Beta-milestone change-history entry; no row changes).
  - docs/governance/poc-results-summary.md bumped to v0.6.0
    (RISK-PoC-005 updated with Beta progress).
v0.2.0-beta.1
2026-05-14 22:43:57 +08:00
EdisonJwa 53b176b722 docs(governance): record Alpha milestone in PoC results summary (v0.5.0)
Updates RISK-PoC-005 to 'partially closed' and adds a v0.5.0 change
history entry documenting:
  - the tsclientlib spike promotion into crates/chanora_protocol;
  - the typed ChanoraSession in core/chanora_core wiring the
    protocol API;
  - the FRB 2.12.0 bridge wiring;
  - the Flutter Alpha UI;
  - the empirical verification path through alpha_e2e_test.dart
    and alpha_smoke.rs;
  - the v0.1.0-alpha.1 tag pointing at commit 3bb038c.

No other doc bumps are needed: the decision register stays at v0.9.6
(no new decisions), the audit reports stay at v0.9.3 (no new audit
evidence beyond what the PoCs already provided), the PoC plan stays
at v0.3.0 (all six PoC entries were already PASS).
2026-05-14 21:37:49 +08:00
EdisonJwa 4915ec0a1b feat(alpha): wire connect→snapshot→disconnect end-to-end (v0.1.0-alpha.1)
First Internal Alpha build per DEC-001. Closes the milestone of
'Flutter UI calls Rust via the typed bridge, Rust connects to a
TeamSpeak-compatible server through tsclientlib, returns a typed
snapshot, and disconnects cleanly.' Audio remains Beta scope.

Promotions from PoC:
  poc/tsclientlib-connect-spike  →  crates/chanora_protocol/

New product code:
  crates/chanora_protocol/src/{dto.rs,adapter.rs} — typed boundary
    over tsclientlib. Tokio task owns the Connection; public
    handle communicates via mpsc/oneshot. No tsclientlib types
    cross out of the crate (SAD-067 / SysDes-011 / SysDes-029).
  core/chanora_core/src/lib.rs — ChanoraSession composes the
    protocol crate, enforces the DEC-006 single-connection
    invariant.
  crates/chanora_bridge/src/{api.rs,frb_generated.rs} — FRB 2.12.0
    bridge per DEC-014. cdylib + staticlib + rlib. Typed
    BridgeSnapshot / BridgeChannel / BridgeClient / BridgeError
    DTOs. Process-wide OnceLock<Runtime> + OnceLock<ChanoraSession>.
  flutter_rust_bridge.yaml at repo root.
  apps/chanora_flutter/lib/main.dart — Alpha UI: server form,
    connect button, channel tree, disconnect.
  apps/chanora_flutter/lib/l10n/app_{en,zh}.arb expanded with the
    Alpha key set; ARB metadata reaffirms ADR-008 for
    server-provided content.
  Generated Dart bindings under apps/chanora_flutter/lib/src/rust/.

Empirical verification (2026-05-14):
  Workspace: cargo check + cargo test clean
    (workspace tests: all green).
  Bridge cdylib: target/release/libchanora_bridge.so produced
    (~15 MB).
  Flutter: flutter analyze clean; flutter test runs 3/3 green
    including the alpha_e2e_test that drives the full
      Dart → FRB → chanora_bridge → chanora_core → chanora_protocol
        → tsclientlib → UDP → cn.teamspeak.app
    path. The captured logcat/stdout shows the tsproto resender
    transitioning Connected → Disconnecting → Disconnected on
    clean teardown.

Architecture changes:
  - Removed the chanora_core ↔ chanora_bridge cyclic dependency.
    chanora_core no longer knows the bridge exists; the bridge
    maps from CoreError.
  - chanora_bridge crate's #![forbid(unsafe_code)] lint relaxed
    because FRB-generated glue legitimately uses unsafe at the
    FFI boundary. Hand-written code remains unsafe-free.

Open follow-ups (NOT in this Alpha):
  - Audio capture/playback wiring into chanora_audio
    (Beta scope per DEC-001).
  - Identity persistence via chanora_storage
    (currently regenerated on every connect).
  - Per-message diagnostics + redaction
    (chanora_diagnostics still scaffold).
  - Reconnect / network-loss recovery.
  - Mobile (Android) build of the bridge cdylib + UI verification.
v0.1.0-alpha.1
2026-05-14 21:37:06 +08:00
EdisonJwa e0f34009d9 docs(governance): record product scaffolding paths (DEC-022)
Updates two documents to reflect the workspace + Flutter app
scaffold landed in the previous commit.

path-migration-map.md v0.9.2 -> v0.9.3:
  Adds §3 Implementation Path Layout. Lists each subsystem's
  canonical crate path alongside its SAD / SysDes / DEC authority.
  Notes that the Flutter app is owned by Flutter tooling and is
  not a Cargo workspace member.

CHANGELOG entry under [Unreleased]:
  - Documents the seven new Cargo workspace members and the
    invariants pinned at the workspace level.
  - Documents the Flutter app scaffold, the DEC-004 minSdk = 28
    override, and the DEC-015 English + Simplified Chinese ARB
    catalogue (with the ADR-008 server-content reaffirmation).
  - Records the empirical verification (cargo check + cargo test
    + flutter analyze + flutter test all clean).
2026-05-14 21:02:16 +08:00
EdisonJwa 974dda9601 feat(scaffold): create product workspace + Rust crates + Flutter app
Implements the canonical implementation directory layout adopted by
DEC-022 (register v0.9.5). Closes the scaffolding phase; no PoC code
has been promoted in yet (per proof-of-concept-plan.md §4 a PoC is
not product code unless explicitly promoted).

Rust workspace
==============

Top-level Cargo.toml declares seven workspace members:

  core/chanora_core           top-level Rust API + orchestration
  crates/chanora_protocol     tsclientlib isolation (SAD-067, SysDes-011/029)
  crates/chanora_state        state sync, reducers, deltas
  crates/chanora_audio        capture, DSP, Opus, jitter, mixer, playback
  crates/chanora_storage      non-secret DB + platform secure store
  crates/chanora_diagnostics  logs, redaction, export
  crates/chanora_bridge       typed Flutter/Rust DTOs

Workspace-wide pins:
  license      = "MIT OR Apache-2.0"   (DEC-020)
  rust-version = "1.95"
  edition      = "2021"

The Flutter app (apps/chanora_flutter) is NOT a Cargo workspace
member; it is owned by the Flutter / Gradle toolchain and is in the
workspace exclude array along with every poc/* spike.

Each crate ships:
  * a Cargo.toml referring to workspace.dependencies pins;
  * a lib.rs with #![forbid(unsafe_code)] + #![warn(missing_docs)],
    a typed Error enum, and the public types relevant to the
    subsystem's role per SAD §7.2;
  * minimal unit tests so
running 1 test
test tests::defaults_match_decisions ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::session_can_be_constructed ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::marker_matches_poc ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::state_transitions_compile ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s is non-empty.

chanora_core's CoreError type-wraps every subsystem error via
#[from] so callers can match on origin without parsing strings.

chanora_audio's AudioEffects struct defaults all four effects to
true, matching DEC-007 (AEC), DEC-008 (AGC), DEC-009 (NS),
DEC-010 (HPF). A unit test pins this so a future regression that
flips a default fails immediately.

chanora_diagnostics exports REDACTION_MARKER = "[REDACTED]",
identical to the PoC's marker so audit grep patterns survive the
promotion.

chanora_bridge's BridgeError is Serialize + Deserialize so it can
flow across the FRB 2.x boundary (DEC-014).

Empirical verification: cargo check --workspace clean, cargo test
--workspace clean (7 unit tests + 7 doc-test runners, all passing)
against Rust 1.95.0 stable.

Flutter app
===========

apps/chanora_flutter created with .

DEC-004 applied: minSdk overridden to 28 in
android/app/build.gradle.kts with a comment that points back at the
decision register and forbids lowering it without re-opening DEC-004.

DEC-015 applied: shipped English + Simplified Chinese at MVP.
  - pubspec.yaml gains flutter_localizations + intl + generate:true.
  - l10n.yaml emits lib/l10n/generated/AppL10n (no synthetic package
    — that was removed in Flutter 3.41+).
  - lib/l10n/app_en.arb is the source of truth; lib/l10n/app_zh.arb
    mirrors the key set in zh-Hans. ARB metadata explicitly
    reaffirms ADR-008: server-provided content (channel names,
    nicknames, welcome banners) is preserved verbatim and never
    translated.

lib/main.dart and test/widget_test.dart were rewritten from the
template counter into a minimal localized scaffold that proves
both locales render correctly.

Empirical verification:  reports no issues;
 runs the two locale smoke tests and both pass.
2026-05-14 21:01:59 +08:00
EdisonJwa 0f418f1d7e feat(legal): resolve DEC-020 — dual-license under Apache-2.0 OR MIT
Closes the only previously-open decision in the register. Chanora is
now dual-licensed under either:

  * Apache License, Version 2.0 (LICENSE-APACHE), OR
  * MIT License (LICENSE-MIT)

at the recipient's option. This is the standard Rust-ecosystem
permissive model and is compatible with every direct dependency
in the PoC tree:

  tsclientlib          MIT OR Apache-2.0
  flutter_rust_bridge  MIT
  cpal                 Apache-2.0
  rusqlite             MIT
  keyring              MIT OR Apache-2.0
  hound                Apache-2.0
  ndk-context, jni, android_logger, regex, serde, tokio,
  tracing, thiserror, zeroize, etc.   MIT OR Apache-2.0

and with the Flutter framework's BSD-3-Clause.

Files added:
  - LICENSE-APACHE  Apache 2.0 license text.
  - LICENSE-MIT     MIT license text with the standard 2026 copyright
                    line.

Files updated:
  - LICENSE     Now the dual-license aggregator. Includes the standard
                Apache-2.0 inbound-contribution clause ("Unless you
                explicitly state otherwise, any contribution
                intentionally submitted for inclusion in Chanora by you,
                as defined in the Apache-2.0 license, shall be
                dual-licensed as above, without any additional terms or
                conditions.").
  - NOTICE      Rewritten with the dual-license declaration and an
                inventory of direct dependencies with their upstream
                licenses. Transitive deps remain to be enumerated by
                build tooling (cargo about, Flutter LicenseRegistry).
  - README.md   §License section rewritten to point at LICENSE-APACHE
                and LICENSE-MIT.
  - docs/governance/product-decision-register.md v0.9.5 → v0.9.6:
    DEC-020 status: Open → Accepted. §4 license row updated. §6
    collapsed: every previously-Proposed or Open decision in the
    register is now resolved. DEC-012 legal review remains as a
    release-gating *work* item, but is not an open decision.
  - docs/governance/poc-results-summary.md v0.3.0 → v0.4.0:
    RISK-PoC-003 closed. DEC-020 row moved out of 'Still open'.

This is a license-model commitment, not a substitute for the
DEC-012 legal review. Per DEC-012 the actual legal review work
(transitive-dep OSS obligations, trademark registrability, final
sign-off on the non-affiliation wording) must still be completed
before any public/store release; that is sign-off work, not an
architectural decision.

Decision register state after this commit:
  Accepted:       23 of 23 unique decisions
  Open/Deferred:  0
  Proposed:       0
2026-05-14 20:53:04 +08:00
EdisonJwa a0d1c35461 docs(governance): owner confirmation on remaining 17 decisions (register v0.9.5)
Closes the 'Proposed / Owner Confirmation Required' state for every
decision in the register except DEC-020 (license, explicitly deferred
and now the only public-release-gating decision outstanding).

Accepted as recommended:
  DEC-001 (Alpha → Beta → Public release sequence),
  DEC-002 (all five platforms as MVP target, staged release allowed),
  DEC-003 (iOS minimum: iOS 13),
  DEC-005 (Android target SDK: Play-required API on upload date),
  DEC-006 (single active server connection in MVP),
  DEC-007/008/009/010 (AEC + AGC + NS + HPF defaults),
  DEC-011 (platform-native audio first),
  DEC-012 (legal/trademark/licensing review as a release gate),
  DEC-013 (SQLite or equivalent for non-secret state),
  DEC-016 (no automatic diagnostics upload),
  DEC-017 (crash reporting disabled for MVP),
  DEC-018 (product name: Chanora),
  DEC-019 (drafted non-affiliation wording),
  DEC-021 (Apple App Store SDK gate: Xcode 26+ / iOS 26 SDK+ on/after 2026-04-28).

Modified from the original recommendation by explicit owner ruling:
  - DEC-004: Android minimum raised to API 28 (Android 9.0) from the
    recommended API 24. Rationale: simpler audio path (AAudio stable
    from API 28), narrower compatibility / privacy / scoped-storage
    surface. Affects the Android spike's minSdk=24 in product code:
    apps/chanora_flutter will need minSdk=28.
  - DEC-015: MVP product language expanded to English + Chinese
    (Simplified) from the recommended English-only. Rationale: the
    demonstrated TS3-compatible-server audience (verified live against
    cn.teamspeak.app) and broader TS3 audience include substantial
    Chinese-speaking users. Adds zh-Hans translation, font, and
    text-length-budget work to MVP. Server-provided content is still
    preserved verbatim per ADR-008.

Still Open / Deferred:
  - DEC-020 license model. The only remaining release-gating decision.

Documentation updates:
  - product-decision-register.md → v0.9.5. §3 statuses updated, §4
    renamed Recommended → Accepted with MODIFIED rows annotated,
    §6 collapsed to DEC-020 only, §7 dated and statused for every
    decision, change-history entry added.
  - poc-results-summary.md → v0.3.0. §4 expanded with the
    2026-05-14 owner-confirmation pass table. RISK-PoC-004 closed.
    New RISK-PoC-006 (Android minSdk move 24 → 28) and RISK-PoC-007
    (MVP language expansion to en + zh-Hans) added.
  - CHANGELOG entry under [Unreleased].
2026-05-14 20:46:29 +08:00
EdisonJwa 4c64517e45 docs(governance): promote audio PoC to PASS; close mobile-Android half
Documentation update following the Android audio spike pass.

Decision register (v0.9.3 → v0.9.4):
  - DEC-011.1 promoted from
      'Accepted (desktop: cpal) / Deferred (mobile)'
    to
      'Accepted (desktop: cpal; Android: cpal-on-Oboe) / Deferred (iOS)'.
  - Evidence pointer added: poc/audio-capture-playback-android-spike/
    VERIFICATION.md.

PoC plan (v0.2.0 → v0.3.0):
  - Audio row promoted from PARTIAL PASS to PASS.
  - All six PoC plan entries are now PASS.

PoC results summary (v0.1.0 → v0.2.0):
  - Audio row collapsed into one PASS spanning both spikes.
  - RISK-PoC-001 narrowed from 'mobile audio' to 'iOS audio only'.
  - Toolchain table expanded with Android NDK, cargo-ndk, AGP/
    Gradle/Kotlin, jni/ndk-context/android_logger, and the test
    device.

Cross-spike pointers updated:
  - poc/audio-capture-playback-spike/VERIFICATION.md result and
    follow-up sections updated to reference the Android spike.
  - poc/README.md status table lists both audio spike directories.

CHANGELOG updated under [Unreleased].
2026-05-14 18:25:03 +08:00
EdisonJwa ec21a880d2 feat(poc/audio): add Android mobile audio spike
Closes the mobile half of the PoC plan §2 audio exit criterion
left open by poc/audio-capture-playback-spike. The desktop and
mobile halves together fully retire the audio PoC.

Stack:
  Kotlin (MainActivity) → JNI → Rust cdylib
    → cpal 0.16 → Oboe (AAudio / OpenSL ES) → Android audio HAL

Layout:
  rust crate (src/lib.rs)   — JNI_OnLoad, initContext,
                              playSine440, record1sToFile;
                              panic-catching at JNI boundary;
                              android_logger → logcat
  android/ (Gradle 8.7,     — minSdk 24, compileSdk 34, AGP 8.5.2.
   AGP 8.5.2, Kotlin 1.9.24)  cargoBuildRust task wraps cargo-ndk
                              -P 26 -t <abi> for all four ABIs;
                              wired into preBuild so AGP picks up
                              the produced .so files.

Verified on 2026-05-13 on a physical Motorola Moto G Stylus 5G
(2023), Android 14 SDK 34 arm64-v8a:
  - Playback: 500 ms 440 Hz mono sine, 22,050 frames emitted at
    44.1 kHz through cpal/Oboe/AAudio/device speaker.
  - Capture: 1 s from default input, 42,624 frames written to
    /data/data/app.chanora.poc.audio/files/chanora_poc_capture.wav.
    File pulled via 'adb exec-out run-as ... cat' and confirmed
    by file(1) as 'RIFF (little-endian) data, WAVE audio,
    Microsoft PCM, 16 bit, mono 44100 Hz'. Header bytes
    cross-checked against the reported frame count.

Notes:
  - cpal links libaaudio (introduced API 26), so cargo-ndk targets
    API 26 via -P 26 while the Android module's minSdk stays at 24
    (DEC-004). API 24/25 devices would fall back to OpenSL ES at
    runtime; not exercised here.
  - JNI panic safety: every JNI entry point wraps its body in
    std::panic::catch_unwind and a tracing panic hook routes
    panic messages to logcat under tag 'ChanoraAudioPoC'. Without
    this, cpal panicking inside an extern "system" function would
    abort the process.
  - The emulator AVD chanora-poc-api34 and its system image were
    installed during Phase 0 but emulator verification was skipped
    once the physical-device run succeeded. Real-device evidence
    is stronger.

Surfaced finding: DEC-011.1 mobile half promoted from Deferred to
Accepted for Android in the same docs commit; iOS remains
explicitly Deferred (requires macOS + Xcode hardware).

Authority: PoC plan §2, DEC-011, DEC-011.1.
Not product code; not promoted into chanora_audio.
2026-05-14 18:24:53 +08:00
EdisonJwa eca93a141e build(repo): gitignore Android/Gradle build artifacts
Adds .gradle/, local.properties, and **/jniLibs/**/*.so to the
ignore list. The jniLibs .so files (600 KB - 950 KB per ABI) are
produced by the cargoBuildRust Gradle task wrapping cargo-ndk;
they are regenerated on every build and must not be tracked.

Required by the next commit (the Android audio spike) which
otherwise would try to track those four prebuilt libraries.
2026-05-14 18:24:33 +08:00
EdisonJwa 271d23faf7 docs(governance): record PoC outcomes, owner decisions, and audit evidence
Closes Phases A and D of the post-PoC sequencing.

Decision register (v0.9.2 → v0.9.3):
  - DEC-014 Accepted: flutter_rust_bridge 2.x pinned (closed by
    poc/flutter_rust_bridge_hello).
  - DEC-013.1 Accepted: rusqlite (bundled) (closed by
    poc/sqlite-storage-spike).
  - DEC-013.2 Accepted: Linux secure-storage backend policy —
    Secret Service preferred, keyutils fallback (closed by
    poc/secure-storage-spike; resolves SysRS-053 / SysRS-162
    ambiguity).
  - DEC-011.1 Accepted (desktop: cpal) / Deferred (mobile)
    (closed by poc/audio-capture-playback-spike desktop half only).
  - DEC-022 Accepted: canonical implementation directory layout per
    the README sketch and SAD §7.2.
  - DEC-020 explicitly Deferred by owner; remains a public-release
    blocker.

Audit reports updated with empirical evidence:
  - docs/security/secure-storage-audit-report.md v0.9.3:
    SS-AUD-001/002/003/005/006 = PoC Pass with evidence pointers;
    SS-TC-003 (Linux) Actual Result populated and Status = PoC Pass;
    SS-AUD-004 cross-referenced to diagnostics-redaction PoC;
    findings SS-FIND-001 (closed by DEC-013.2), SS-FIND-002 (keyutils
    session caveat), SS-FIND-003 (non-Linux adapters still open).
  - docs/security/diagnostic-redaction-audit-report.md v0.9.3:
    REDACT-TC-001..010 = PoC Pass with evidence pointers; export
    bundle policy §5 populated for every row; findings
    REDACT-FIND-001 (regex coverage), REDACT-FIND-002
    (tracing-layer integration), REDACT-FIND-003 (cross-spike
    KnownSecretRegistry contract).

PoC plan (v0.1.0 → v0.2.0):
  - Status column added to §2; outcomes recorded.

New doc:
  - docs/governance/poc-results-summary.md v0.1.0 — single-page
    reviewer-facing summary listing each spike's status, the
    toolchain exercised, the owner decisions taken, the audit
    coverage table, and open risks RISK-PoC-001..005 (mobile audio,
    non-Linux secure-storage adapters, license, remaining
    Proposed decisions, no product code yet).

This completes the post-PoC documentation work. Repo is at a clean
pause point: PoC code is committed, owner decisions are recorded,
audit reports carry empirical evidence, and the residual risks are
named in the summary doc.
2026-05-14 12:34:13 +08:00
EdisonJwa 181b3d329d docs(poc): add PoC index and record PoC outcomes in CHANGELOG
Adds poc/README.md as the top-level index across all six PoC spikes,
recording status (5 PASS, 1 PARTIAL PASS), authority, and the
non-promotion rule from proof-of-concept-plan.md §4.

Updates CHANGELOG.md to enumerate the six spikes with their
verification dates and to reference each spike's VERIFICATION.md.

This completes Phase E of the post-bootstrap sequencing:
  E.1  git init + baseline import
  E.2  justfile
  E.3..E.8  six PoC spikes
  E.9  PoC index + CHANGELOG  ← this commit
2026-05-14 12:27:13 +08:00
EdisonJwa d5b53996bc feat(poc/audio): add audio capture/playback spike (partial — desktop only)
Proof-of-concept addressing the audio exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Capture/playback works on at least one desktop and one mobile
   target."

PARTIAL PASS. The desktop half is verified on Linux; the mobile
half is NOT verified by this PoC and remains a documented open gap.

Implements via cpal (matching DEC-011 'platform-native first'):
  - AudioCapture::record_to_wav opens the default input device,
    handles f32/i16/u16 sample formats, down-mixes to mono, writes
    16-bit PCM WAV via hound.
  - AudioPlayback::play_wav opens the default output device, picks
    a stream config matching the WAV, blocks until drained.
  - synth_sine_wav produces a deterministic 440 Hz test signal for
    headless verification of the playback path when no microphone
    is available.
  - Typed AudioError DTO with NoInputDevice, NoOutputDevice,
    DefaultConfig, BuildStream, PlayStream, Wav, Io,
    UnsupportedFormat arms.

Verified on 2026-05-13 (Linux + cpal + PipeWire). Capture stream
opened against the system default input; build failed against the
auto_null source (typed AudioError::BuildStream returned cleanly,
demonstrating the production error path); fallback to synth fired;
playback drove 24,000 frames to completion through
Rust → cpal → ALSA → pcm_pipewire → PipeWire → auto_null.
Both audio.rs tests pass.

Mobile gap (explicit, NOT closed):
  - Android Oboe path not built or run.
  - iOS AVAudioEngine path not built or run.

Surfaced finding for the decision register: DEC-011 does not pin an
audio crate. The PoC uses cpal; production code needs an owner
ruling, ideally after the mobile spike closes the gap.

Out of scope: DSP (HPF/NS/AEC/AGC), Opus encode/decode, jitter
buffer, mixer, latency measurement, bit-exact loopback, device
permission flows. These belong to chanora_audio.

Authority: PoC plan §2, DEC-011, SysDes audio subsystem.
Not product code; not promoted into chanora_audio.
2026-05-14 12:27:04 +08:00
EdisonJwa 06ec6f2965 feat(poc/diagnostics): add diagnostics-redaction spike
Proof-of-concept proving the diagnostics-redaction exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Password and identity-secret samples are redacted."

Full coverage of the audit-report test matrix in
docs/security/diagnostic-redaction-audit-report.md §4
(REDACT-TC-001..010), plus two sanity tests.

The spike ships:
  - RedactionPolicy: typed catalogue of regex rules
    (identity-base64-blob, password-kv, ts3server-url-password,
     authorization-bearer, linux/windows/macos user-path) with
    optional capture-group narrowing.
  - Structured-field redaction keyed on case-insensitive name
    substrings (password, secret, token, ...).
  - Bundle-level switches: chat and channel tree excluded by
    default per audit-report §5.
  - KnownSecretRegistry: literal-substring scrub for secrets the
    host application has already loaded into memory (defense in
    depth that regexes alone cannot guarantee — closes the gap
    behind REDACT-TC-002).
  - Length cap (MAX_PROTOCOL_STRING_LEN = 256) with truncation
    marker for REDACT-TC-009.
  - UTF-8 preserved in non-sensitive fields per REDACT-TC-010 /
    ADR-008.

Test suite (12/12 PASS on 2026-05-13):
  REDACT-TC-001 server password in connection data
  REDACT-TC-002 identity secret in storage error (via KnownSecretRegistry)
  REDACT-TC-003 server URL with password field
  REDACT-TC-004 chat text excluded by default
  REDACT-TC-005 channel name with Unicode excluded by default
  REDACT-TC-006 nickname with Unicode preserved in safe field
  REDACT-TC-007 local file path user segment minimized
  REDACT-TC-008 mixed sensitive bundle (whole-bundle JSON scan)
  REDACT-TC-009 long hostile protocol string truncated
  REDACT-TC-010 multilingual safe text preserved
  + known-secret literal scrub
  + empty registered secret ignored

Out of scope: tracing-subscriber integration, diagnostic export
file format, memory/core dumps, performance, adversarial regex
evasion beyond trivial cases. These belong to chanora_diagnostics.

Authority: PoC plan §2, docs/security/diagnostic-redaction-audit-report.md,
SRS-093, SysRS-152/154/155.
Not product code; not promoted into chanora_diagnostics.
2026-05-14 12:26:49 +08:00
EdisonJwa 52e8d43f69 feat(poc/storage): add sqlite-storage spike
Proof-of-concept proving the SQLite-storage exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Schema, migration, and repository pattern are demonstrated."

Also satisfies the SRS-089 acceptance criteria explicitly:
  "Storage implementation uses an embedded local data store and
   migration mechanism."

Implements:
  - A forward-only Migrator over a fixed Migration list, tracking
    the applied version via PRAGMA user_version. Each migration is
    applied inside an IMMEDIATE transaction; rolled back on failure.
  - Three canonical migrations (initial schema, add nickname,
    add last_connected_at) demonstrating ALTER TABLE flows.
  - A LocalDatabaseRepository implementing both BookmarkRepository
    and SettingsRepository traits.
  - Bookmark.identity_ref is a reference to a secret name, never
    a secret value (cross-checked by the secure-storage spike's
    SS-AUD-001/002 scans). This is the SAD-067 separation.

Test suite (11/11 PASS on 2026-05-13):
  - migrator brings fresh DB to latest version
  - migrator is idempotent (no-op when already current)
  - migrator applies only pending versions (catch-up upgrade)
  - migrator rejects out-of-order versions
  - migrator rejects DB newer than known migrations (downgrade guard)
  - failed migration rolls back atomically
  - bookmark CRUD round-trip
  - bookmark list ordered by recency
  - bookmark UNIQUE(host, identity_ref) enforcement
  - settings upsert + delete
  - open creates file and persists across reopen

Surfaced finding for the decision register: DEC-013 does not pin a
SQLite crate. The PoC uses rusqlite with the bundled feature
(no system libsqlite3 dependency); production code needs an
owner ruling on rusqlite vs. sqlx vs. sea-orm.

Authority: PoC plan §2, SRS-089, SDD-077, SAD-067,
SysDes-033/036/049/091.
Not product code; not promoted into chanora_storage.
2026-05-14 12:26:36 +08:00
EdisonJwa 50c95b61ad feat(poc/storage): add secure-storage spike (Linux)
Proof-of-concept proving the secure-storage exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Secret write/read/delete works through platform secure storage."

Implements a typed SecretStorageRepository trait per ADR-006
(SecureStore + per-platform adapters) and a Linux adapter (the only
adapter in PoC scope) that supports both equivalent Linux backends
per SysRS-053/SysRS-162: Secret Service (libsecret) and kernel
keyutils.

The audit test suite covers:
  SS-AUD-001  identity secret absent from local DB (raw file scan)
  SS-AUD-002  server password absent from local DB
  SS-AUD-003  secrets absent from logs (Secret newtype redaction)
  SS-AUD-005  failure returns safe typed error (NotFound)
  SS-AUD-006  delete removes entry
  SS-TC-003   Linux round-trip set/get/delete

Verified on 2026-05-13 against the local keyutils backend (cargo
test runs need 'keyctl session -' to provide a valid session
keyring under non-interactive shells, documented in the spike
README). The CLI driver additionally observed a real locked
gnome-keyring collection and exercised the typed-error → fallback
path live.

Surfaced finding for the decision register: DEC-013 does not pin
a Linux secure-storage backend policy. Both Secret Service and
keyutils are 'equivalent' per the requirements; production code
needs an owner ruling.

Out of scope: Windows DPAPI, macOS/iOS Keychain, Android Keystore,
SS-AUD-004 (covered by diagnostics-redaction spike), SS-AUD-007/008
(process / migration items).

Authority: PoC plan §2, ADR-006, SDD-078, SRS-091..095,
SysRS-158..162.
Not product code; not promoted into chanora_storage.
2026-05-14 12:26:23 +08:00
EdisonJwa 2bbad5feb9 feat(poc/bridge): add flutter_rust_bridge hello spike
Proof-of-concept proving the Flutter/Rust bridge exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Flutter can call Rust and receive event stream data."

The spike exposes one synchronous fallible command (greet) returning
a typed GreetResult / GreetError DTO, and one async event stream
(counter_stream) emitting typed CounterTick events. The Flutter app
demonstrates both flows on a Material 3 surface; the headless test
suite in test/poc_verification_test.dart exercises the same API
directly through dart:ffi.

Verified on 2026-05-13 (Linux desktop, Flutter 3.41.9 / Dart 3.11.5,
flutter_rust_bridge 2.12.0, Rust 1.95). All three tests pass:
  - greet() returns typed result for valid input
  - greet() surfaces typed error for empty input
  - counterStream() delivers the expected event sequence

Authority: PoC plan §2, DEC-014 (typed Flutter/Rust bridge),
SAD-068, SDD-079, SysDes-049.
Naming note: the PoC plan lists this as flutter-rust-bridge-hello,
but Dart pubspec.yaml package names require underscores; the
directory uses underscores accordingly.
Not product code; not promoted into chanora_bridge.

Layout note: includes the full Flutter platform scaffold (android,
ios, macos, windows, web, linux). Only the Linux desktop target has
been built and verified.
2026-05-14 12:26:09 +08:00
EdisonJwa 02c11ead7e feat(poc/protocol): add tsclientlib connect spike
Proof-of-concept proving the protocol-feasibility exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Rust can connect to a compatible server/test double."

The spike opens a tsclientlib connection, waits for the BookEvents
state snapshot, subscribes to the server channel tree, prints
server metadata and the channel tree with client names, and
disconnects cleanly. Audio feature is disabled because audio is
covered by a separate PoC.

Verified on 2026-05-13 against cn.teamspeak.app (TeamSpeak 3 server
3.13.7); 36 channels and 5 online clients retrieved with full
UTF-8 (CJK) preservation. See
poc/tsclientlib-connect-spike/VERIFICATION.md for the captured run.

Authority: PoC plan §2, SysRS-005, SysDes-011, SysDes-029.
Not product code; not promoted into chanora_protocol.
2026-05-14 12:25:48 +08:00
EdisonJwa bdeab3b451 build(repo): add justfile to complete bootstrap v0.1.0
Adds the local task runner required by
docs/governance/repository-bootstrap-plan.md v0.1.0 §3, with stubs
for format, lint, test, verify-docs, and security-scan.

Closes the last gap in repository-bootstrap-plan v0.1.0; CI workflow
files remain deferred per the plan.
2026-05-14 12:25:38 +08:00
EdisonJwa f1bc9a6c85 chore(repo): initial baseline import (docs v0.9.2 + bootstrap)
Imports the v0.9.2 documentation baseline and the bootstrap files
required by docs/governance/repository-bootstrap-plan.md v0.1.0 §3,
minus the justfile (added in the next commit).

This commit establishes the git history for the project. All previous
work lived only as filesystem state with no version control.
2026-05-14 12:25:33 +08:00