diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..732de89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: ci + +on: + push: + branches: ["**"] + pull_request: + +jobs: + rust: + name: cargo check + cargo test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System deps (cpal / Opus / SQLite) + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev libpulse-dev pkg-config \ + libopus-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo check --workspace + run: cargo check --workspace --locked + - name: cargo test --workspace + run: cargo test --workspace --locked --no-fail-fast + - name: cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings + continue-on-error: true + + flutter: + name: flutter analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - name: flutter pub get + working-directory: apps/chanora_flutter + run: flutter pub get + - name: flutter analyze + working-directory: apps/chanora_flutter + run: flutter analyze + - name: flutter test (unit only) + working-directory: apps/chanora_flutter + run: flutter test --exclude-tags e2e || true diff --git a/Cargo.lock b/Cargo.lock index db45114..0b6a5b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,18 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -303,6 +315,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -314,6 +337,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chanora_audio" version = "0.0.1-pre" @@ -321,6 +357,8 @@ dependencies = [ "audiopus", "chanora_protocol", "cpal", + "jni 0.21.1", + "ndk-context", "thiserror 2.0.18", "tokio", "tracing", @@ -395,8 +433,12 @@ dependencies = [ name = "chanora_storage" version = "0.0.1-pre" dependencies = [ + "chacha20poly1305", + "rand 0.8.6", + "rusqlite", "thiserror 2.0.18", "tracing", + "zeroize", ] [[package]] @@ -407,6 +449,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -837,6 +880,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "ff" version = "0.13.1" @@ -1125,6 +1180,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1141,6 +1199,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1626,6 +1693,17 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" @@ -1961,6 +2039,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2056,6 +2140,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2249,7 +2344,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -2424,6 +2519,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -3350,6 +3459,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3391,6 +3510,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index b8b70d3..8f37858 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -8,6 +8,10 @@ "fieldServerHost": "Server address", "fieldNickname": "Nickname", + "fieldServerPassword": "Server password", + "fieldServerPasswordHelp": "Leave blank if the server does not require one.", + "fieldPassword": "Password", + "fieldDisplayName": "Display name", "connectAction": "Connect", "disconnectAction": "Disconnect", @@ -18,6 +22,17 @@ "startAudioAction": "Start audio", "pttHoldToTalk": "Hold to talk", "pttTransmitting": "Transmitting…", + "inputMuteAction": "Mute mic", + "inputUnmuteAction": "Unmute mic", + "outputMuteAction": "Mute speaker", + "outputUnmuteAction": "Unmute speaker", + "joinChannelAction": "Join channel", + "channelPasswordTitle": "Channel password", + "bookmarksHeading": "Bookmarks", + "bookmarksEmpty": "No bookmarks yet. Enter a server above and tap \"Save bookmark\".", + "bookmarkAddAction": "Save bookmark", + "bookmarkAddTitle": "Save bookmark", + "bookmarkDeleteAction": "Delete bookmark", "statusIdle": "Not connected", "statusConnecting": "Connecting…", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 809c580..148d9af 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -7,6 +7,10 @@ "fieldServerHost": "服务器地址", "fieldNickname": "昵称", + "fieldServerPassword": "服务器密码", + "fieldServerPasswordHelp": "如果服务器无需密码,留空即可。", + "fieldPassword": "密码", + "fieldDisplayName": "显示名称", "connectAction": "连接", "disconnectAction": "断开连接", @@ -17,6 +21,17 @@ "startAudioAction": "启动语音", "pttHoldToTalk": "按住说话", "pttTransmitting": "正在发送…", + "inputMuteAction": "静音麦克风", + "inputUnmuteAction": "取消麦克风静音", + "outputMuteAction": "静音扬声器", + "outputUnmuteAction": "取消扬声器静音", + "joinChannelAction": "加入频道", + "channelPasswordTitle": "频道密码", + "bookmarksHeading": "书签", + "bookmarksEmpty": "尚无书签。先在上方填写服务器,然后点击“保存书签”。", + "bookmarkAddAction": "保存书签", + "bookmarkAddTitle": "保存书签", + "bookmarkDeleteAction": "删除书签", "statusIdle": "未连接", "statusConnecting": "正在连接…", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index fa16534..d091f0d 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -121,6 +121,30 @@ abstract class AppL10n { /// **'Nickname'** String get fieldNickname; + /// No description provided for @fieldServerPassword. + /// + /// In en, this message translates to: + /// **'Server password'** + String get fieldServerPassword; + + /// No description provided for @fieldServerPasswordHelp. + /// + /// In en, this message translates to: + /// **'Leave blank if the server does not require one.'** + String get fieldServerPasswordHelp; + + /// No description provided for @fieldPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get fieldPassword; + + /// No description provided for @fieldDisplayName. + /// + /// In en, this message translates to: + /// **'Display name'** + String get fieldDisplayName; + /// No description provided for @connectAction. /// /// In en, this message translates to: @@ -175,6 +199,72 @@ abstract class AppL10n { /// **'Transmitting…'** String get pttTransmitting; + /// No description provided for @inputMuteAction. + /// + /// In en, this message translates to: + /// **'Mute mic'** + String get inputMuteAction; + + /// No description provided for @inputUnmuteAction. + /// + /// In en, this message translates to: + /// **'Unmute mic'** + String get inputUnmuteAction; + + /// No description provided for @outputMuteAction. + /// + /// In en, this message translates to: + /// **'Mute speaker'** + String get outputMuteAction; + + /// No description provided for @outputUnmuteAction. + /// + /// In en, this message translates to: + /// **'Unmute speaker'** + String get outputUnmuteAction; + + /// No description provided for @joinChannelAction. + /// + /// In en, this message translates to: + /// **'Join channel'** + String get joinChannelAction; + + /// No description provided for @channelPasswordTitle. + /// + /// In en, this message translates to: + /// **'Channel password'** + String get channelPasswordTitle; + + /// No description provided for @bookmarksHeading. + /// + /// In en, this message translates to: + /// **'Bookmarks'** + String get bookmarksHeading; + + /// No description provided for @bookmarksEmpty. + /// + /// In en, this message translates to: + /// **'No bookmarks yet. Enter a server above and tap \"Save bookmark\".'** + String get bookmarksEmpty; + + /// No description provided for @bookmarkAddAction. + /// + /// In en, this message translates to: + /// **'Save bookmark'** + String get bookmarkAddAction; + + /// No description provided for @bookmarkAddTitle. + /// + /// In en, this message translates to: + /// **'Save bookmark'** + String get bookmarkAddTitle; + + /// No description provided for @bookmarkDeleteAction. + /// + /// In en, this message translates to: + /// **'Delete bookmark'** + String get bookmarkDeleteAction; + /// No description provided for @statusIdle. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 5f0f472..3780c65 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -21,6 +21,19 @@ class AppL10nEn extends AppL10n { @override String get fieldNickname => 'Nickname'; + @override + String get fieldServerPassword => 'Server password'; + + @override + String get fieldServerPasswordHelp => + 'Leave blank if the server does not require one.'; + + @override + String get fieldPassword => 'Password'; + + @override + String get fieldDisplayName => 'Display name'; + @override String get connectAction => 'Connect'; @@ -48,6 +61,40 @@ class AppL10nEn extends AppL10n { @override String get pttTransmitting => 'Transmitting…'; + @override + String get inputMuteAction => 'Mute mic'; + + @override + String get inputUnmuteAction => 'Unmute mic'; + + @override + String get outputMuteAction => 'Mute speaker'; + + @override + String get outputUnmuteAction => 'Unmute speaker'; + + @override + String get joinChannelAction => 'Join channel'; + + @override + String get channelPasswordTitle => 'Channel password'; + + @override + String get bookmarksHeading => 'Bookmarks'; + + @override + String get bookmarksEmpty => + 'No bookmarks yet. Enter a server above and tap \"Save bookmark\".'; + + @override + String get bookmarkAddAction => 'Save bookmark'; + + @override + String get bookmarkAddTitle => 'Save bookmark'; + + @override + String get bookmarkDeleteAction => 'Delete bookmark'; + @override String get statusIdle => 'Not connected'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index cb89e0b..4d3eac1 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -20,6 +20,18 @@ class AppL10nZh extends AppL10n { @override String get fieldNickname => '昵称'; + @override + String get fieldServerPassword => '服务器密码'; + + @override + String get fieldServerPasswordHelp => '如果服务器无需密码,留空即可。'; + + @override + String get fieldPassword => '密码'; + + @override + String get fieldDisplayName => '显示名称'; + @override String get connectAction => '连接'; @@ -47,6 +59,39 @@ class AppL10nZh extends AppL10n { @override String get pttTransmitting => '正在发送…'; + @override + String get inputMuteAction => '静音麦克风'; + + @override + String get inputUnmuteAction => '取消麦克风静音'; + + @override + String get outputMuteAction => '静音扬声器'; + + @override + String get outputUnmuteAction => '取消扬声器静音'; + + @override + String get joinChannelAction => '加入频道'; + + @override + String get channelPasswordTitle => '频道密码'; + + @override + String get bookmarksHeading => '书签'; + + @override + String get bookmarksEmpty => '尚无书签。先在上方填写服务器,然后点击“保存书签”。'; + + @override + String get bookmarkAddAction => '保存书签'; + + @override + String get bookmarkAddTitle => '保存书签'; + + @override + String get bookmarkDeleteAction => '删除书签'; + @override String get statusIdle => '未连接'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 4f72ed5..ca8e8b8 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1,13 +1,12 @@ -// Chanora Flutter application — Beta build (v0.2.0-beta.1). +// Chanora Flutter application — External Beta build. // -// Adds voice in/out via push-to-talk on top of the Alpha UI: -// 1. Connect form + channel/client tree (Alpha) -// 2. "Start audio" button after connect → opens the audio engine -// 3. Push-to-talk button: hold to transmit, release to stop -// 4. Live audio stats line (TX/RX frame counts) -// -// Audio rendering on the speaker is automatic once the engine -// starts; nothing to wire on the Dart side beyond that. +// Builds on Internal Beta v0.3.0-beta.1: +// * server password field +// * bookmark list with add / connect / delete actions +// * channel tap-to-join with optional channel password +// * self input + output mute toggles + master output gain slider +// * diagnostics dialog + reconnect banner + identity persistence +// (all carried over from v0.3.0-beta.1) import 'dart:async'; @@ -23,13 +22,7 @@ import 'src/rust/frb_generated.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); - // A.2 — wire identity persistence to the platform's app-private - // directory so the same TS3 UID is presented on every restart. - // Beta: stored as a plain 0600 file (RISK-PoC-002). unawaited(_wireStorage()); - // A.6.1 — push the OS-reported connectivity state into the core - // supervisor so reconnects redial promptly when the network - // returns. unawaited(_wireConnectivity()); runApp(const ChanoraApp()); } @@ -39,15 +32,11 @@ Future _wireStorage() async { final dir = await getApplicationSupportDirectory(); await rust.initStorage(dir: dir.path); } catch (_) { - // Storage is best-effort — on failure the app still works but - // each session gets a fresh ephemeral identity. + // Best-effort; missing storage just means no identity persistence + // and no bookmark list this session. } } -/// Map `connectivity_plus`' list-of-results to our coarse tri-state. -/// We consider the device "Online" if any of the reported transports -/// is non-`none`. This is intentionally permissive — the supervisor's -/// watchdog still verifies reachability against the actual server. rust.BridgeNetworkState _mapConnectivity(List results) { if (results.isEmpty) return rust.BridgeNetworkState.unknown; final allNone = results.every((r) => r == ConnectivityResult.none); @@ -57,16 +46,10 @@ rust.BridgeNetworkState _mapConnectivity(List results) { Future _wireConnectivity() async { final connectivity = Connectivity(); - // Seed with the current value so the supervisor has a real reading - // before the first transition. try { final initial = await connectivity.checkConnectivity(); rust.setNetworkState(state: _mapConnectivity(initial)); - } catch (_) { - // Best effort; if the plugin isn't available on this platform - // we stay at Unknown and the supervisor falls back to its - // watchdog-only behaviour. - } + } catch (_) {} connectivity.onConnectivityChanged.listen((results) { rust.setNetworkState(state: _mapConnectivity(results)); }); @@ -102,6 +85,7 @@ class _BetaHome extends StatefulWidget { class _BetaHomeState extends State<_BetaHome> { final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); final _nickCtl = TextEditingController(text: 'ChanoraBeta'); + final _passwordCtl = TextEditingController(); _Phase _phase = _Phase.idle; rust.BridgeSnapshot? _snapshot; @@ -111,15 +95,31 @@ class _BetaHomeState extends State<_BetaHome> { Timer? _statsTimer; StreamSubscription? _eventsSub; - // A.6 reconnect banner state. String? _lostReason; int? _reconnectAttempt; int? _reconnectDelay; + bool _inputMuted = false; + bool _outputMuted = false; + double _outputGain = 1.0; + + List _bookmarks = const []; + @override void initState() { super.initState(); _eventsSub = rust.eventsStream().listen(_onEvent); + unawaited(_reloadBookmarks()); + } + + Future _reloadBookmarks() async { + try { + final list = await rust.listBookmarks(); + if (!mounted) return; + setState(() => _bookmarks = list); + } catch (_) { + // Bookmark store missing on this platform — empty list is fine. + } } void _onEvent(rust.BridgeEvent evt) { @@ -155,10 +155,6 @@ class _BetaHomeState extends State<_BetaHome> { case rust.BridgeEvent_AudioStopped(): setState(() => _audioStarted = false); case rust.BridgeEvent_SnapshotChanged(): - // A.4: the supervisor's watchdog observed a tree change. - // Trigger a single async refresh so the channel/client - // list stays current without a polling timer on the Dart - // side. unawaited(_onRefresh()); } } @@ -169,10 +165,15 @@ class _BetaHomeState extends State<_BetaHome> { _statsTimer?.cancel(); _hostCtl.dispose(); _nickCtl.dispose(); + _passwordCtl.dispose(); super.dispose(); } - Future _onConnect() async { + Future _onConnect({ + String? host, + String? nickname, + String? password, + }) async { setState(() { _phase = _Phase.connecting; _error = null; @@ -180,8 +181,9 @@ class _BetaHomeState extends State<_BetaHome> { }); try { final snap = await rust.connect( - host: _hostCtl.text.trim(), - nickname: _nickCtl.text.trim(), + host: (host ?? _hostCtl.text).trim(), + nickname: (nickname ?? _nickCtl.text).trim(), + password: password ?? _passwordCtl.text, ); if (!mounted) return; setState(() { @@ -225,6 +227,87 @@ class _BetaHomeState extends State<_BetaHome> { } } + Future _toggleInputMute() async { + final next = !_inputMuted; + try { + await rust.setInputMuted(muted: next); + if (!mounted) return; + setState(() => _inputMuted = next); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _toggleOutputMute() async { + final next = !_outputMuted; + try { + await rust.setOutputMuted(muted: next); + if (!mounted) return; + setState(() => _outputMuted = next); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _setOutputGain(double value) async { + setState(() => _outputGain = value); + try { + await rust.setOutputGain(gain: value); + } catch (_) { + // Audio may not be started yet — that's fine; the next start_audio + // picks up the current slider position next time we plumb it. + } + } + + Future _onJoinChannel(rust.BridgeChannel ch) async { + final l10n = AppL10n.of(context); + String? password; + // Heuristic: a channel name annotated with a lock prompts. + if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) { + password = await _askChannelPassword(l10n); + if (password == null) return; // cancelled + } + try { + await rust.moveToChannel( + channelId: ch.id, + password: password ?? '', + ); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _askChannelPassword(AppL10n l10n) async { + final ctl = TextEditingController(); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.channelPasswordTitle), + content: TextField( + controller: ctl, + obscureText: true, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldPassword), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(ctl.text), + child: Text(l10n.connectAction), + ), + ], + ), + ); + ctl.dispose(); + return result; + } + Future _onRefresh() async { try { final snap = await rust.snapshot(); @@ -249,12 +332,12 @@ class _BetaHomeState extends State<_BetaHome> { _audioStarted = false; _audioStats = null; _error = null; + _inputMuted = false; + _outputMuted = false; }); } Future _onShowDiagnostics(BuildContext context) async { - // A.3: user-initiated diagnostic export (DEC-016). Content is - // already redacted on the Rust side; we just present it. final l10n = AppL10n.of(context); final text = rust.exportDiagnostics(); if (!mounted) return; @@ -286,6 +369,70 @@ class _BetaHomeState extends State<_BetaHome> { ); } + Future _onAddCurrentBookmark() async { + final l10n = AppL10n.of(context); + final nameCtl = TextEditingController(text: _hostCtl.text.trim()); + final name = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.bookmarkAddTitle), + content: TextField( + controller: nameCtl, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldDisplayName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(nameCtl.text), + child: Text(l10n.bookmarkAddAction), + ), + ], + ), + ); + nameCtl.dispose(); + if (name == null || name.trim().isEmpty) return; + try { + await rust.addBookmark( + b: rust.BridgeBookmark( + id: 0, + displayName: name.trim(), + host: _hostCtl.text.trim(), + nickname: _nickCtl.text.trim(), + password: _passwordCtl.text, + ), + ); + await _reloadBookmarks(); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _onDeleteBookmark(rust.BridgeBookmark b) async { + try { + await rust.deleteBookmark(id: b.id); + await _reloadBookmarks(); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _onUseBookmark(rust.BridgeBookmark b) async { + _hostCtl.text = b.host; + _nickCtl.text = b.nickname; + _passwordCtl.text = b.password; + await _onConnect( + host: b.host, + nickname: b.nickname, + password: b.password, + ); + } + @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); @@ -381,10 +528,27 @@ class _BetaHomeState extends State<_BetaHome> { ], const SizedBox(height: 12), if (_phase == _Phase.idle) ...[ - _ConnectForm( - hostCtl: _hostCtl, - nickCtl: _nickCtl, - onConnect: _onConnect, + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ConnectForm( + hostCtl: _hostCtl, + nickCtl: _nickCtl, + passwordCtl: _passwordCtl, + onConnect: () => _onConnect(), + onAddBookmark: _onAddCurrentBookmark, + ), + const SizedBox(height: 16), + _BookmarkList( + bookmarks: _bookmarks, + onConnect: _onUseBookmark, + onDelete: _onDeleteBookmark, + ), + ], + ), + ), ), ] else if (_phase == _Phase.connecting) ...[ const Center( @@ -394,7 +558,6 @@ class _BetaHomeState extends State<_BetaHome> { ), ), ] else if (_phase == _Phase.connected && _snapshot != null) ...[ - // Audio row: start button or stats + PTT. if (!_audioStarted) ...[ FilledButton.icon( icon: const Icon(Icons.mic_none), @@ -405,12 +568,23 @@ class _BetaHomeState extends State<_BetaHome> { ] else ...[ _AudioControls( stats: _audioStats, + inputMuted: _inputMuted, + outputMuted: _outputMuted, + outputGain: _outputGain, onPttDown: () => _setPtt(true), onPttUp: () => _setPtt(false), + onToggleInputMute: _toggleInputMute, + onToggleOutputMute: _toggleOutputMute, + onGainChanged: _setOutputGain, ), const SizedBox(height: 12), ], - Expanded(child: _SnapshotView(snapshot: _snapshot!)), + Expanded( + child: _SnapshotView( + snapshot: _snapshot!, + onJoinChannel: _onJoinChannel, + ), + ), ], ], ), @@ -423,12 +597,16 @@ class _ConnectForm extends StatelessWidget { const _ConnectForm({ required this.hostCtl, required this.nickCtl, + required this.passwordCtl, required this.onConnect, + required this.onAddBookmark, }); final TextEditingController hostCtl; final TextEditingController nickCtl; + final TextEditingController passwordCtl; final VoidCallback onConnect; + final VoidCallback onAddBookmark; @override Widget build(BuildContext context) { @@ -451,12 +629,91 @@ class _ConnectForm extends StatelessWidget { border: const OutlineInputBorder(), ), ), - const SizedBox(height: 16), - FilledButton.icon( - icon: const Icon(Icons.login), - label: Text(l10n.connectAction), - onPressed: onConnect, + const SizedBox(height: 8), + TextField( + controller: passwordCtl, + obscureText: true, + decoration: InputDecoration( + labelText: l10n.fieldServerPassword, + helperText: l10n.fieldServerPasswordHelp, + border: const OutlineInputBorder(), + ), ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.login), + label: Text(l10n.connectAction), + onPressed: onConnect, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + icon: const Icon(Icons.bookmark_add_outlined), + label: Text(l10n.bookmarkAddAction), + onPressed: onAddBookmark, + ), + ], + ), + ], + ); + } +} + +class _BookmarkList extends StatelessWidget { + const _BookmarkList({ + required this.bookmarks, + required this.onConnect, + required this.onDelete, + }); + + final List bookmarks; + final ValueChanged onConnect; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + if (bookmarks.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + l10n.bookmarksEmpty, + style: theme.textTheme.bodySmall, + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + for (final b in bookmarks) + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + title: Text(b.displayName), + subtitle: Text('${b.host} — ${b.nickname}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.login), + tooltip: l10n.connectAction, + onPressed: () => onConnect(b), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: l10n.bookmarkDeleteAction, + onPressed: () => onDelete(b), + ), + ], + ), + ), + ), ], ); } @@ -465,13 +722,25 @@ class _ConnectForm extends StatelessWidget { class _AudioControls extends StatefulWidget { const _AudioControls({ required this.stats, + required this.inputMuted, + required this.outputMuted, + required this.outputGain, required this.onPttDown, required this.onPttUp, + required this.onToggleInputMute, + required this.onToggleOutputMute, + required this.onGainChanged, }); final rust.BridgeAudioStats? stats; + final bool inputMuted; + final bool outputMuted; + final double outputGain; final VoidCallback onPttDown; final VoidCallback onPttUp; + final VoidCallback onToggleInputMute; + final VoidCallback onToggleOutputMute; + final ValueChanged onGainChanged; @override State<_AudioControls> createState() => _AudioControlsState(); @@ -540,7 +809,55 @@ class _AudioControlsState extends State<_AudioControls> { ), ), ), - const SizedBox(height: 6), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: FilterChip( + avatar: Icon( + widget.inputMuted ? Icons.mic_off : Icons.mic, + size: 18, + ), + label: Text( + widget.inputMuted ? l10n.inputUnmuteAction : l10n.inputMuteAction, + ), + selected: widget.inputMuted, + onSelected: (_) => widget.onToggleInputMute(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilterChip( + avatar: Icon( + widget.outputMuted ? Icons.volume_off : Icons.volume_up, + size: 18, + ), + label: Text( + widget.outputMuted ? l10n.outputUnmuteAction : l10n.outputMuteAction, + ), + selected: widget.outputMuted, + onSelected: (_) => widget.onToggleOutputMute(), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.volume_down, size: 18), + Expanded( + child: Slider( + value: widget.outputGain.clamp(0.0, 2.0), + min: 0.0, + max: 2.0, + divisions: 40, + label: '${(widget.outputGain * 100).round()}%', + onChanged: widget.onGainChanged, + ), + ), + const Icon(Icons.volume_up, size: 18), + ], + ), Text(statsText, style: theme.textTheme.bodySmall), ], ); @@ -548,9 +865,13 @@ class _AudioControlsState extends State<_AudioControls> { } class _SnapshotView extends StatelessWidget { - const _SnapshotView({required this.snapshot}); + const _SnapshotView({ + required this.snapshot, + required this.onJoinChannel, + }); final rust.BridgeSnapshot snapshot; + final ValueChanged onJoinChannel; @override Widget build(BuildContext context) { @@ -582,7 +903,6 @@ class _SnapshotView extends StatelessWidget { color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), - // Server-provided content; preserved verbatim per ADR-008. child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall), ), ], @@ -595,6 +915,12 @@ class _SnapshotView extends StatelessWidget { leading: const Icon(Icons.tag), title: Text(ch.name), subtitle: Text('id=${ch.id} parent=${ch.parent}'), + trailing: IconButton( + icon: const Icon(Icons.login), + tooltip: l10n.joinChannelAction, + onPressed: () => onJoinChannel(ch), + ), + onTap: () => onJoinChannel(ch), ), for (final cl in byChannel[ch.id] ?? const []) Padding( diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 58ed6ca..9b21dd2 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -10,15 +10,23 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `log_sink`, `runtime`, `session` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from` /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant /// via [`BridgeError::AlreadyConnected`]. +/// +/// `password` is optional — pass an empty string for servers that +/// don't require one. Future connect({ required String host, required String nickname, -}) => RustLib.instance.api.crateApiConnect(host: host, nickname: nickname); + required String password, +}) => RustLib.instance.api.crateApiConnect( + host: host, + nickname: nickname, + password: password, +); /// Re-fetch a fresh snapshot from the active connection. Future snapshot() => RustLib.instance.api.crateApiSnapshot(); @@ -37,6 +45,36 @@ Future startAudio() => RustLib.instance.api.crateApiStartAudio(); Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); +/// Move our own client to `channel_id`. Optional channel password +/// for password-protected channels — pass an empty string when not +/// required. +Future moveToChannel({ + required BigInt channelId, + required String password, +}) => RustLib.instance.api.crateApiMoveToChannel( + channelId: channelId, + password: password, +); + +/// Toggle self input-mute (microphone) on the server. Independent +/// of push-to-talk: a muted client never transmits regardless of +/// PTT state. +Future setInputMuted({required bool muted}) => + RustLib.instance.api.crateApiSetInputMuted(muted: muted); + +/// Toggle self output-mute (speaker). Mutes locally *and* informs +/// the server. The server uses this for the channel icon next to +/// the client name; the local mute kicks in immediately even +/// before the server acknowledges. +Future setOutputMuted({required bool muted}) => + RustLib.instance.api.crateApiSetOutputMuted(muted: muted); + +/// Set master output gain. `1.0` is unity, `0.0` is silent. Values +/// above `1.0` amplify and can clip downstream. Errors when audio +/// is not started. +Future setOutputGain({required double gain}) => + RustLib.instance.api.crateApiSetOutputGain(gain: gain); + /// User-initiated diagnostic export. Returns a multi-line text /// blob, redacted per the production policy, that the user can /// share or copy. DEC-016 forbids automatic uploads — this is the @@ -58,6 +96,23 @@ String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics(); Future initStorage({required String dir}) => RustLib.instance.api.crateApiInitStorage(dir: dir); +/// List persisted bookmarks. +Future> listBookmarks() => + RustLib.instance.api.crateApiListBookmarks(); + +/// Insert a bookmark and return its assigned id. The `id` field on +/// the input is ignored. +Future addBookmark({required BridgeBookmark b}) => + RustLib.instance.api.crateApiAddBookmark(b: b); + +/// Update an existing bookmark. +Future updateBookmark({required BridgeBookmark b}) => + RustLib.instance.api.crateApiUpdateBookmark(b: b); + +/// Delete a bookmark by id. +Future deleteBookmark({required PlatformInt64 id}) => + RustLib.instance.api.crateApiDeleteBookmark(id: id); + /// Notify the core of the latest OS-reported connectivity state. /// Called by the Flutter side from `connectivity_plus` callbacks. /// The core's supervisor uses this to (a) pre-charge the watchdog @@ -107,6 +162,52 @@ class BridgeAudioStats { pttActive == other.pttActive; } +/// Bookmark DTO mirroring [`chanora_core::Bookmark`]. +class BridgeBookmark { + /// Row id assigned by SQLite. Use `0` when adding new rows; + /// the returned id is then meaningful. + final PlatformInt64 id; + + /// User-facing label. + final String displayName; + + /// `hostname[:port]` or TSDNS name. + final String host; + + /// Nickname to use for this bookmark. + final String nickname; + + /// Optional remembered password. Empty string = none. + final String password; + + const BridgeBookmark({ + required this.id, + required this.displayName, + required this.host, + required this.nickname, + required this.password, + }); + + @override + int get hashCode => + id.hashCode ^ + displayName.hashCode ^ + host.hashCode ^ + nickname.hashCode ^ + password.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BridgeBookmark && + runtimeType == other.runtimeType && + id == other.id && + displayName == other.displayName && + host == other.host && + nickname == other.nickname && + password == other.password; +} + /// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo` /// but with primitive `u64` ids so the Dart side gets `BigInt`s /// without any wrapper-type ceremony. diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 778766f..d7d4b68 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 2126340080; + int get rustContentHash => 663465485; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -79,6 +79,8 @@ class RustLib extends BaseEntrypoint { } abstract class RustLibApi extends BaseApi { + Future crateApiAddBookmark({required BridgeBookmark b}); + Future crateApiAudioStats(); Future crateApiBridgeInit(); @@ -86,8 +88,11 @@ abstract class RustLibApi extends BaseApi { Future crateApiConnect({ required String host, required String nickname, + required String password, }); + Future crateApiDeleteBookmark({required PlatformInt64 id}); + Future crateApiDisconnect(); Stream crateApiEventsStream(); @@ -98,13 +103,28 @@ abstract class RustLibApi extends BaseApi { Future crateApiIsConnected(); + Future> crateApiListBookmarks(); + + Future crateApiMoveToChannel({ + required BigInt channelId, + required String password, + }); + + Future crateApiSetInputMuted({required bool muted}); + void crateApiSetNetworkState({required BridgeNetworkState state}); + Future crateApiSetOutputGain({required double gain}); + + Future crateApiSetOutputMuted({required bool muted}); + Future crateApiSetPtt({required bool active}); Future crateApiSnapshot(); Future crateApiStartAudio(); + + Future crateApiUpdateBookmark({required BridgeBookmark b}); } class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @@ -115,6 +135,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required super.portManager, }); + @override + Future crateApiAddBookmark({required BridgeBookmark b}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_bridge_bookmark(b, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 1, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_i_64, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiAddBookmarkConstMeta, + argValues: [b], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiAddBookmarkConstMeta => + const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]); + @override Future crateApiAudioStats() { return handler.executeNormal( @@ -124,7 +172,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 1, + funcId: 2, port: port_, ); }, @@ -151,7 +199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 2, + funcId: 3, port: port_, ); }, @@ -173,6 +221,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { Future crateApiConnect({ required String host, required String nickname, + required String password, }) { return handler.executeNormal( NormalTask( @@ -180,10 +229,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(host, serializer); sse_encode_String(nickname, serializer); + sse_encode_String(password, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 3, + funcId: 4, port: port_, ); }, @@ -192,14 +242,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_bridge_error, ), constMeta: kCrateApiConnectConstMeta, - argValues: [host, nickname], + argValues: [host, nickname, password], apiImpl: this, ), ); } - TaskConstMeta get kCrateApiConnectConstMeta => - const TaskConstMeta(debugName: "connect", argNames: ["host", "nickname"]); + TaskConstMeta get kCrateApiConnectConstMeta => const TaskConstMeta( + debugName: "connect", + argNames: ["host", "nickname", "password"], + ); + + @override + Future crateApiDeleteBookmark({required PlatformInt64 id}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(id, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiDeleteBookmarkConstMeta, + argValues: [id], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiDeleteBookmarkConstMeta => + const TaskConstMeta(debugName: "delete_bookmark", argNames: ["id"]); @override Future crateApiDisconnect() { @@ -210,7 +290,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 4, + funcId: 6, port: port_, ); }, @@ -240,7 +320,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 5, + funcId: 7, port: port_, ); }, @@ -266,7 +346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -292,7 +372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 7, + funcId: 9, port: port_, ); }, @@ -319,7 +399,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 8, + funcId: 10, port: port_, ); }, @@ -337,6 +417,95 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiIsConnectedConstMeta => const TaskConstMeta(debugName: "is_connected", argNames: []); + @override + Future> crateApiListBookmarks() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 11, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_bridge_bookmark, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiListBookmarksConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiListBookmarksConstMeta => + const TaskConstMeta(debugName: "list_bookmarks", argNames: []); + + @override + Future crateApiMoveToChannel({ + required BigInt channelId, + required String password, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(channelId, serializer); + sse_encode_String(password, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiMoveToChannelConstMeta, + argValues: [channelId, password], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiMoveToChannelConstMeta => const TaskConstMeta( + debugName: "move_to_channel", + argNames: ["channelId", "password"], + ); + + @override + Future crateApiSetInputMuted({required bool muted}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(muted, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 13, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetInputMutedConstMeta, + argValues: [muted], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetInputMutedConstMeta => + const TaskConstMeta(debugName: "set_input_muted", argNames: ["muted"]); + @override void crateApiSetNetworkState({required BridgeNetworkState state}) { return handler.executeSync( @@ -344,7 +513,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -360,6 +529,62 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiSetNetworkStateConstMeta => const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]); + @override + Future crateApiSetOutputGain({required double gain}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_f_32(gain, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 15, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetOutputGainConstMeta, + argValues: [gain], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetOutputGainConstMeta => + const TaskConstMeta(debugName: "set_output_gain", argNames: ["gain"]); + + @override + Future crateApiSetOutputMuted({required bool muted}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(muted, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 16, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetOutputMutedConstMeta, + argValues: [muted], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetOutputMutedConstMeta => + const TaskConstMeta(debugName: "set_output_muted", argNames: ["muted"]); + @override Future crateApiSetPtt({required bool active}) { return handler.executeNormal( @@ -370,7 +595,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 17, port: port_, ); }, @@ -397,7 +622,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 18, port: port_, ); }, @@ -424,7 +649,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 19, port: port_, ); }, @@ -442,6 +667,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiStartAudioConstMeta => const TaskConstMeta(debugName: "start_audio", argNames: []); + @override + Future crateApiUpdateBookmark({required BridgeBookmark b}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_bridge_bookmark(b, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 20, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiUpdateBookmarkConstMeta, + argValues: [b], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiUpdateBookmarkConstMeta => + const TaskConstMeta(debugName: "update_bookmark", argNames: ["b"]); + @protected AnyhowException dco_decode_AnyhowException(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -468,6 +721,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as bool; } + @protected + BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_bridge_bookmark(raw); + } + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -481,6 +740,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + BridgeBookmark dco_decode_bridge_bookmark(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + return BridgeBookmark( + id: dco_decode_i_64(arr[0]), + displayName: dco_decode_String(arr[1]), + host: dco_decode_String(arr[2]), + nickname: dco_decode_String(arr[3]), + password: dco_decode_String(arr[4]), + ); + } + @protected BridgeChannel dco_decode_bridge_channel(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -583,6 +857,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + double dco_decode_f_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as double; + } + @protected int dco_decode_i_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -595,6 +875,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dcoDecodeI64(raw); } + @protected + List dco_decode_list_bridge_bookmark(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_bridge_bookmark).toList(); + } + @protected List dco_decode_list_bridge_channel(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -665,6 +951,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8() != 0; } + @protected + BridgeBookmark sse_decode_box_autoadd_bridge_bookmark( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_bridge_bookmark(deserializer)); + } + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -678,6 +972,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + BridgeBookmark sse_decode_bridge_bookmark(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_i_64(deserializer); + var var_displayName = sse_decode_String(deserializer); + var var_host = sse_decode_String(deserializer); + var var_nickname = sse_decode_String(deserializer); + var var_password = sse_decode_String(deserializer); + return BridgeBookmark( + id: var_id, + displayName: var_displayName, + host: var_host, + nickname: var_nickname, + password: var_password, + ); + } + @protected BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -796,6 +1107,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + double sse_decode_f_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getFloat32(); + } + @protected int sse_decode_i_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -808,6 +1125,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getPlatformInt64(); } + @protected + List sse_decode_list_bridge_bookmark( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_bridge_bookmark(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_bridge_channel( SseDeserializer deserializer, @@ -904,6 +1235,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8(self ? 1 : 0); } + @protected + void sse_encode_box_autoadd_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bridge_bookmark(self, serializer); + } + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, @@ -915,6 +1255,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(self.pttActive, serializer); } + @protected + void sse_encode_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_64(self.id, serializer); + sse_encode_String(self.displayName, serializer); + sse_encode_String(self.host, serializer); + sse_encode_String(self.nickname, serializer); + sse_encode_String(self.password, serializer); + } + @protected void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1013,6 +1366,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_bridge_client(self.clients, serializer); } + @protected + void sse_encode_f_32(double self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putFloat32(self); + } + @protected void sse_encode_i_32(int self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1025,6 +1384,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putPlatformInt64(self); } + @protected + void sse_encode_list_bridge_bookmark( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_bridge_bookmark(item, serializer); + } + } + @protected void sse_encode_list_bridge_channel( List self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index 3005cf5..bed705f 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -33,9 +33,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool dco_decode_bool(dynamic raw); + @protected + BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw); + @protected + BridgeBookmark dco_decode_bridge_bookmark(dynamic raw); + @protected BridgeChannel dco_decode_bridge_channel(dynamic raw); @@ -54,12 +60,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + double dco_decode_f_32(dynamic raw); + @protected int dco_decode_i_32(dynamic raw); @protected PlatformInt64 dco_decode_i_64(dynamic raw); + @protected + List dco_decode_list_bridge_bookmark(dynamic raw); + @protected List dco_decode_list_bridge_channel(dynamic raw); @@ -95,9 +107,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool sse_decode_bool(SseDeserializer deserializer); + @protected + BridgeBookmark sse_decode_box_autoadd_bridge_bookmark( + SseDeserializer deserializer, + ); + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer); + @protected + BridgeBookmark sse_decode_bridge_bookmark(SseDeserializer deserializer); + @protected BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer); @@ -118,12 +138,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + double sse_decode_f_32(SseDeserializer deserializer); + @protected int sse_decode_i_32(SseDeserializer deserializer); @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + @protected + List sse_decode_list_bridge_bookmark( + SseDeserializer deserializer, + ); + @protected List sse_decode_list_bridge_channel( SseDeserializer deserializer, @@ -167,12 +195,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bool(bool self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, SseSerializer serializer, ); + @protected + void sse_encode_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer); @@ -197,12 +237,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + @protected void sse_encode_i_32(int self, SseSerializer serializer); @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + @protected + void sse_encode_list_bridge_bookmark( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_list_bridge_channel( List self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index 5e189b5..fa7ebb7 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -35,9 +35,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool dco_decode_bool(dynamic raw); + @protected + BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw); + @protected + BridgeBookmark dco_decode_bridge_bookmark(dynamic raw); + @protected BridgeChannel dco_decode_bridge_channel(dynamic raw); @@ -56,12 +62,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + double dco_decode_f_32(dynamic raw); + @protected int dco_decode_i_32(dynamic raw); @protected PlatformInt64 dco_decode_i_64(dynamic raw); + @protected + List dco_decode_list_bridge_bookmark(dynamic raw); + @protected List dco_decode_list_bridge_channel(dynamic raw); @@ -97,9 +109,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool sse_decode_bool(SseDeserializer deserializer); + @protected + BridgeBookmark sse_decode_box_autoadd_bridge_bookmark( + SseDeserializer deserializer, + ); + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer); + @protected + BridgeBookmark sse_decode_bridge_bookmark(SseDeserializer deserializer); + @protected BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer); @@ -120,12 +140,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + double sse_decode_f_32(SseDeserializer deserializer); + @protected int sse_decode_i_32(SseDeserializer deserializer); @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + @protected + List sse_decode_list_bridge_bookmark( + SseDeserializer deserializer, + ); + @protected List sse_decode_list_bridge_channel( SseDeserializer deserializer, @@ -169,12 +197,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bool(bool self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, SseSerializer serializer, ); + @protected + void sse_encode_bridge_bookmark( + BridgeBookmark self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer); @@ -199,12 +239,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_f_32(double self, SseSerializer serializer); + @protected void sse_encode_i_32(int self, SseSerializer serializer); @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + @protected + void sse_encode_list_bridge_bookmark( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_list_bridge_channel( List self, diff --git a/apps/chanora_flutter/test/alpha_e2e_test.dart b/apps/chanora_flutter/test/alpha_e2e_test.dart index 8f04fce..1c5e7f4 100644 --- a/apps/chanora_flutter/test/alpha_e2e_test.dart +++ b/apps/chanora_flutter/test/alpha_e2e_test.dart @@ -29,6 +29,7 @@ void main() { final snap = await rust.connect( host: 'cn.teamspeak.app', nickname: 'ChanoraAlphaTest', + password: '', ); expect(snap.serverName, isNotEmpty); expect(snap.channels, isNotEmpty); diff --git a/apps/chanora_flutter/test/beta_e2e_test.dart b/apps/chanora_flutter/test/beta_e2e_test.dart index 4ff9c10..8137906 100644 --- a/apps/chanora_flutter/test/beta_e2e_test.dart +++ b/apps/chanora_flutter/test/beta_e2e_test.dart @@ -30,6 +30,7 @@ void main() { final snap = await rust.connect( host: 'cn.teamspeak.app', nickname: 'ChanoraBetaTest', + password: '', ); expect(snap.serverName, isNotEmpty); expect(snap.channels, isNotEmpty); diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 44c604d..50e4408 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -52,7 +52,7 @@ pub use chanora_diagnostics::{ pub use chanora_protocol::{ ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot, }; -pub use chanora_storage::IdentityFileStore; +pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore}; /// Errors that can arise during top-level orchestration. #[derive(Debug, Error)] @@ -198,6 +198,10 @@ pub struct ChanoraSession { /// carries (or a fresh ephemeral one if that is also `None`). /// Wired by [`Self::init_storage`]. identity_store: Arc>>, + /// Bookmark store backed by SQLite (A.2 + External Beta + /// extension). Lives alongside the identity file. Wired by + /// [`Self::init_storage`]. + bookmark_store: Arc>>, } impl ChanoraSession { @@ -210,6 +214,7 @@ impl ChanoraSession { events_tx, network_tx, identity_store: Arc::new(Mutex::new(None)), + bookmark_store: Arc::new(Mutex::new(None)), } } @@ -222,12 +227,54 @@ impl ChanoraSession { /// Beta caveat: the file is *not* encrypted at rest — see /// `chanora_storage::IdentityFileStore` for the full gap notice. pub async fn init_storage(&self, dir: impl AsRef) -> Result<(), CoreError> { + let dir = dir.as_ref(); let store = IdentityFileStore::new(dir)?; info!(target: "chanora_core", path = ?store.path(), "identity store initialised"); *self.identity_store.lock().await = Some(store); + + let bookmarks = BookmarkRepository::new(dir)?; + *self.bookmark_store.lock().await = Some(bookmarks); + info!(target: "chanora_core", "bookmark store initialised"); Ok(()) } + /// List persisted bookmarks. Returns an empty list if the store + /// has not been wired or has no entries. + pub async fn list_bookmarks(&self) -> Result, CoreError> { + let guard = self.bookmark_store.lock().await; + match guard.as_ref() { + Some(repo) => Ok(repo.list()?), + None => Ok(Vec::new()), + } + } + + /// Insert a bookmark. Returns the assigned id. + pub async fn add_bookmark(&self, b: Bookmark) -> Result { + let guard = self.bookmark_store.lock().await; + let repo = guard + .as_ref() + .ok_or(CoreError::Invariant("bookmark store not initialised"))?; + Ok(repo.add(&b)?) + } + + /// Update an existing bookmark. + pub async fn update_bookmark(&self, b: Bookmark) -> Result<(), CoreError> { + let guard = self.bookmark_store.lock().await; + let repo = guard + .as_ref() + .ok_or(CoreError::Invariant("bookmark store not initialised"))?; + Ok(repo.update(&b)?) + } + + /// Delete a bookmark by id. + pub async fn delete_bookmark(&self, id: i64) -> Result<(), CoreError> { + let guard = self.bookmark_store.lock().await; + let repo = guard + .as_ref() + .ok_or(CoreError::Invariant("bookmark store not initialised"))?; + Ok(repo.delete(id)?) + } + /// Push an OS connectivity update. Called by the bridge when /// `connectivity_plus` fires. Safe to call from any thread. pub fn set_network_state(&self, state: NetworkState) { @@ -382,6 +429,51 @@ impl ChanoraSession { Ok(()) } + /// Move our own client to `channel_id`. Optional channel + /// `password` for password-protected channels (empty string + /// counts as no password). + pub async fn move_to_channel( + &self, + channel_id: u64, + password: Option, + ) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + state.protocol.move_to_channel(channel_id, password).await?; + Ok(()) + } + + /// Update self-mute state. `input` mutes the microphone, `output` + /// mutes the local speaker for remote clients. Pass `None` to + /// leave a field unchanged. Adjusting the local output mute also + /// updates the audio engine's master output gain so playback + /// silences immediately, independent of the server's broadcast. + pub async fn set_self_muted( + &self, + input: Option, + output: Option, + ) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + state.protocol.set_muted(input, output).await?; + if let Some(muted) = output { + if let Some(audio) = state.audio.as_ref() { + audio.set_output_muted(muted); + } + } + Ok(()) + } + + /// Set master output gain (0.0 = silent, 1.0 = unity). Errors + /// only when audio is not started. + pub async fn set_output_gain(&self, gain: f32) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; + audio.set_output_gain(gain); + Ok(()) + } + /// Read audio engine statistics: (frames_sent, frames_received, ptt_active). pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> { let guard = self.inner.lock().await; diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 5086a44..6aa756b 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -26,3 +26,10 @@ audiopus = "0.3.0-rc.0" tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls", "audio"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } + +[target.'cfg(target_os = "android")'.dependencies] +# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION +# when the voice-comm preset is requested. ndk_context is initialised +# by the bridge crate's android_init shim. +jni = { version = "0.21", default-features = false } +ndk-context = "0.1" diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 60de397..89e258d 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -80,6 +80,15 @@ pub struct AudioEngine { ptt: Arc, frames_sent: Arc, frames_received: Arc, + /// Master output gain as f32 bits in an AtomicU32. Default 1.0. + /// Adjusted via [`Self::set_output_gain`] from the bridge. + output_gain: Arc, + /// Master output mute. When true the output callback fills the + /// device buffer with silence regardless of incoming voice + /// frames. Used for self-output-mute on the local device, + /// independent of the server-side mute the protocol layer + /// broadcasts. + output_muted: Arc, // Streams must be dropped to stop audio. Both are `!Send` because // cpal's Stream isn't Send on some backends; we keep them in an @@ -141,17 +150,24 @@ impl AudioEngine { #[cfg(target_os = "android")] { if cfg.mobile_voice_preset { - info!( - target: "chanora_audio", - "android: mobile_voice_preset requested (RISK-AUDIO-MOBILE-001 — flag plumbed, switch pending cpal upstream)" - ); + match android_engage_voice_communication() { + Ok(()) => info!( + target: "chanora_audio", + "android: AudioManager mode set to MODE_IN_COMMUNICATION" + ), + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "android: failed to set MODE_IN_COMMUNICATION; falling back to default routing" + ), + } } if cfg.effects.aec || cfg.effects.noise_suppression { info!( target: "chanora_audio", aec = cfg.effects.aec, ns = cfg.effects.noise_suppression, - "android: effects requested; awaiting OS-source switch to engage hardware AEC/NS" + "android: effects requested; engagement depends on device AEC/NS support under MODE_IN_COMMUNICATION" ); } } @@ -168,6 +184,8 @@ impl AudioEngine { let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial)); let frames_sent = Arc::new(AtomicU32::new(0)); let frames_received = Arc::new(AtomicU32::new(0)); + let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits())); + let output_muted = Arc::new(AtomicBool::new(false)); // ---------- Capture ---------- // Capture is best-effort. If the platform default input @@ -218,16 +236,22 @@ impl AudioEngine { &out_dev, &out_stream_cfg, audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), )?, SampleFormat::I16 => build_output_stream::( &out_dev, &out_stream_cfg, audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), )?, SampleFormat::U16 => build_output_stream::( &out_dev, &out_stream_cfg, audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), )?, other => { return Err(AudioError::StreamConfig(format!( @@ -272,6 +296,8 @@ impl AudioEngine { ptt, frames_sent, frames_received, + output_gain, + output_muted, _input_stream: Mutex::new(input_stream), _output_stream: Mutex::new(Some(output_stream)), shutdown_tx: Some(shutdown_tx), @@ -316,6 +342,31 @@ impl AudioEngine { pub fn frames_received(&self) -> u32 { self.frames_received.load(Ordering::Relaxed) } + + /// Set master output mute. When true the output stream emits + /// silence regardless of incoming voice frames. + pub fn set_output_muted(&self, muted: bool) { + self.output_muted.store(muted, Ordering::Relaxed); + } + + /// True if the master output is currently muted locally. + pub fn output_muted(&self) -> bool { + self.output_muted.load(Ordering::Relaxed) + } + + /// Set master output gain. 1.0 is unity; 0.0 is silent. Values + /// above 1.0 amplify (and may clip downstream). Clamped to a + /// sensible range internally. + pub fn set_output_gain(&self, gain: f32) { + let clamped = gain.clamp(0.0, 4.0); + self.output_gain + .store(clamped.to_bits(), Ordering::Relaxed); + } + + /// Current master output gain. + pub fn output_gain(&self) -> f32 { + f32::from_bits(self.output_gain.load(Ordering::Relaxed)) + } } impl Drop for AudioEngine { @@ -533,25 +584,39 @@ fn build_output_stream( device: &cpal::Device, config: &cpal::StreamConfig, handler: Arc>>, + output_gain: Arc, + output_muted: Arc, ) -> Result where T: SizedSample + FromF32 + Send + 'static, { - // Reusable f32 scratch buffer. cpal callbacks ask for a max - // buffer size known at construction time; we allocate per-call - // because reusing across calls would need an Arc> and - // we already hold one for the handler. let stream = device .build_output_stream( config, move |out: &mut [T], _| { + let muted = output_muted.load(Ordering::Relaxed); + if muted { + // Still call fill_buffer to keep the jitter + // buffer draining; just discard the result and + // emit silence to the device. + let mut scratch = vec![0.0f32; out.len()]; + { + let mut h = handler.lock().unwrap(); + h.fill_buffer(&mut scratch); + } + for dst in out.iter_mut() { + *dst = T::from_f32_sample(0.0); + } + return; + } + let gain = f32::from_bits(output_gain.load(Ordering::Relaxed)); let mut scratch = vec![0.0f32; out.len()]; { let mut h = handler.lock().unwrap(); h.fill_buffer(&mut scratch); } for (dst, src) in out.iter_mut().zip(scratch.into_iter()) { - *dst = T::from_f32_sample(src); + *dst = T::from_f32_sample(src * gain); } }, move |e| { @@ -582,3 +647,55 @@ impl FromF32 for u16 { (s + i32::from(i16::MAX) + 1) as u16 } } + +// ---------- Android voice-communication routing ---------- +// +// Engages `AudioManager.MODE_IN_COMMUNICATION` on the Android-side +// AudioManager. This is the routing-level lever that tells the OS +// "this is a voice call, please use the earpiece / engage hardware +// AEC / NS / AGC where the device supports it". cpal opens its +// input stream at the AAudio default preset; on most Android +// devices this honours the global mode and chooses the right +// pipeline. Fully wiring `setInputPreset(VOICE_COMMUNICATION)` would +// need either a cpal fork or a parallel Oboe input — out of scope +// for External Beta. +#[cfg(target_os = "android")] +fn android_engage_voice_communication() -> Result<(), String> { + use jni::objects::{JObject, JString, JValue}; + let ctx = ndk_context::android_context(); + let vm_ptr = ctx.vm(); + if vm_ptr.is_null() { + return Err("ndk_context vm is null".to_string()); + } + // SAFETY: ndk_context::android_context guarantees `vm` points at + // a live JavaVM* set by our bridge_init JNI hook. The unsafe + // block contains only the cast required by `JavaVM::from_raw`. + let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) } + .map_err(|e| format!("jvm from_raw: {e}"))?; + let mut env = jvm + .attach_current_thread() + .map_err(|e| format!("attach: {e}"))?; + + let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; + + let service_name: JString = env + .new_string("audio") + .map_err(|e| format!("new_string: {e}"))?; + let audio_manager = env + .call_method( + &context_obj, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&service_name.into())], + ) + .map_err(|e| format!("getSystemService: {e}"))? + .l() + .map_err(|e| format!("getSystemService obj: {e}"))?; + if audio_manager.is_null() { + return Err("AudioManager service is null".to_string()); + } + // AudioManager.MODE_IN_COMMUNICATION == 3. + env.call_method(&audio_manager, "setMode", "(I)V", &[JValue::Int(3)]) + .map_err(|e| format!("setMode: {e}"))?; + Ok(()) +} diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index f130c45..2456ba7 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -51,6 +51,12 @@ fn log_sink() -> &'static chanora_core::InMemoryLogSink { // ---------- Bridge lifecycle ---------- +/// Default tracing filter. Suppresses the chatty +/// `tsproto::resend` and `tsproto::packet_codec` paths that +/// flood the diagnostic export during transient packet loss; +/// users can still raise verbosity via `RUST_LOG=info`. +const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error"; + /// Initialise the bridge. Must be called once on Dart side before /// any other API call. Sets up panic logging. #[frb(init)] @@ -71,7 +77,7 @@ pub fn bridge_init() { use tracing_subscriber::util::SubscriberInitExt; let android_layer = tracing_android::layer("chanora").ok(); let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)); let _ = tracing_subscriber::registry() .with(filter) .with(android_layer) @@ -85,7 +91,7 @@ pub fn bridge_init() { use tracing_subscriber::util::SubscriberInitExt; let fmt_layer = tracing_subscriber::fmt::layer().with_target(true); let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)); let _ = tracing_subscriber::registry() .with(filter) .with(fmt_layer) @@ -176,11 +182,18 @@ impl From for BridgeSnapshot { /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant /// via [`BridgeError::AlreadyConnected`]. -pub async fn connect(host: String, nickname: String) -> Result { +/// +/// `password` is optional — pass an empty string for servers that +/// don't require one. +pub async fn connect( + host: String, + nickname: String, + password: String, +) -> Result { let cfg = chanora_core::ConnectConfig { address: host, nickname, - password: None, + password: if password.is_empty() { None } else { Some(password) }, identity: None, ready_timeout: Duration::from_secs(15), }; @@ -242,6 +255,52 @@ pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { Ok(()) } +/// Move our own client to `channel_id`. Optional channel password +/// for password-protected channels — pass an empty string when not +/// required. +pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> { + let pw = if password.is_empty() { None } else { Some(password) }; + runtime() + .spawn(async move { session().move_to_channel(channel_id, pw).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Toggle self input-mute (microphone) on the server. Independent +/// of push-to-talk: a muted client never transmits regardless of +/// PTT state. +pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_self_muted(Some(muted), None).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Toggle self output-mute (speaker). Mutes locally *and* informs +/// the server. The server uses this for the channel icon next to +/// the client name; the local mute kicks in immediately even +/// before the server acknowledges. +pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_self_muted(None, Some(muted)).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Set master output gain. `1.0` is unity, `0.0` is silent. Values +/// above `1.0` amplify and can clip downstream. Errors when audio +/// is not started. +pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_output_gain(gain).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + /// Statistics from the audio engine. #[derive(Debug, Clone)] pub struct BridgeAudioStats { @@ -294,6 +353,89 @@ pub async fn init_storage(dir: String) -> Result<(), BridgeError> { Ok(()) } +/// Bookmark DTO mirroring [`chanora_core::Bookmark`]. +#[derive(Debug, Clone)] +pub struct BridgeBookmark { + /// Row id assigned by SQLite. Use `0` when adding new rows; + /// the returned id is then meaningful. + pub id: i64, + /// User-facing label. + pub display_name: String, + /// `hostname[:port]` or TSDNS name. + pub host: String, + /// Nickname to use for this bookmark. + pub nickname: String, + /// Optional remembered password. Empty string = none. + pub password: String, +} + +impl From for BridgeBookmark { + fn from(b: chanora_core::Bookmark) -> Self { + Self { + id: b.id, + display_name: b.display_name, + host: b.host, + nickname: b.nickname, + password: b.password.unwrap_or_default(), + } + } +} + +impl From for chanora_core::Bookmark { + fn from(b: BridgeBookmark) -> Self { + chanora_core::Bookmark { + id: b.id, + display_name: b.display_name, + host: b.host, + nickname: b.nickname, + password: if b.password.is_empty() { + None + } else { + Some(b.password) + }, + } + } +} + +/// List persisted bookmarks. +pub async fn list_bookmarks() -> Result, BridgeError> { + let v = runtime() + .spawn(async { session().list_bookmarks().await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(v.into_iter().map(Into::into).collect()) +} + +/// Insert a bookmark and return its assigned id. The `id` field on +/// the input is ignored. +pub async fn add_bookmark(b: BridgeBookmark) -> Result { + let core_b: chanora_core::Bookmark = b.into(); + let id = runtime() + .spawn(async move { session().add_bookmark(core_b).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(id) +} + +/// Update an existing bookmark. +pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> { + let core_b: chanora_core::Bookmark = b.into(); + runtime() + .spawn(async move { session().update_bookmark(core_b).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + +/// Delete a bookmark by id. +pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().delete_bookmark(id).await }) + .await + .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; + Ok(()) +} + // ---------- Connectivity (A.6.1) ---------- /// Coarse OS-reported network state. Mirrors diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 1dce0d8..32a9db6 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2126340080; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 663465485; // Section: executor @@ -46,6 +46,42 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs +fn wire__crate__api__add_bookmark_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "add_bookmark", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_b = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::add_bookmark(api_b).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__audio_stats_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -139,11 +175,49 @@ fn wire__crate__api__connect_impl( flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_host = ::sse_decode(&mut deserializer); let api_nickname = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, crate::BridgeError>( (move || async move { - let output_ok = crate::api::connect(api_host, api_nickname).await?; + let output_ok = + crate::api::connect(api_host, api_nickname, api_password).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__delete_bookmark_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "delete_bookmark", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::delete_bookmark(api_id).await?; Ok(output_ok) })() .await, @@ -323,6 +397,115 @@ fn wire__crate__api__is_connected_impl( }, ) } +fn wire__crate__api__list_bookmarks_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "list_bookmarks", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::list_bookmarks().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__move_to_channel_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "move_to_channel", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_channel_id = ::sse_decode(&mut deserializer); + let api_password = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = + crate::api::move_to_channel(api_channel_id, api_password).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__set_input_muted_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_input_muted", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_muted = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_input_muted(api_muted).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_network_state_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -355,6 +538,78 @@ fn wire__crate__api__set_network_state_impl( }, ) } +fn wire__crate__api__set_output_gain_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_output_gain", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_gain = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_output_gain(api_gain).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__set_output_muted_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_output_muted", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_muted = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_output_muted(api_muted).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_ptt_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -461,6 +716,42 @@ fn wire__crate__api__start_audio_impl( }, ) } +fn wire__crate__api__update_bookmark_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "update_bookmark", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_b = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::update_bookmark(api_b).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} // Section: dart2rust @@ -511,6 +802,24 @@ impl SseDecode for crate::api::BridgeAudioStats { } } +impl SseDecode for crate::api::BridgeBookmark { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); + let mut var_displayName = ::sse_decode(deserializer); + let mut var_host = ::sse_decode(deserializer); + let mut var_nickname = ::sse_decode(deserializer); + let mut var_password = ::sse_decode(deserializer); + return crate::api::BridgeBookmark { + id: var_id, + display_name: var_displayName, + host: var_host, + nickname: var_nickname, + password: var_password, + }; + } +} + impl SseDecode for crate::api::BridgeChannel { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -660,6 +969,13 @@ impl SseDecode for crate::api::BridgeSnapshot { } } +impl SseDecode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_f32::().unwrap() + } +} + impl SseDecode for i32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -674,6 +990,18 @@ impl SseDecode for i64 { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -745,16 +1073,24 @@ fn pde_ffi_dispatcher_primary_impl( ) { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 1 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len), - 2 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len), - 3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len), - 4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), - 5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), - 7 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 8 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), + 1 => wire__crate__api__add_bookmark_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 16 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 18 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -767,8 +1103,8 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 6 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), - 9 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), + 14 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -795,6 +1131,25 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeBookmark { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.id.into_into_dart().into_dart(), + self.display_name.into_into_dart().into_dart(), + self.host.into_into_dart().into_dart(), + self.nickname.into_into_dart().into_dart(), + self.password.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeBookmark {} +impl flutter_rust_bridge::IntoIntoDart for crate::api::BridgeBookmark { + fn into_into_dart(self) -> crate::api::BridgeBookmark { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -986,6 +1341,17 @@ impl SseEncode for crate::api::BridgeAudioStats { } } +impl SseEncode for crate::api::BridgeBookmark { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); + ::sse_encode(self.display_name, serializer); + ::sse_encode(self.host, serializer); + ::sse_encode(self.nickname, serializer); + ::sse_encode(self.password, serializer); + } +} + impl SseEncode for crate::api::BridgeChannel { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -1110,6 +1476,13 @@ impl SseEncode for crate::api::BridgeSnapshot { } } +impl SseEncode for f32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_f32::(self).unwrap(); + } +} + impl SseEncode for i32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -1124,6 +1497,16 @@ impl SseEncode for i64 { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 9fe341c..715458a 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -24,6 +24,7 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{info, warn}; use tsclientlib::data::{self, Channel, Client}; +use tsclientlib::prelude::*; use tsclientlib::{ ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem, }; @@ -66,6 +67,18 @@ impl Default for ConnectConfig { enum Request { Snapshot(oneshot::Sender>), Disconnect(oneshot::Sender<()>), + /// Move self to a channel. Optional channel password. + MoveToChannel { + channel_id: u64, + password: Option, + reply: oneshot::Sender>, + }, + /// Update own client mute state (input and/or output). + SetMuted { + input: Option, + output: Option, + reply: oneshot::Sender>, + }, } /// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven @@ -201,6 +214,46 @@ impl ProtocolClient { } } + /// Move our own client into a channel. `password` is optional + /// for password-protected channels. + pub async fn move_to_channel( + &self, + channel_id: u64, + password: Option, + ) -> Result<(), ProtocolError> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Request::MoveToChannel { + channel_id, + password, + reply: tx, + }) + .await + .map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?; + rx.await + .map_err(|_| ProtocolError::Lost("move_to_channel reply dropped".to_string()))? + } + + /// Update mute state on our own client. Pass `Some(_)` for the + /// fields you want to change, `None` to leave a field as-is. + pub async fn set_muted( + &self, + input: Option, + output: Option, + ) -> Result<(), ProtocolError> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Request::SetMuted { + input, + output, + reply: tx, + }) + .await + .map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?; + rx.await + .map_err(|_| ProtocolError::Lost("set_muted reply dropped".to_string()))? + } + /// Sender for outbound voice packets. Clone freely. pub fn voice_out(&self) -> mpsc::Sender { self.voice_out_tx.clone() @@ -406,6 +459,14 @@ async fn connection_task( let snap = build_snapshot(&con); let _ = reply.send(snap); } + Ok(Request::MoveToChannel { channel_id, password, reply }) => { + let r = move_self_to(&mut con, channel_id, password.as_deref()); + let _ = reply.send(r); + } + Ok(Request::SetMuted { input, output, reply }) => { + let r = set_self_muted(&mut con, input, output); + let _ = reply.send(r); + } Ok(Request::Disconnect(reply)) => { let _ = con.disconnect(DisconnectOptions::new()); con.events().for_each(|_| future::ready(())).await; @@ -424,6 +485,62 @@ async fn connection_task( } } +/// Move our own client into `channel_id` with an optional password. +/// Looks up our `own_client` in the current state and dispatches the +/// generated `client_move` command via the `OutCommandExt` trait. +fn move_self_to( + con: &mut Connection, + channel_id: u64, + password: Option<&str>, +) -> Result<(), ProtocolError> { + let state = con + .get_state() + .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; + let own_id = state.own_client; + let own_client = state + .clients + .get(&own_id) + .ok_or_else(|| ProtocolError::Backend("own_client not in state".to_string()))?; + let target = TsChannelId(channel_id); + let mut part = own_client.client_move(target); + if let Some(pw) = password { + part = part.set_password(pw); + } + part.send(con) + .map_err(|e| ProtocolError::Backend(format!("client_move send: {e}")))?; + info!(target: "chanora_protocol", channel_id, "client_move sent"); + Ok(()) +} + +/// Send a `clientupdate` with the requested mute fields set. `None` +/// fields are omitted so callers can toggle just one flag. +fn set_self_muted( + con: &mut Connection, + input: Option, + output: Option, +) -> Result<(), ProtocolError> { + if input.is_none() && output.is_none() { + return Ok(()); + } + let part = { + let state = con + .get_state() + .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; + let mut p = state.client_update(); + if let Some(v) = input { + p = p.set_input_muted(v); + } + if let Some(v) = output { + p = p.set_output_muted(v); + } + p + }; + part.send(con) + .map_err(|e| ProtocolError::Backend(format!("client_update send: {e}")))?; + info!(target: "chanora_protocol", ?input, ?output, "client_update sent"); + Ok(()) +} + /// Extract the originating `client_id` from an inbound voice packet. fn packet_sender_id(buf: &InAudioBuf) -> Option { use tsproto_packets::packets::AudioData; diff --git a/crates/chanora_storage/Cargo.toml b/crates/chanora_storage/Cargo.toml index bed8e16..def5d64 100644 --- a/crates/chanora_storage/Cargo.toml +++ b/crates/chanora_storage/Cargo.toml @@ -12,3 +12,7 @@ publish.workspace = true [dependencies] thiserror.workspace = true tracing.workspace = true +rusqlite = { version = "0.32", features = ["bundled"] } +chacha20poly1305 = "0.10" +rand = "0.8" +zeroize = "1" diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 77295b6..9fd6455 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -43,9 +43,15 @@ use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use rand::RngCore; +use rusqlite::{params, Connection}; use thiserror::Error; use tracing::{info, warn}; +use zeroize::Zeroize; /// Errors raised by either storage repository. #[derive(Debug, Error)] @@ -65,6 +71,11 @@ pub enum StorageError { /// Filesystem I/O error (Beta file-fallback store). #[error("io: {0}")] Io(String), + /// Cryptographic operation failed (key generation, encrypt, or + /// decrypt). Typically indicates a corrupted DEK or tampered + /// identity file. + #[error("crypto: {0}")] + Crypto(String), } /// Marker trait for the non-secret database side. Concrete impl will @@ -77,74 +88,175 @@ pub trait LocalDatabaseRepository: Send + Sync {} /// promoted from the secure-storage PoC. pub trait SecretStorageRepository: Send + Sync {} -/// Beta identity store: a single file containing the base64 TS3 -/// identity string. The directory is created on first use; on Unix -/// the file is written with mode 0600 so other local users can't -/// read it. **Not** encrypted at rest — that is the v0.4 task. +/// Beta identity store: a single ChaCha20-Poly1305-encrypted file +/// containing the base64 TS3 identity string. The Data Encryption +/// Key (DEK) is 32 random bytes stored alongside in a separate +/// `identity.dek` file with the same 0600 permissions on Unix. +/// +/// The dual-file layout means an attacker who recovers either file +/// alone can't decrypt the identity. The honest threat model: +/// +/// * **Helps against** stale backups, casual filesystem snooping +/// that grabs one file but not the other, and accidental leaks +/// to diagnostic exports (the ciphertext is never logged). +/// * **Does NOT help against** a full app-private storage dump (an +/// attacker who can read one file in the directory can read both). +/// The v0.4 storage rework lands proper OS-keyring backing for the +/// DEK so this two-file weakness is closed. +/// +/// File format: `identity.tskey` = `[12-byte nonce][AEAD ciphertext+tag]`. +/// Legacy plaintext files written by v0.3 are still readable; the +/// next `save()` upgrades them to encrypted form (and shreds the +/// plaintext temp file via the atomic rename). #[derive(Debug, Clone)] pub struct IdentityFileStore { path: PathBuf, + dek_path: PathBuf, } impl IdentityFileStore { - /// Construct a store rooted at `dir`. The directory is created - /// recursively if it does not already exist. + /// Construct a store rooted at `dir`. Creates the directory and + /// the DEK on first use; subsequent uses reuse the existing DEK. pub fn new(dir: impl AsRef) -> Result { let dir = dir.as_ref(); fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?; - Ok(Self { + let store = Self { path: dir.join("identity.tskey"), - }) + dek_path: dir.join("identity.dek"), + }; + // Ensure a DEK exists. Subsequent operations expect it. + store.ensure_dek()?; + Ok(store) } - /// Path to the underlying file. Exposed for diagnostics. + /// Path to the underlying identity file. Exposed for diagnostics. pub fn path(&self) -> &Path { &self.path } - /// Read the persisted identity, if any. Returns `Ok(None)` when - /// no identity has been saved yet — that is not an error. + fn ensure_dek(&self) -> Result<(), StorageError> { + if self.dek_path.exists() { + return Ok(()); + } + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + { + let mut f = open_private(&self.dek_path)?; + f.write_all(&key) + .map_err(|e| StorageError::Io(format!("write dek: {e}")))?; + f.sync_all() + .map_err(|e| StorageError::Io(format!("sync dek: {e}")))?; + } + key.zeroize(); + info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated"); + Ok(()) + } + + fn load_dek(&self) -> Result<[u8; 32], StorageError> { + let mut f = fs::File::open(&self.dek_path) + .map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", self.dek_path)))?; + let mut key = [0u8; 32]; + f.read_exact(&mut key) + .map_err(|e| StorageError::Io(format!("read dek: {e}")))?; + Ok(key) + } + + /// Read the persisted identity, if any. Transparently handles + /// the legacy plaintext format (pre-External Beta). pub fn load(&self) -> Result, StorageError> { - match fs::File::open(&self.path) { - Ok(mut f) => { - let mut buf = String::new(); - f.read_to_string(&mut buf) - .map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?; - let trimmed = buf.trim().to_string(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed)) - } + let mut f = match fs::File::open(&self.path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))), + }; + let mut buf = Vec::new(); + f.read_to_end(&mut buf) + .map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?; + if buf.is_empty() { + return Ok(None); + } + + // Encrypted format: at least nonce (12) + tag (16) = 28 + // bytes, and the first byte should not be a printable ASCII + // base64 character. Heuristic: a legacy plaintext file + // starts with an ASCII digit (counter prefix) or an ASCII + // letter (raw base64 key). If the first byte is non-ASCII + // or non-printable, treat as ciphertext. + if buf.len() >= 28 && !is_plausibly_legacy_plaintext(&buf) { + let mut key_bytes = self.load_dek()?; + let key = Key::from_slice(&key_bytes); + let cipher = ChaCha20Poly1305::new(key); + let nonce_bytes = &buf[..12]; + let nonce = Nonce::from_slice(nonce_bytes); + let pt = cipher + .decrypt(nonce, &buf[12..]) + .map_err(|e| { + key_bytes.zeroize(); + StorageError::Crypto(format!("decrypt: {e}")) + })?; + key_bytes.zeroize(); + let s = String::from_utf8(pt) + .map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?; + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { + return Ok(None); } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(StorageError::Io(format!("open {:?}: {e}", self.path))), + return Ok(Some(trimmed)); + } + + // Legacy plaintext path. We do NOT auto-upgrade here — + // upgrade happens on the next save() to keep load() + // side-effect-free. + warn!( + target: "chanora_storage", + "identity file is in legacy plaintext format; will encrypt on next save" + ); + let s = String::from_utf8(buf) + .map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?; + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed)) } } /// Persist `identity` to disk, replacing any previous content. - /// On Unix the file is written with mode 0600. + /// On Unix the file is written with mode 0600. Encrypted with + /// ChaCha20-Poly1305 using the per-install DEK. pub fn save(&self, identity: &str) -> Result<(), StorageError> { - // Write atomically: temp file + rename. Avoids leaving a - // half-written identity on the device after a crash or - // power loss. + let plaintext = identity.trim().as_bytes(); + let mut key_bytes = self.load_dek()?; + let key = Key::from_slice(&key_bytes); + let cipher = ChaCha20Poly1305::new(key); + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ct = cipher.encrypt(nonce, plaintext).map_err(|e| { + key_bytes.zeroize(); + StorageError::Crypto(format!("encrypt: {e}")) + })?; + key_bytes.zeroize(); + + // Atomic write: temp file + rename. let tmp = self.path.with_extension("tskey.tmp"); { let mut f = open_private(&tmp)?; - f.write_all(identity.trim().as_bytes()) - .map_err(|e| StorageError::Io(format!("write {tmp:?}: {e}")))?; - f.write_all(b"\n") - .map_err(|e| StorageError::Io(format!("write nl: {e}")))?; + f.write_all(&nonce_bytes) + .map_err(|e| StorageError::Io(format!("write nonce: {e}")))?; + f.write_all(&ct) + .map_err(|e| StorageError::Io(format!("write ct: {e}")))?; f.sync_all() .map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?; } fs::rename(&tmp, &self.path) .map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?; - info!(target: "chanora_storage", path = ?self.path, "identity persisted"); + info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)"); Ok(()) } - /// Remove any persisted identity. No-op if none exists. + /// Remove any persisted identity. No-op if none exists. Leaves + /// the DEK in place so future saves don't generate a new one. pub fn clear(&self) -> Result<(), StorageError> { match fs::remove_file(&self.path) { Ok(()) => { @@ -160,6 +272,31 @@ impl IdentityFileStore { } } +/// True if `buf` looks like a legacy plaintext identity (printable +/// ASCII with a digit/letter first byte and at most a trailing +/// newline). False for ciphertext. +fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool { + if buf.is_empty() { + return false; + } + let first = buf[0]; + if !(first.is_ascii_alphanumeric() || first == b'+' || first == b'/') { + return false; + } + // The TS3 identity format is base64 + a counter prefix; all + // bytes are printable ASCII. The ciphertext is uniformly random. + buf.iter().all(|&b| { + b.is_ascii_alphanumeric() + || b == b'+' + || b == b'/' + || b == b'=' + || b == b'V' + || b == b'\n' + || b == b'\r' + || b == b' ' + }) +} + #[cfg(unix)] fn open_private(p: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; @@ -186,6 +323,140 @@ fn open_private(p: &Path) -> Result { .map_err(|e| StorageError::Io(format!("open {p:?}: {e}"))) } +/// A persisted bookmark: a friendly label paired with a TS3 server +/// address and the nickname the user wants when connecting. +/// +/// Bookmark rows are uniquely identified by an auto-incrementing +/// `id`. The `display_name` is purely cosmetic. `host` is the same +/// string the user would type into the connect form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bookmark { + /// Stable row id assigned by SQLite. + pub id: i64, + /// User-facing label. + pub display_name: String, + /// `hostname[:port]` or TSDNS name. + pub host: String, + /// Nickname to use for this bookmark. + pub nickname: String, + /// Optional remembered server password. Stored as plain text in + /// the SQLite file (Beta gap, RISK-PoC-002). v0.4 will lift + /// passwords into the keyring. + pub password: Option, +} + +/// SQLite-backed bookmark store. The DB file lives at +/// `/chanora.db`. The schema is migrated on +/// construction; failures here abort the constructor rather than +/// poisoning later calls. +pub struct BookmarkRepository { + conn: Mutex, +} + +impl BookmarkRepository { + /// Open or create the bookmark database under `dir`. + pub fn new(dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?; + let path = dir.join("chanora.db"); + let conn = Connection::open(&path) + .map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?; + conn.pragma_update(None, "foreign_keys", "ON") + .map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + host TEXT NOT NULL, + nickname TEXT NOT NULL, + password TEXT + ); + CREATE TABLE IF NOT EXISTS schema_version ( + v INTEGER PRIMARY KEY + ); + INSERT OR IGNORE INTO schema_version(v) VALUES (1);", + ) + .map_err(|e| StorageError::Migration(format!("init schema: {e}")))?; + info!(target: "chanora_storage", path = ?path, "bookmark db opened"); + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Insert a new bookmark and return its assigned id. The `id` + /// field on the input is ignored. + pub fn add(&self, b: &Bookmark) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; + conn.execute( + "INSERT INTO bookmarks (display_name, host, nickname, password) VALUES (?1, ?2, ?3, ?4)", + params![b.display_name, b.host, b.nickname, b.password], + ) + .map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?; + Ok(conn.last_insert_rowid()) + } + + /// Replace an existing bookmark identified by `id`. Errors with + /// [`StorageError::NotFound`] if no such row exists. + pub fn update(&self, b: &Bookmark) -> Result<(), StorageError> { + let conn = self + .conn + .lock() + .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; + let n = conn + .execute( + "UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4 WHERE id=?5", + params![b.display_name, b.host, b.nickname, b.password, b.id], + ) + .map_err(|e| StorageError::Sqlite(format!("update: {e}")))?; + if n == 0 { + Err(StorageError::NotFound) + } else { + Ok(()) + } + } + + /// Delete a bookmark by id. No-op if it doesn't exist. + pub fn delete(&self, id: i64) -> Result<(), StorageError> { + let conn = self + .conn + .lock() + .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; + conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id]) + .map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?; + Ok(()) + } + + /// List all bookmarks ordered by id (insertion order). + pub fn list(&self) -> Result, StorageError> { + let conn = self + .conn + .lock() + .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; + let mut stmt = conn + .prepare( + "SELECT id, display_name, host, nickname, password FROM bookmarks ORDER BY id", + ) + .map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(Bookmark { + id: row.get(0)?, + display_name: row.get(1)?, + host: row.get(2)?, + nickname: row.get(3)?, + password: row.get(4)?, + }) + }) + .map_err(|e| StorageError::Sqlite(format!("query: {e}")))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?); + } + Ok(out) + } +} + #[cfg(test)] mod tests { use super::*; @@ -220,6 +491,96 @@ mod tests { assert_eq!(mode, 0o600, "expected 0600, got {mode:o}"); } + #[test] + fn bookmark_round_trip() { + let tmp = tempdir(); + let repo = BookmarkRepository::new(&tmp).unwrap(); + assert!(repo.list().unwrap().is_empty()); + let id = repo + .add(&Bookmark { + id: 0, + display_name: "home".to_string(), + host: "cn.teamspeak.app".to_string(), + nickname: "u".to_string(), + password: None, + }) + .unwrap(); + assert!(id > 0); + let rows = repo.list().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].display_name, "home"); + } + + #[test] + fn bookmark_update_and_delete() { + let tmp = tempdir(); + let repo = BookmarkRepository::new(&tmp).unwrap(); + let id = repo + .add(&Bookmark { + id: 0, + display_name: "a".to_string(), + host: "h".to_string(), + nickname: "n".to_string(), + password: Some("pw".to_string()), + }) + .unwrap(); + repo.update(&Bookmark { + id, + display_name: "b".to_string(), + host: "h2".to_string(), + nickname: "n2".to_string(), + password: None, + }) + .unwrap(); + let rows = repo.list().unwrap(); + assert_eq!(rows[0].display_name, "b"); + assert_eq!(rows[0].password, None); + repo.delete(id).unwrap(); + assert!(repo.list().unwrap().is_empty()); + } + + #[test] + fn bookmark_update_missing_is_notfound() { + let tmp = tempdir(); + let repo = BookmarkRepository::new(&tmp).unwrap(); + let r = repo.update(&Bookmark { + id: 999, + display_name: "x".to_string(), + host: "h".to_string(), + nickname: "n".to_string(), + password: None, + }); + assert!(matches!(r, Err(StorageError::NotFound))); + } + + #[test] + fn encrypted_round_trip() { + let tmp = tempdir(); + let store = IdentityFileStore::new(&tmp).unwrap(); + store.save("3456V/abcdef==").unwrap(); + // The bytes on disk are not the plaintext. + let raw = fs::read(store.path()).unwrap(); + assert!(!raw.windows(6).any(|w| w == b"abcdef")); + // load() returns the original. + assert_eq!(store.load().unwrap().as_deref(), Some("3456V/abcdef==")); + } + + #[test] + fn legacy_plaintext_is_read_and_upgraded() { + let tmp = tempdir(); + // Pre-Beta on-disk format: raw base64 with counter prefix. + let path = tmp.join("identity.tskey"); + fs::write(&path, b"9999VabcdefghIJKLmnop=\n").unwrap(); + let store = IdentityFileStore::new(&tmp).unwrap(); + let v = store.load().unwrap(); + assert_eq!(v.as_deref(), Some("9999VabcdefghIJKLmnop=")); + // Save round-trips through encrypted format. + store.save("9999VabcdefghIJKLmnop=").unwrap(); + let raw = fs::read(store.path()).unwrap(); + assert!(!raw.starts_with(b"9999")); + assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop=")); + } + fn tempdir() -> PathBuf { let p = std::env::temp_dir() .join("chanora_storage_test")