Commit Graph
13 Commits
Author SHA1 Message Date
Edison Jwa 6c380fc7db feat(protocol): add file download support for avatars and icons
- Add Request::DownloadFile variant with oneshot reply
- Add ProtocolClient::download_avatar(client_uid) and download_icon(icon_id)
- Track pending file downloads by FiletransferHandle
- Handle StreamItem::FileDownload: read bytes from TCP stream
- Handle StreamItem::FiletransferFailed: map to ProtocolError
- Add ProtocolError::FileTransfer(String) variant
- Add path helper tests for avatar/icon download paths
- No tsclientlib types leak across the adapter boundary
2026-06-10 04:49:12 +09:00
Edison Jwa 31cf45ce35 feat(protocol): classify poke notification strength 2026-06-08 22:51:41 +09:00
Edison Jwa 808324f374 feat: event-driven UI updates for instant channel switching (#15)
* chore: regenerate Cargo.lock after rebase

* fix(ui): add 1s cool-down to prevent double-tap channel join

voiceJoin returns instantly (fire-and-forget protocol), so the
pending-join guard clears before a second tap lands. The cool-down
prevents the rapid channel oscillation and ClientIsFlooding (524)
that results from double-tapping.

* fix(ui): handle ChannelAlreadyIn as success, ClientIsFlooding with backoff

- ChannelAlreadyIn (0x0302): treat as silent success, update UI state
- ClientIsFlooding (0x020c): show localized snackbar, extend cooldown 5s
- Add l10n strings for flooding error (en + zh)

* fix(proto): use Windows TS3 client version for broadest compatibility

Matches Qint's default (Windows_3_X_X__1). Avoids server-side
behavioral differences with TS5 version strings.

* fix(proto): patch tsproto-types to handle short P-256 coordinates

BigInt::to_bytes_be() strips leading zeros, causing WrongPublicKeyLength
when a server's ephemeral key coordinate starts with 0x00. Patch from
EdisonJwa/tsclientlib fix/p256-short-coordinate-pad branch left-pads
coordinates to the P-256 field size instead of rejecting them.

* refactor(core): stop watchdog from emitting SnapshotChanged

The watchdog now serves only as a liveness probe (miss counting for
reconnection). UI updates are handled entirely by the event-driven
delta path (ProtocolDelta → SessionEvent → BridgeEvent → Flutter).

Removes signature tracking and SnapshotChanged emission from the
supervisor loop. The initial snapshot is still fetched via the
Connected event handler in Flutter.

* refactor(ui): remove channel-join cooldown guard

With event-driven deltas the UI updates instantly on channel moves,
so the 1-second cooldown is no longer needed. Double-taps are handled
by the server (ChannelAlreadyIn → success) and the pending-channel-id
guard prevents overlapping requests.

Also removes the _lastJoinCompletedAt field entirely.

* fix(core): reattach event forwarders after reconnect

The reconnect path swapped in a new ProtocolClient but never took
chat_rx, activity_rx, or delta_rx from it. After the first reconnect,
the event-driven UI pipeline was dead.

Fix by extracting spawn_event_forwarders() helper called on both
initial connect and reconnect. Also replaces lossy try_recv+sleep
polling with proper recv().await for push-based delivery.

* feat(protocol): enrich delta schema with all snapshot-visible fields

ClientJoined now carries input_muted, output_muted, is_server_query,
talk_power, talk_power_granted. ChannelAdded/ChannelUpdated now carry
has_password and needed_talk_power. ClientUpdated also carries
is_server_query, talk_power, talk_power_granted.

This prevents local snapshot drift where fabricated defaults could
hide password requirements, talk-power restrictions, or client type.

* refactor: remove dead SnapshotChanged variant end-to-end

SnapshotChanged is no longer emitted since the watchdog was refactored
to liveness-only. Removes the variant from SessionEvent, BridgeEvent,
and the Flutter switch statement. FRB bindings regenerated.

* fix(ci): regenerate license inventory and fix iOS submodule fetch

- Regenerate docs/security/license-inventory.md to match current lockfile
- Remove submodules: true from checkout (causes hard fail on private submodule)
- Add explicit git submodule update --init --depth=1 with || true fallback
- Check silero-coreml/Package.swift instead of directory existence
2026-06-03 18:20:33 +09:00
Edison Jwa fe6e07353e chore: restore product scaffold to rollback baseline 2026-05-29 14:02:04 +09:00
Edison Jwa eb9014cd81 feat: add TeamSpeak address resolver 2026-05-25 01:12:50 +09:00
Edison Jwa 7d5d8c2c90 feat: integrate chat voice and diagnostics client 2026-05-23 06:51:55 +09:00
EdisonJwa 2511b24982 fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy
Five user-reported defects + one auto-test regression-catcher.

== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.

Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.

Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.

New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.

== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.

Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.

Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
  carries the canonical TS3 error code per the official catalogue
  at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
  The connection-task loop holds a pending_moves HashMap keyed by
  MessageHandle, services StreamItem::MessageResult by looking up
  and resolving the reply with either Ok or the typed
  ServerRejected.
* Pending entries have a 3 s deadline so a server that never
  replies doesn't leak the reply channel — expired entries fall
  back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
  full snapshot poll), still polls for confirmation as a
  belt-and-braces fallback for legacy servers; on poll failure
  emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
  CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
  localised message selected by error code (channelJoinFailed*
  l10n entries). 6 known codes mapped to specific messages
  (insufficient permission, wrong password, channel full,
  family limit, private channel, timeout); everything else
  falls back to the server-supplied generic message.

== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.

Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.

== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.

Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).

== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
  +2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
2026-05-16 03:27:25 +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 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 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).
2026-05-14 22:43:57 +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.
2026-05-14 21:37:06 +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