Compare commits

..
Author SHA1 Message Date
Edison Jwa 861e915b81 chore(repo): untrack macOS chanora_bridge.framework build artifacts
The macOS chanora_bridge.framework tree at
apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework was
being tracked in git despite being a pure build artifact. Both
mechanisms in chanora_bridge.podspec rebuild the entire tree from
scratch:

  * prepare_command (runs on `pod install`) — `rm -rf $FW` and
    reconstructs Versions/A, the Versions/Current and Resources
    symlinks, Info.plist, and copies the lipo-merged universal dylib.
  * script_phase :before_compile (runs on every Xcode build) — same
    rm -rf + reconstruction, gated on freshness of the cargo output.

Tracking the tree therefore added zero value and ~36 MB per binary
revision (the chanora_bridge dylib alone). The iOS counterpart at
apps/chanora_flutter/ios/Frameworks/ has been correctly ignored since
.gitignore:118-119 was added; this commit mirrors that rule for macOS.

Changes:
  - git rm --cached -r the 5 tracked entries (binary, 3 symlinks,
    Info.plist). Working tree is untouched, so existing local
    builds keep functioning until the next `pod install` /
    Xcode build refreshes them.
  - Add /apps/chanora_flutter/macos/Frameworks/ to .gitignore
    alongside the existing iOS entry, with a comment pointing at the
    podspec mechanism so the next maintainer understands the rule.

Verified the working tree binary survives the cache untrack and
the path is now matched by .gitignore:125.
2026-06-07 20:55:41 +09:00
61 changed files with 618 additions and 4257 deletions
-11
View File
@@ -124,16 +124,5 @@ opencode.json
# pure waste — the committed binary was ~40 MB per commit).
/apps/chanora_flutter/macos/Frameworks/
.opencode/
.omo/
AGENTS.md
Screenshot 2026-05-17 at 22.23.07.png
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
**/Chanora */
**/*.xcarchive/
**/*.ipa
**/*.dSYM/
# macOS release zip bundles produced by local release scripts
/chanora-v*.zip
Generated
+74 -72
View File
@@ -70,15 +70,6 @@ dependencies = [
"backtrace",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "alsa"
version = "0.11.0"
@@ -437,12 +428,12 @@ dependencies = [
"dhat",
"dispatch2",
"futures-util",
"jni 0.22.4",
"jni 0.21.1",
"ndarray",
"ndk-context",
"oboe",
"ort",
"rand 0.10.1",
"rand 0.8.6",
"rustfft",
"sdl2",
"serde_json",
@@ -453,7 +444,7 @@ dependencies = [
"tracing-subscriber",
"tsclientlib",
"webrtc-vad",
"windows",
"windows 0.54.0",
"zbus",
]
@@ -726,15 +717,14 @@ dependencies = [
[[package]]
name = "cpal"
version = "0.18.0"
version = "0.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9dd2b2151ebb4d5866c804d89fe28244bfb6b74481b9b4d406e4ec4d7f88ce5"
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7"
dependencies = [
"alsa",
"block2",
"coreaudio-rs",
"dasp_sample",
"jni 0.22.4",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -749,9 +739,10 @@ dependencies = [
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
"windows-core",
"windows 0.62.2",
]
[[package]]
@@ -774,24 +765,25 @@ dependencies = [
[[package]]
name = "criterion"
version = "0.8.2"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools 0.13.0",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
@@ -799,12 +791,12 @@ dependencies = [
[[package]]
name = "criterion-plot"
version = "0.8.2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.13.0",
"itertools 0.10.5",
]
[[package]]
@@ -1993,7 +1985,7 @@ dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
@@ -2007,10 +1999,21 @@ dependencies = [
]
[[package]]
name = "itertools"
version = "0.13.0"
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
@@ -2235,9 +2238,12 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mach2"
version = "0.6.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "matchers"
@@ -2534,7 +2540,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"bitflags 2.12.1",
"objc2",
"objc2-foundation",
]
@@ -2747,16 +2752,6 @@ dependencies = [
"sha2",
]
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3503,9 +3498,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdl2"
version = "0.38.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
dependencies = [
"bitflags 1.3.2",
"lazy_static",
@@ -3515,9 +3510,9 @@ dependencies = [
[[package]]
name = "sdl2-sys"
version = "0.38.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
dependencies = [
"cfg-if",
"libc",
@@ -4773,22 +4768,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4799,10 +4778,14 @@ dependencies = [
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
@@ -4811,7 +4794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-core 0.62.2",
"windows-future",
"windows-numerics",
]
@@ -4822,7 +4805,17 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
@@ -4834,7 +4827,7 @@ dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
@@ -4844,7 +4837,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-core 0.62.2",
"windows-link",
"windows-threading",
]
@@ -4883,7 +4876,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-core 0.62.2",
"windows-link",
]
@@ -4894,10 +4887,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.4.1"
-14
View File
@@ -82,17 +82,3 @@ tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch =
[patch.crates-io]
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
# Apple-only DWARF emission for archive validation lives in the iOS and
# macOS chanora_bridge podspecs (apps/chanora_flutter/{ios,macos}/
# chanora_bridge.podspec) as per-build environment overrides:
#
# CARGO_PROFILE_RELEASE_DEBUG=true
# CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off
# CARGO_PROFILE_RELEASE_STRIP=false
#
# This keeps Android, Linux, and Windows release binaries on the cargo
# default release profile (no DWARF, no extra ~10MB symbol payload).
# Apple builds need the DWARF so dsymutil can emit a usable
# chanora_bridge.framework.dSYM that the archive validator accepts.
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>ad-hoc</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>teamID</key>
<string>ZNVDEVDRX3</string>
<key>provisioningProfiles</key>
<dict>
<key>app.teamspeak.chanora</key>
<string>Chanora_Ad_Hoc</string>
</dict>
</dict>
</plist>
@@ -1,6 +1,2 @@
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
// Mirror Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -1,27 +1,2 @@
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
// Force the linker to retain Swift @_cdecl symbols that the chanora_bridge
// Rust framework resolves at runtime via dlsym(RTLD_DEFAULT). Two flags per
// symbol, intentionally redundant:
//
// -u _sym marks the symbol as force-undefined at link time,
// which keeps the object that defines it from being
// dropped and prevents dead-strip from removing the
// definition. This is the load-bearing flag.
// -exported_symbol _sym re-exports the symbol in the final binary's
// dynamic symbol table so dlsym(RTLD_DEFAULT) can
// find it from the Rust framework at runtime.
//
// Without -u, Xcode Archive's -dead_strip (WMO + LTO) can remove the
// symbol before the export list is applied, and CoreML VAD silently falls
// back to WebRTC on TestFlight / App Store. The Swift-side static
// `unsafeBitCast` references in SileroCoreMLBridge.swift are belt-and-
// suspenders defense-in-depth, NOT the primary guarantee.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// `STRIP_STYLE = all` (Xcode default for archive installs) runs `strip` without
// `-x`, which removes even the global @_cdecl symbols the linker exported above
// via -exported_symbol. `non-global` runs `strip -x`, preserving globals so the
// Rust framework's dlsym(RTLD_DEFAULT) can find them. 264-byte cost in the app.
STRIP_STYLE = non-global
+1 -1
View File
@@ -27,7 +27,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup
target 'Chanora' do
target 'Runner' do
use_frameworks!
# Chanora Rust bridge as a vendored framework. The podspec runs
+2 -2
View File
@@ -23,11 +23,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/haptic_kit/ios"
SPEC CHECKSUMS:
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
COCOAPODS: 1.16.2
@@ -8,19 +8,19 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B4754099284EEDCD859A973 /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */; };
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -48,10 +48,10 @@
/* Begin PBXFileReference section */
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
0B4754099284EEDCD859A973 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.release.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
@@ -60,23 +60,20 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.profile.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.profile.xcconfig"; sourceTree = "<group>"; };
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chanora.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.debug.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -86,7 +83,7 @@
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -112,8 +109,8 @@
4351F25046559EFA4C03047A /* Frameworks */ = {
isa = PBXGroup;
children = (
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -127,9 +124,6 @@
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */,
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */,
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -161,7 +155,7 @@
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Chanora.app */,
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -207,15 +201,14 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Chanora */ = {
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
CA110001000000000000A100 /* Verify Silero Exports */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
@@ -225,13 +218,13 @@
);
dependencies = (
);
name = Chanora;
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C5000032DD0000000000001 /* SileroCoreML */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -264,14 +257,14 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Chanora */,
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
@@ -344,15 +337,15 @@
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
@@ -370,7 +363,7 @@
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
@@ -392,21 +385,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
CA110001000000000000A100 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -434,7 +412,7 @@
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Chanora */;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@@ -518,23 +496,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 349G7M4TQQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -555,7 +528,7 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
@@ -571,7 +544,7 @@
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
@@ -587,7 +560,7 @@
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
@@ -709,23 +682,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 349G7M4TQQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -740,23 +708,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 349G7M4TQQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -786,7 +749,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
@@ -799,26 +762,26 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../../../silero-coreml";
relativePath = ../../../silero-coreml;
};
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
8C5000032DD0000000000001 /* SileroCoreML */ = {
isa = XCSwiftPackageProductDependency;
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@@ -15,8 +15,8 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
@@ -33,8 +33,8 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
@@ -50,8 +50,8 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
@@ -86,8 +86,8 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
@@ -103,8 +103,8 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
@@ -11,10 +11,6 @@ import AVFoundation
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// Configure the iOS AVAudioSession **category + mode** at
// app-launch time, but DEFER setActive(true) until the scene
// is foregrounded. Calling setActive in didFinishLaunching is
+10 -9
View File
@@ -4,6 +4,9 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
supported iPhones. Flutter's iOS embedder reads this key; no
additional Flutter package is required for dynamic refresh. -->
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -24,16 +27,10 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to your voice servers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Chanora needs microphone access so you can talk on your voice server.</string>
<string>Chanora needs microphone access so you can talk on your TeamSpeak-compatible voice server.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -61,8 +58,8 @@
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -80,5 +77,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>
@@ -1,5 +1,4 @@
import CoreML
import Darwin
import Foundation
import SileroCoreML
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return }
free(string)
}
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
// Static linker references: keep the Swift compiler / linker from
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
// as a static reference for the dead-stripper these `_ = ` lines
// do. Without them, TestFlight builds shipped without the symbols
// even though Debug builds (no LTO) worked.
//
// The `withoutActuallyEscaping` dance prevents the optimizer from
// proving the references are unused: assigning the function value
// to a `@convention(c)` typealias forces address-taken semantics.
_ = unsafeBitCast(
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadProcess
as @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
UnsafeMutablePointer<Float>?
) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
to: UnsafeRawPointer.self,
)
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -85,13 +85,6 @@ Pod::Spec.new do |s|
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
cd "$REPO_ROOT"
HOME="$USER_HOME" \\
@@ -102,9 +95,6 @@ Pod::Spec.new do |s|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
if [ ! -f "$BRIDGE" ]; then
@@ -139,21 +129,7 @@ PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder), which is the
# failure mode that produced this prepare_command in the first
# place. dsymutil reads the DWARF that cargo emitted (enabled by
# [profile.release] debug = true at the workspace root) and writes
# chanora_bridge.framework.dSYM next to the framework. We then
# strip the in-framework binary so the shipped app stays slim —
# the symbols live exclusively in the dSYM bundle, which is the
# layout xcodebuild -exportArchive and App Store Connect expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
echo "[chanora_bridge.podspec] framework ready at $FW"
SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into
@@ -209,13 +185,6 @@ PLIST
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
RUST_TARGET="aarch64-apple-ios-sim"
SUPPORTED_PLATFORM="iPhoneSimulator"
@@ -234,9 +203,6 @@ PLIST
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/ios"
@@ -244,19 +210,15 @@ PLIST
# Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
FW_UP_TO_DATE=0
# builds where Rust didn't change).
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date"
FW_UP_TO_DATE=1
exit 0
fi
if [ "$FW_UP_TO_DATE" = 0 ]; then
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -272,28 +234,9 @@ PLIST
</dict>
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). Without this the archive validator
# fails with "archive did not include a dSYM for the
# chanora_bridge.framework with the UUIDs [<uuid>]" and the
# IPA cannot be uploaded to App Store Connect / TestFlight.
# ${DWARF_DSYM_FOLDER_PATH} resolves to <ARCHIVE>/dSYMs for
# archive builds and <BUILT_PRODUCTS_DIR> otherwise; both paths
# are the ones xcodebuild scans when collecting symbols.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed"
SCRIPT
:execution_position => :before_compile,
}
@@ -1,69 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Canonical layout breakpoints for Chanora.
//
// Aligned with Material 3 adaptive layout guidance:
// compact < 600dp — phone, narrow tablet
// medium 6001023 — tablet portrait, small desktop window
// expanded ≥ 1024dp — desktop, tablet landscape
//
// 1024dp was chosen as the expanded threshold based on production app
// research: Discord (member list at 1024px), Mattermost (RHS docked at
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
/// Canonical breakpoint thresholds in logical pixels.
///
/// Use these instead of hardcoded pixel values in layout decisions.
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
/// to these named constants.
class ChanoraBreakpoints {
ChanoraBreakpoints._();
/// Width at which the layout switches from compact to medium.
/// Below this: single-column mobile layout.
/// At/above: two-panel side-by-side layout.
static const double medium = 600;
/// Width at which the layout switches from medium to expanded.
/// Below this: chat opens as a pushed route.
/// At/above: three-panel layout with inline chat panel.
static const double expanded = 1024;
// Panel sizing constants.
/// Fixed width of the left voice/control panel.
static const double voicePanelWidth = 320;
/// Fixed width of the right chat panel (expanded layout only).
static const double chatPanelWidth = 380;
/// Horizontal gap between panels.
static const double panelGap = 12;
/// Desktop snackbar width cap (used when width ≥ [medium]).
static const double snackBarDesktopCap = 560;
/// Connect form action buttons switch from row to column below this width.
static const double connectActionsStackMaxWidth = 400;
/// Modal bottom sheet max height as fraction of screen height.
static const double modalSheetHeightFraction = 0.72;
}
/// Semantic layout class derived from viewport width.
enum LayoutClass {
/// < 600dp — single-column mobile layout.
compact,
/// 6001023dp — two-panel side-by-side layout.
medium,
/// ≥ 1024dp — three-panel layout with inline chat.
expanded,
}
/// Computes the current [LayoutClass] from viewport [width].
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
@@ -1,67 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Viewport info inherited widget for Chanora.
//
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
// and provides it to the entire widget subtree. Downstream widgets read
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
// `MediaQuery.sizeOf` directly for layout-class decisions.
import 'package:flutter/widgets.dart';
import 'breakpoints.dart';
/// Inherited widget that exposes the current layout class and viewport
/// dimensions to the entire subtree.
///
/// Insert this once near the top of the widget tree (inside the Scaffold
/// body or equivalent). All descendants can then read
/// `ViewportInfo.of(context)` to determine their layout behaviour.
class ViewportInfo extends InheritedWidget {
/// Creates a [ViewportInfo].
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
/// Current layout class derived from viewport width.
final LayoutClass layoutClass;
/// Current viewport width in logical pixels.
final double width;
/// Current viewport height in logical pixels.
final double height;
/// Returns the nearest [ViewportInfo] in the widget tree.
///
/// Asserts that a [ViewportInfo] ancestor exists.
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
/// Whether the current layout is compact (< 600dp).
bool get isCompact => layoutClass == LayoutClass.compact;
/// Whether the current layout is medium (6001023dp).
bool get isMedium => layoutClass == LayoutClass.medium;
/// Whether the current layout is expanded (≥ 1024dp).
bool get isExpanded => layoutClass == LayoutClass.expanded;
/// Whether the layout has room for at least two panels (medium or expanded).
bool get isWide => !isCompact;
@override
bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass;
// NOTE: width/height changes within the same layout class do NOT trigger
// notification. Dependents who genuinely need pixel-level dimensions
// (rare — most layouts should switch on layoutClass) must use a local
// LayoutBuilder. Notifying on every pixel would rebuild every dependent
// on every resize frame, which is the exact pessimisation this
// InheritedWidget exists to avoid.
}
-1
View File
@@ -46,7 +46,6 @@
"retryAction": "Retry",
"chatAction": "Chat",
"chatCloseAction": "Close chat",
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
"chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message",
-1
View File
@@ -39,7 +39,6 @@
"retryAction": "重试",
"chatAction": "聊天",
"chatCloseAction": "关闭聊天",
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
"chatNewPrivateAction": "新建私聊",
"chatSearchClientsHint": "搜索用户...",
"chatDirectMessageAction": "私聊",
@@ -307,12 +307,6 @@ abstract class AppL10n {
/// **'Close chat'**
String get chatCloseAction;
/// No description provided for @chatPanelCollapsedHint.
///
/// In en, this message translates to:
/// **'Tap the chat button to continue your conversation'**
String get chatPanelCollapsedHint;
/// No description provided for @chatNewPrivateAction.
///
/// In en, this message translates to:
@@ -123,10 +123,6 @@ class AppL10nEn extends AppL10n {
@override
String get chatCloseAction => 'Close chat';
@override
String get chatPanelCollapsedHint =>
'Tap the chat button to continue your conversation';
@override
String get chatNewPrivateAction => 'New private chat';
@@ -120,9 +120,6 @@ class AppL10nZh extends AppL10n {
@override
String get chatCloseAction => '关闭聊天';
@override
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
@override
String get chatNewPrivateAction => '新建私聊';
+299 -495
View File
@@ -16,8 +16,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'design/breakpoints.dart';
import 'design/viewport_info.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
@@ -33,14 +31,12 @@ import 'services/ui_preferences_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
import 'widgets/permission_state_banner.dart';
import 'widgets/audio_processing_config_state.dart';
import 'widgets/audio_debug_stats_panel.dart';
import 'widgets/chat_panel.dart';
import 'widgets/chat_views.dart';
import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -49,11 +45,8 @@ import 'widgets/voice_settings.dart';
import 'package:share_plus/share_plus.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
// Debug-only overlay. `kDebugMode` is a compile-time const in
// release builds (= false), so the overlay subtree is tree-shaken
// out of release/profile binaries entirely — release users never
// see internal audio stats and we don't pay the render cost.
bool get _showAudioDebugOverlay => kDebugMode && _isMacOS;
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
const Color _appSurfaceColor = Color(0xFFFFFBFE);
@@ -157,14 +150,9 @@ class _ChanoraAppState extends State<ChanoraApp> {
themeMode: _themeMode,
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Stack(
children: [
_BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
],
home: _BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
),
);
@@ -313,6 +301,8 @@ class _ReceivedPoke {
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
static const _wideBreakpoint = 600.0;
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
final _passwordCtl = TextEditingController();
@@ -378,37 +368,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
rust.BridgeMessageTarget? _inlineChatTarget;
String _inlineChatClientName = '';
bool _inlineChatCollapseNoticeShown = false;
/// Per-target draft text. Populated when switching away from a conversation
/// so the user's unfinished message is preserved.
final Map<String, String> _chatDrafts = {};
/// Channel IDs that have unread chat messages (used for dot indicators).
Set<BigInt> get _unreadChannelIds {
if (_chatOpen) return const {};
final ids = <BigInt>{};
for (final entry in _chatMessages) {
if (!entry.countsTowardUnread || entry.isSelf) continue;
final target = entry.target;
if (target is rust.BridgeMessageTarget_Channel) {
// Channel target has no ID payload — it means "current channel".
// We can't distinguish per-channel without the channel ID in the target.
// For now, if there are any unread channel messages, mark the current voice channel.
if (_currentVoiceChannelId != null) {
ids.add(_currentVoiceChannelId!);
}
}
}
return ids;
}
/// The last chat target before the panel was closed. Used to restore the
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
@@ -633,39 +592,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
}
void _showLocalNetworkDeniedSnackBar() {
if (!mounted) return;
final l10n = AppL10n.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 8),
content: Text(
l10n.networkPermissionBody,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
action: SnackBarAction(
label: l10n.networkPermissionOpenSettings,
onPressed: () {
// Fire-and-forget: don't block snackbar dismissal on the
// child process. `Process.run` may throw synchronously
// (e.g. exec ENOENT) or return a future that rejects
// (e.g. macOS denies fork); both paths are swallowed
// because the user can still open Settings manually.
unawaited(
Future<void>(() async {
await Process.run('open', [
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
]);
}).catchError((_) {}),
);
},
),
),
);
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = pttDisplayLabelForKey(event.logicalKey);
final isBoundKey =
@@ -1109,36 +1035,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
String? password,
}) async {
final connectEpoch = ++_connectionEpoch;
// Trigger macOS Local Network permission prompt before connecting.
// On macOS 15+ the first network connection triggers the system
// dialog; triggering it via NWBrowser here ensures the prompt
// appears *before* the actual connect attempt so the user can grant
// permission and the connection succeeds in one flow.
final lnState = _macOSPermissions.localNetworkState.value;
if (lnState == MacOSLocalNetworkState.unknown ||
lnState == MacOSLocalNetworkState.notDetermined) {
await _macOSPermissions.triggerLocalNetworkPrompt();
}
// If the prompt resolved to Denied (or was already Denied), confirm
// with a read-only NWConnection probe to the target host and show
// a snackbar directing the user to System Settings.
final resolvedState = _macOSPermissions.localNetworkState.value;
if (resolvedState == MacOSLocalNetworkState.denied) {
final targetHost = (host ?? _hostCtl.text).trim();
final accessState = await _macOSPermissions.checkLocalNetworkAccess(
host: targetHost,
port: 9987,
);
if (accessState == MacOSLocalNetworkState.denied) {
if (!mounted) return;
setState(() { _phase = ConnectionPhase.idle; });
_showLocalNetworkDeniedSnackBar();
return;
}
}
setState(() {
_phase = ConnectionPhase.connecting;
_snapshot = null;
@@ -1175,6 +1071,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
if (errorStr.contains('PermissionDenied') ||
errorStr.contains('Operation not permitted')) {
_showPermissionDeniedDialog(errorStr);
}
setState(() {
_phase = ConnectionPhase.idle;
});
@@ -1199,6 +1100,59 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (_) {}
}
void _showPermissionDeniedDialog(String rawError) {
final l10n = AppL10n.of(context);
final isNetwork = rawError.contains('9987') || rawError.contains('connect');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
),
content: Text(
isNetwork
? l10n.networkPermissionBody
: l10n.microphonePermissionBody,
),
actions: [
if (Platform.isIOS && !isNetwork)
TextButton(
onPressed: () {
Navigator.pop(ctx);
unawaited(_openIosAppSettings());
},
child: Text(l10n.networkPermissionOpenSettings),
),
if (Platform.isMacOS)
TextButton(
onPressed: () {
Navigator.pop(ctx);
try {
Process.run('open', [
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
]);
} catch (_) {}
},
child: Text(l10n.networkPermissionOpenSettings),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}
Future<void> _openIosAppSettings() async {
try {
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
} catch (_) {
// Best-effort affordance only; if iOS refuses the URL, the
// dialog still explained the missing microphone permission.
}
}
Future<void> _setPtt(bool active, {bool reportError = true}) async {
if (active) {
try {
@@ -1659,88 +1613,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatDrafts.clear();
_chatUnread = 0;
_chatOpen = false;
_inlineChatTarget = null;
_inlineChatClientName = '';
_inlineChatCollapseNoticeShown = false;
_lastDismissedTarget = null;
_lastDismissedClientName = '';
_notifyChatFeedChanged();
}
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Server() => 'server',
rust.BridgeMessageTarget_Channel() => 'channel',
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
};
}
/// Returns the current draft text for the inline chat target, or null
/// if no draft has been stored.
///
/// Draft persistence relies on [ChatDetailView]'s own lifecycle:
/// `didUpdateWidget` flushes the outgoing target's draft via
/// `onDraftChanged` when the parent rebuilds with a new target, and
/// `dispose` flushes the final draft when the panel is torn down
/// (e.g. on [_closeInlineChat]). Both paths feed
/// [_chatDrafts] without requiring an explicit save call from this
/// class.
String? _currentDraftFor(rust.BridgeMessageTarget target) =>
_chatDrafts[_draftKeyForTarget(target)];
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
// Resolve the new target.
// If no target passed and panel is already open, switch to current voice channel.
// If no target passed and panel is closed, resolve from history or default.
rust.BridgeMessageTarget newTarget;
if (target != null) {
newTarget = target;
} else if (_chatOpen && _currentVoiceChannelId != null) {
newTarget = const rust.BridgeMessageTarget.channel();
} else if (_inlineChatTarget != null) {
newTarget = _inlineChatTarget!;
} else {
newTarget =
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
) ??
const rust.BridgeMessageTarget.server();
}
final newClientName = clientName.isNotEmpty
? clientName
: (newTarget == _inlineChatTarget)
? _inlineChatClientName
: (newTarget == _lastDismissedTarget)
? _lastDismissedClientName
: '';
final isExpanded =
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
LayoutClass.expanded;
if (isExpanded) {
// Outgoing draft is preserved by ChatDetailView.didUpdateWidget,
// which fires onDraftChanged with the old target's text on rebuild.
setState(() {
_chatUnread = 0;
_chatOpen = true;
_inlineChatTarget = newTarget;
_inlineChatClientName = newClientName;
_inlineChatCollapseNoticeShown = false;
});
return;
}
setState(() {
_chatUnread = 0;
_chatOpen = true;
@@ -1753,8 +1635,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget: newTarget,
initialClientName: newClientName,
initialTarget:
target ??
resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
),
initialClientName: clientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
@@ -1762,50 +1649,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _closeInlineChat() {
// Final draft for the dismissed target is preserved by
// ChatDetailView.dispose, which fires onDraftChanged when the panel
// is removed from the tree on the next rebuild.
setState(() {
_lastDismissedTarget = _inlineChatTarget;
_lastDismissedClientName = _inlineChatClientName;
_chatOpen = false;
// Do NOT null _inlineChatTarget — remember it for reopen.
});
}
void _handleInlineChatViewport(LayoutClass layoutClass) {
if (_inlineChatTarget == null) return;
if (layoutClass == LayoutClass.expanded) {
_inlineChatCollapseNoticeShown = false;
if (!_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = true);
}
});
}
return;
}
if (_chatOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inlineChatTarget != null) {
setState(() => _chatOpen = false);
}
});
}
if (_inlineChatCollapseNoticeShown) return;
_inlineChatCollapseNoticeShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
),
);
});
}
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
await showModalBottomSheet<void>(
context: context,
@@ -1914,7 +1757,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
const side = 16.0;
var bottom = 16.0;
final wideConnectedLayout =
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
_serverReachable &&
_snapshot != null;
@@ -2402,8 +2245,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
)
: headerTitle;
final compactIdleChrome =
!_serverReachable &&
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
final compactIdleHeader = Row(
children: [
Expanded(
@@ -2435,10 +2277,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
_handleInlineChatViewport(layoutClass);
final isWideSnapshot =
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
bodyConstraints.maxWidth >= _wideBreakpoint &&
_serverReachable &&
_snapshot != null;
final banner = Container(
@@ -2452,273 +2292,237 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
),
);
return ViewportInfo(
layoutClass: layoutClass,
width: bodyConstraints.maxWidth,
height: bodyConstraints.maxHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ?? theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color:
connTokens.background ??
theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
),
],
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
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 == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
unreadChannelIds: _unreadChannelIds,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onOpenChannelChat: (channel) => unawaited(
_onOpenChat(
target: const rust.BridgeMessageTarget.channel(),
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
final layoutInfo = ViewportInfo.of(ctx);
if (layoutInfo.isWide) {
final inlineChatTarget = _inlineChatTarget;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: ChanoraBreakpoints.voicePanelWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
),
const SizedBox(width: ChanoraBreakpoints.panelGap),
Expanded(child: snapshotView),
if (layoutInfo.isExpanded &&
inlineChatTarget != null) ...[
const SizedBox(
width: ChanoraBreakpoints.panelGap,
),
ChatPanel(
messages: _chatMessages,
snapshot: _snapshot!,
target: inlineChatTarget,
clientName: _inlineChatClientName,
restoredDraft: _currentDraftFor(
inlineChatTarget,
),
onDraftChanged: (text) {
final draftKey = _draftKeyForTarget(
inlineChatTarget,
);
_chatDrafts[draftKey] = text;
},
onTs3ServerLink: _onTs3ServerLink,
onClose: _closeInlineChat,
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
),
),
],
),
],
),
const SizedBox(height: 12),
if (_phase == ConnectionPhase.idle ||
_phase == ConnectionPhase.disconnected) ...[
Expanded(
child: AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
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 == ConnectionPhase.connecting ||
awaitingServerSnapshot) ...[
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
statusText,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
],
),
),
),
),
] else if (_serverReachable && _snapshot != null) ...[
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final voiceBar = VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
talkPowerBlocked: _hardMuteByTalkPower,
releaseTailMs: _releaseTailMs,
channelName: snapshotChannelName(
_snapshot,
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
// banner. Self-hides on granted / unknown.
final permissionBanner =
PermissionStateBanner.fromCallbacks(
recordAudioState: _activeRecordAudioState,
ensureRecordAudio: _ensureActiveRecordAudio,
openAppSettings: _openActivePermissionSettings,
);
final snapshotView = SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
currentVoiceChannelId: _currentVoiceChannelId,
pendingVoiceChannelId: _pendingVoiceChannelId,
localInputMuted: _inputMuted || _hardMute,
localOutputMuted: _outputMuted,
hasJoinPending: _pendingVoiceChannelId != null,
canJoinVoiceChannel: _canJoinVoiceChannel,
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
enableClientLongPressMenu: isTouchOnlyPttHost,
onOpenClientInfo: (client) =>
unawaited(_onOpenClientInfo(client)),
onOpenClientChat: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.client(client.id),
clientName: client.name,
),
),
onOpenClientPoke: (client) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.poke(client.id),
clientName: client.name,
),
),
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
const voiceBarWidthWide = 320.0;
if (constraints.maxWidth >= _wideBreakpoint) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: voiceBarWidthWide,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
const SizedBox(height: 12),
permissionBanner,
voiceBar,
],
),
),
const SizedBox(width: 12),
Expanded(child: snapshotView),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
hardMuteByTalkPower: _hardMuteByTalkPower,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},
),
),
],
],
);
},
);
@@ -6,22 +6,7 @@ import '../src/rust/api.dart' as rust;
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
const macosAudioLifecycleChannelName = 'chanora/macos_audio_lifecycle';
/// Parses a platform-channel route string into a [rust.BridgeAudioRoute].
///
/// The producer contract is:
/// - iOS: `AppDelegate.classifyAudioRoute(_:)` emits one of
/// `Earpiece`, `Speaker`, `WiredHeadset`, `BluetoothHfp`, `BluetoothA2dp`,
/// `Unknown`.
/// - Android: `AndroidAudioLifecycleController.classifyDevice` emits one of
/// `Earpiece`, `Speaker`, `WiredHeadset`, `UsbHeadset`, `BluetoothHfp`,
/// `BluetoothA2dp`, `Hdmi`, `Unknown`.
///
/// Both producers emit exact PascalCase strings. Case variants (`USB_HEADSET`,
/// `usb_headset`, `UsbHeadphone`) are NOT handled and will fall through to
/// `unknown`. If either platform classifier changes its string contract,
/// update both producers and this parser together.
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
switch (value) {
case 'Earpiece':
@@ -29,15 +14,11 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
case 'Speaker':
return rust.BridgeAudioRoute.speaker;
case 'WiredHeadset':
case 'UsbHeadset':
return rust.BridgeAudioRoute.wiredHeadset;
case 'BluetoothHfp':
return rust.BridgeAudioRoute.bluetoothHfp;
case 'BluetoothA2dp':
return rust.BridgeAudioRoute.bluetoothA2Dp;
case 'Hdmi':
case 'Unknown':
return rust.BridgeAudioRoute.unknown;
default:
return rust.BridgeAudioRoute.unknown;
}
@@ -46,7 +27,6 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
void wireAudioLifecycle() {
wireIosAudioLifecycle();
wireAndroidAudioLifecycle();
wireMacosAudioLifecycle();
}
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
@@ -125,42 +105,3 @@ void wireAndroidAudioLifecycle({
}
});
}
/// Wire the macOS audio lifecycle MethodChannel.
///
/// Swift side (`MacOSAudioLifecycle`) posts `handleDefaultDeviceChange`
/// (with `role: 'input' | 'output'`) when Core Audio HAL default-input /
/// default-output device changes, and `handleConfigurationChange` when
/// the VPIO AudioUnit reports a stream-format change. Closes the
/// iOS/macOS asymmetry noted in SysRS-051.
///
/// Current scope: events are received and logged. The FRB
/// `macosDefaultDeviceChanged` function that triggers a VPIO
/// re-bind on the engine is a follow-up; until it's exposed, the
/// macOS path mirrors the iOS `chanora/ios_audio_lifecycle` event
/// surface but does not yet trigger an engine-side restart.
void wireMacosAudioLifecycle({
bool isMacos = false,
MethodChannel channel = const MethodChannel(macosAudioLifecycleChannelName),
}) {
if (!isMacos && !Platform.isMacOS) return;
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleDefaultDeviceChange':
// TODO: call rust.macosDefaultDeviceChanged() once exposed
// via flutter_rust_bridge; until then the event is captured
// here for observability.
break;
case 'handleConfigurationChange':
// TODO: same — currently captured, no engine action yet.
break;
default:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
@@ -63,8 +63,6 @@ const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
@visibleForTesting
const String methodCheckLocalNetwork = 'checkLocalNetwork';
@visibleForTesting
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
@visibleForTesting
const String methodRequestNotifications = 'requestNotifications';
@visibleForTesting
const String methodCheckNotifications = 'checkNotifications';
@@ -442,37 +440,6 @@ class MacOSPermissionsService {
}
}
/// Probe whether Local Network access is currently denied for [host]:[port]
/// by creating a short-lived NWConnection and checking
/// `unsatisfiedReason == .localNetworkDenied`.
///
/// This does NOT trigger a new system prompt — it is a read-only check.
/// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms.
Future<MacOSLocalNetworkState> checkLocalNetworkAccess({
required String host,
required int port,
}) async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(
methodCheckLocalNetworkAccess,
<String, dynamic>{'host': host, 'port': port},
);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkLocalNetworkAccess failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
// -- Outbound: Notifications ----------------------------------------------
Future<MacOSPermissionState> _checkNotifications() async {
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
/// Semantic tones for lightweight, Material 3 SnackBars.
enum AppSnackBarVariant { neutral, success, warning, error }
@@ -9,6 +7,7 @@ enum AppSnackBarVariant { neutral, success, warning, error }
class AppSnackBar {
const AppSnackBar._();
static const double _desktopMaxWidth = 560;
static const double _radius = 16;
static const double _elevation = 3;
@@ -40,9 +39,9 @@ class AppSnackBar {
}) {
final scheme = Theme.of(context).colorScheme;
final viewWidth = MediaQuery.sizeOf(context).width;
final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
final useDesktopCap = viewWidth >= 600;
final snackBarWidth = useDesktopCap && margin == null
? ChanoraBreakpoints.snackBarDesktopCap
? _desktopMaxWidth
: null;
final effectiveMargin = useDesktopCap && margin != null
? _desktopCappedMargin(context, margin)
@@ -77,11 +76,7 @@ class AppSnackBar {
final viewWidth = MediaQuery.sizeOf(context).width;
final resolved = margin.resolve(Directionality.of(context));
final extraHorizontal =
(viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
0.0,
viewWidth,
) /
2;
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2;
return EdgeInsets.fromLTRB(
resolved.left + extraHorizontal,
resolved.top,
@@ -1,88 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'chat_views.dart';
/// Fixed-width inline chat panel for expanded desktop layouts.
class ChatPanel extends StatelessWidget {
/// Construct an inline chat panel.
const ChatPanel({
super.key,
required this.messages,
required this.snapshot,
required this.target,
required this.clientName,
required this.onClose,
this.restoredDraft,
this.onDraftChanged,
this.onTs3ServerLink,
});
/// Backing chat messages shared with the chat route.
final List<ChatEntry> messages;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Chat target shown in the panel.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Called when the user closes the inline panel.
final VoidCallback onClose;
/// External draft text to restore in the chat detail view.
final String? restoredDraft;
/// Called when the draft text changes.
final ValueChanged<String>? onDraftChanged;
@override
Widget build(BuildContext context) {
final currentChannelId = ownClientSnapshotState(snapshot)?.channelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
return SizedBox(
width: ChanoraBreakpoints.chatPanelWidth,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: BorderDirectional(
start: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: ChatDetailView(
messages: messages,
snapshot: snapshot,
target: target,
clientName: clientName,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: onTs3ServerLink,
restoredDraft: restoredDraft,
onDraftChanged: onDraftChanged,
messageMaxWidth: 500,
headerTrailing: IconButton(
tooltip: l10n.chatCloseAction,
icon: const Icon(Icons.close),
onPressed: onClose,
),
),
),
);
}
}
@@ -2,7 +2,6 @@ import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
@@ -14,6 +13,7 @@ import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
const double _chatMobileBreakpoint = 600;
/// One chat/activity message shown in the chat hub.
class ChatEntry {
@@ -616,7 +616,7 @@ class _ChatPageState extends State<ChatPage> {
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
final detail = ChatDetailView(
final detail = _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
@@ -641,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
body: LayoutBuilder(
builder: (context, constraints) {
final sidebar = _ChatSidebar(
compact: constraints.maxWidth < ChanoraBreakpoints.medium,
compact: constraints.maxWidth < _chatMobileBreakpoint,
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
@@ -650,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
);
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
if (constraints.maxWidth < _chatMobileBreakpoint) {
return Column(
children: [
sidebar,
@@ -1050,11 +1050,8 @@ class _ChannelGroup extends StatelessWidget {
}
}
/// Detail view for a single chat target, including message history and input.
class ChatDetailView extends StatefulWidget {
/// Construct a chat detail view.
const ChatDetailView({
super.key,
class _ChatDetailView extends StatefulWidget {
const _ChatDetailView({
required this.target,
required this.clientName,
required this.snapshot,
@@ -1062,50 +1059,21 @@ class ChatDetailView extends StatefulWidget {
required this.currentChannelId,
required this.channelName,
this.onTs3ServerLink,
this.headerTrailing,
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
});
/// Chat target displayed by this detail view.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Backing message list. Self-sent messages are appended here.
final List<ChatEntry> messages;
/// Current voice channel id for channel-chat send gating.
final BigInt? currentChannelId;
/// Current voice channel name for labels and placeholders.
final String channelName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Optional widget shown at the trailing edge of the header.
final Widget? headerTrailing;
/// Optional max width for message content.
final double? messageMaxWidth;
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
@override
State<ChatDetailView> createState() => _ChatDetailViewState();
State<_ChatDetailView> createState() => _ChatDetailViewState();
}
class _ChatDetailViewState extends State<ChatDetailView> {
class _ChatDetailViewState extends State<_ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
@@ -1132,35 +1100,8 @@ class _ChatDetailViewState extends State<ChatDetailView> {
clientName: widget.clientName,
);
@override
void initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
// Propagate the OUTGOING draft unconditionally, including empty
// text. The empty case is load-bearing: if the user typed text,
// saved it, restored it, then deleted everything, the parent
// map must learn the draft is now empty — otherwise the stale
// entry resurrects on the next target swap.
oldWidget.onDraftChanged?.call(_textCtl.text);
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override
void dispose() {
// Same unconditional flush on tear-down. The `isNotEmpty` guard
// here would silently drop "user cleared the field then closed
// the panel" into the same stale-entry bug class as didUpdateWidget.
widget.onDraftChanged?.call(_textCtl.text);
_textCtl.dispose();
_scrollCtl.dispose();
super.dispose();
@@ -1235,12 +1176,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
if (widget.headerTrailing != null) widget.headerTrailing!,
],
),
child: Text(_title, style: theme.textTheme.titleMedium),
),
Expanded(
child: msgs.isEmpty
@@ -1280,16 +1216,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: widget.messageMaxWidth ?? double.infinity,
),
child: _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
),
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
@@ -115,6 +114,7 @@ class _ConnectFormState extends State<ConnectForm> {
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
const stackedActionsMaxWidth = 400.0;
final connectButton = FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
@@ -125,8 +125,7 @@ class _ConnectFormState extends State<ConnectForm> {
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
);
if (constraints.maxWidth <=
ChanoraBreakpoints.connectActionsStackMaxWidth) {
if (constraints.maxWidth <= stackedActionsMaxWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -23,14 +23,12 @@ class SnapshotView extends StatefulWidget {
required this.localOutputMuted,
required this.hasJoinPending,
required this.canJoinVoiceChannel,
required this.unreadChannelIds,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.enableClientLongPressMenu = false,
this.onOpenClientInfo,
this.onOpenClientChat,
this.onOpenClientPoke,
this.onOpenChannelChat,
this.onTs3ServerLink,
});
@@ -58,9 +56,6 @@ class SnapshotView extends StatefulWidget {
/// True when the local client may join voice channels.
final bool canJoinVoiceChannel;
/// Set of channel IDs that have unread chat messages.
final Set<BigInt> unreadChannelIds;
/// Join an unlocked channel.
final ValueChanged<rust.BridgeChannel> onJoinChannel;
@@ -79,9 +74,6 @@ class SnapshotView extends StatefulWidget {
/// Open a poke composer for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
/// Open chat for a channel.
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -248,64 +240,47 @@ class _SnapshotViewState extends State<SnapshotView> {
);
}
return _ChannelContextMenu(
channel: channel,
onChat: widget.onOpenChannelChat != null
? () => widget.onOpenChannelChat!(channel)
: null,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.unreadChannelIds.contains(channel.id)) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
);
}
@@ -1107,60 +1082,3 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
);
}
}
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
/// long-press. Primary tap passes through to the child for voice join.
class _ChannelContextMenu extends StatelessWidget {
const _ChannelContextMenu({
required this.channel,
this.onChat,
required this.child,
});
final rust.BridgeChannel channel;
final VoidCallback? onChat;
final Widget child;
@override
Widget build(BuildContext context) {
if (onChat == null) return child;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) =>
_show(context, details.globalPosition),
onLongPressStart: (details) =>
_show(context, details.globalPosition),
child: child,
);
}
void _show(BuildContext context, Offset globalPosition) {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = RelativeRect.fromLTRB(
globalPosition.dx,
globalPosition.dy,
overlay.size.width - globalPosition.dx,
overlay.size.height - globalPosition.dy,
);
showMenu<String>(
context: context,
position: position,
items: [
PopupMenuItem(
value: 'chat',
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 18),
const SizedBox(width: 12),
Text(AppL10n.of(context).chatAction),
],
),
),
],
).then((value) {
if (value == 'chat') onChat?.call();
});
}
}
@@ -1,6 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
// Mirror Flutter-Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -1,11 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
// macOS Release defaults to DEAD_CODE_STRIPPING = YES. See
// ios/Flutter/Release.xcconfig for the full rationale; -u is the
// load-bearing flag, -exported_symbol re-exports for dlsym, both
// are intentionally present per @_cdecl symbol.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// See ios/Flutter/Release.xcconfig for the STRIP_STYLE rationale.
STRIP_STYLE = non-global
+1 -1
View File
@@ -13,7 +13,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
SPEC CHECKSUMS:
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
@@ -248,7 +248,6 @@
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
CA110002000000000000A200 /* Verify Silero Exports */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
@@ -442,21 +441,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
CA110002000000000000A200 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -7,10 +7,6 @@ class AppDelegate: FlutterAppDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// Ask for microphone access on launch rather than on first
// voice-channel join. Matches user expectations for a voice
// chat client and saves the user from a surprising prompt
@@ -2,10 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Flutter default: app-sandbox + JIT for the Dart VM. network.server is
required for the `flutter run` hot-reload listener AND, on macOS, for
every UDP bind() the app does (tsclientlib binds 0.0.0.0:0 for
outbound TS3 traffic and the sandbox treats that as a server op). -->
<!-- Flutter default: app-sandbox + JIT for the Dart VM + server
listener for `flutter run` hot-reload. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
@@ -22,8 +22,6 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
@@ -1,106 +0,0 @@
import Cocoa
import CoreAudio
import FlutterMacOS
import os.log
// ---------------------------------------------------------------------------
// MacOSAudioLifecycle
//
// Native-side MethodChannel handler for macOS audio lifecycle events.
// Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via
// AVAudioSession (no AVAudioSession equivalent on macOS). The macOS
// equivalents of the iOS lifecycle events are:
//
// * Default audio device change Core Audio HAL default-input /
// default-output device property listeners (mirrors iOS route
// change). Posted as `handleDefaultDeviceChange` with payload
// `{role: 'input' | 'output'}`.
//
// * VPIO AudioUnit configuration change observed via the VPIO
// unit's `kAudioUnitProperty_StreamFormat` property listener.
// Posted as `handleConfigurationChange` with no payload.
//
// Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the
// matching `chanora/ios_audio_lifecycle`-shaped event surface in
// `audio_lifecycle_service.dart` and currently logs the events; the
// engine-restart call (FRB `macos_default_device_changed`) is a
// follow-up.
//
// Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311
// (the deferred macOS audio-lifecycle platform-adapter allocation).
// ---------------------------------------------------------------------------
private let kLogTag = "chanora_flutter.macos_audio_lifecycle"
final class MacOSAudioLifecycle: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel?
private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:]
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "chanora/macos_audio_lifecycle",
binaryMessenger: registrar.messenger
)
let instance = MacOSAudioLifecycle()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
instance.startObserving()
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// macOS audio lifecycle is a one-way push: Swift Dart.
// The Dart side never invokes methods on this channel.
result(FlutterMethodNotImplemented)
}
// MARK: - Core Audio HAL listeners
private func startObserving() {
registerDefaultDeviceListener(role: "output")
registerDefaultDeviceListener(role: "input")
}
private func registerDefaultDeviceListener(role: String) {
let selector: AudioObjectPropertySelector = (role == "input")
? kAudioHardwarePropertyDefaultInputDevice
: kAudioHardwarePropertyDefaultOutputDevice
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject)
let channel = self.channel
let block: AudioObjectPropertyListenerBlock = { _, _ in
os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role])
}
let status = AudioObjectAddPropertyListener(objectID, &address, block, nil)
if status == noErr {
listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block
os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
} else {
os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)",
log: OSLog(subsystem: kLogTag, category: "lifecycle"),
type: .error, role, Int(status))
}
}
deinit {
// Best-effort cleanup; AudioObjectRemovePropertyListener only
// matters if the system still holds the block.
for (id, block) in listenerBlocks {
for selector in [
kAudioHardwarePropertyDefaultInputDevice,
kAudioHardwarePropertyDefaultOutputDevice,
] {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
_ = AudioObjectRemovePropertyListener(id, &address, block, nil)
}
}
}
}
@@ -63,18 +63,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
case "triggerLocalNetworkPrompt":
triggerLocalNetworkPrompt(result: result)
case "checkLocalNetworkAccess":
guard let args = call.arguments as? [String: Any],
let host = args["host"] as? String,
let port = args["port"] as? Int else {
result(FlutterError(
code: "INVALID_ARGS",
message: "checkLocalNetworkAccess requires host (String) and port (Int)",
details: nil))
return
}
checkLocalNetworkAccess(host: host, port: port, result: result)
// -- Notifications ------------------------------------------------------
case "checkNotifications":
checkNotifications(result: result)
@@ -211,10 +199,10 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
case .failed(let error):
if !resolved {
resolved = true
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
// This is the canonical signal that the user denied the Local Network prompt.
if case .dns(let dnsError) = error,
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
let code = error.errorCode
// POSIX permission-denied or network-down signals that
// the user denied the Local Network prompt.
if code == ENETDOWN || code == EACCES || code == EPERM {
result("Denied")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Denied",
@@ -226,23 +214,11 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
}
}
browser.cancel()
case .waiting(let error):
// The browser is waiting for network. If the specific DNS error
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
// the Local Network prompt report immediately.
if case .dns(let dnsError) = error,
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
if !resolved {
resolved = true
result("Denied")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Denied",
])
}
browser.cancel()
}
// Otherwise the system dialog may be showing wait for
// .ready, .failed, or the timeout.
case .waiting:
// The browser is waiting for network this is normal and
// may mean the permission dialog is showing. Don't resolve
// yet; wait for .ready or .failed or the timeout.
break
case .setup, .cancelled:
break
@unknown default:
@@ -271,77 +247,6 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
}
}
/// Probe whether Local Network access is currently denied for a specific
/// host:port by creating a short-lived NWConnection and checking
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
/// new system prompt it is a read-only check.
private func checkLocalNetworkAccess(
host: String, port: Int, result: @escaping FlutterResult
) {
if #available(macOS 15.0, *) {
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
} else {
result("Unsupported")
}
}
@available(macOS 15.0, *)
private func checkLocalNetworkAccessImpl(
host: String, port: Int, result: @escaping FlutterResult
) {
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
result("NotDetermined")
return
}
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(
host: endpointHost, port: endpointPort, using: .tcp)
let queue = DispatchQueue(
label: "app.chanora.macos_permissions.local_network_check")
var didComplete = false
func finish(_ state: String) {
guard !didComplete else { return }
didComplete = true
connection.cancel()
result(state)
}
connection.stateUpdateHandler = { state in
switch state {
case .waiting, .failed:
if connection.currentPath?.unsatisfiedReason
== .localNetworkDenied {
finish("Denied")
} else {
finish("NotDetermined")
}
case .ready:
finish("Granted")
case .cancelled:
finish("NotDetermined")
case .setup, .preparing:
break
@unknown default:
break
}
}
connection.start(queue: queue)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
if !didComplete {
if connection.currentPath?.unsatisfiedReason
== .localNetworkDenied {
finish("Denied")
} else {
finish("NotDetermined")
}
}
}
}
// =========================================================================
// Notifications
// =========================================================================
@@ -399,8 +304,6 @@ class MainFlutterWindow: NSWindow {
// Register the macOS permissions MethodChannel handler.
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
// Register the macOS audio-lifecycle MethodChannel handler (closes the iOS/macOS asymmetry in SysRS-051).
MacOSAudioLifecycle.register(with: flutterViewController.registrar(forPlugin: "MacOSAudioLifecycle"))
super.awakeFromNib()
}
@@ -2,26 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Release builds omit cs.allow-jit (Flutter hot-reload only). They keep
network.server because the macOS App Sandbox classifies UDP bind()
against a local port - including the ephemeral 0.0.0.0:0 that
tsclientlib's tokio::net::UdpSocket::bind() issues for outbound
voice traffic - as a server operation that requires
com.apple.security.network.server, regardless of whether the
socket is later used only to sendto() a remote peer. Without it,
bind() returns EPERM and the sandbox log records
"Sandbox: chanora(...) deny(1) network-bind". network.client
alone gates outbound connect()-style flows (TCP, connected UDP)
and is insufficient for the bind()-then-sendto() pattern Tokio's
UdpSocket uses. See Apple's App Sandbox entitlement reference:
"Network Server" covers any process that listens on, or binds
to, a network port. -->
<!-- Release builds omit the cs.allow-jit + network.server entitlements
used only by Flutter's debug hot-reload. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
@@ -1,5 +1,4 @@
import CoreML
import Darwin
import Foundation
import SileroCoreML
@@ -97,136 +96,3 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return }
free(string)
}
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
// Static linker references: keep the Swift compiler / linker from
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
// as a static reference for the dead-stripper these `_ = ` lines
// do. Without them, TestFlight builds shipped without the symbols
// even though Debug builds (no LTO) worked.
//
// The `withoutActuallyEscaping` dance prevents the optimizer from
// proving the references are unused: assigning the function value
// to a `@convention(c)` typealias forces address-taken semantics.
_ = unsafeBitCast(
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadProcess
as @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
UnsafeMutablePointer<Float>?
) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
to: UnsafeRawPointer.self,
)
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -52,25 +52,19 @@ Pod::Spec.new do |s|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
PATH="$HOME/.cargo/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\
LIBOPUS_NO_PKG=1 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
PATH="$HOME/.cargo/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\
LIBOPUS_NO_PKG=1 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
if [ ! -f "$BRIDGE_ARM64" ]; then
@@ -118,19 +112,7 @@ PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
"$FW/Versions/A/chanora_bridge"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder). dsymutil reads
# the DWARF that cargo emitted (enabled by [profile.release]
# debug = true at the workspace root) and writes the bundle next
# to the framework. We then strip the in-framework binary so the
# shipped app stays slim; symbols live in the dSYM bundle, which
# is the layout xcodebuild -exportArchive and notarisation expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
echo "[chanora_bridge.podspec] framework ready at $FW"
SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into
@@ -159,54 +141,44 @@ PLIST
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
PATH="$HOME/.cargo/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \
LIBOPUS_NO_PKG=1 \
CARGO_PROFILE_RELEASE_DEBUG=true \
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
CARGO_PROFILE_RELEASE_STRIP=false \
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
PATH="$HOME/.cargo/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \
LIBOPUS_NO_PKG=1 \
CARGO_PROFILE_RELEASE_DEBUG=true \
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
CARGO_PROFILE_RELEASE_STRIP=false \
cargo build --release --target x86_64-apple-darwin -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/macos"
FW=Frameworks/chanora_bridge.framework
FW_UP_TO_DATE=0
# Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
# builds where Rust didn't change).
if [ -f "$FW/Versions/A/chanora_bridge" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_ARM64" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_X86_64" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date"
FW_UP_TO_DATE=1
exit 0
fi
if [ "$FW_UP_TO_DATE" = 0 ]; then
# Create universal binary with lipo.
mkdir -p "$(dirname "$UNIVERSAL")"
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
# Create universal binary with lipo.
mkdir -p "$(dirname "$UNIVERSAL")"
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources"
ln -sfh A "$FW/Versions/Current"
ln -sfh Versions/Current/Resources "$FW/Resources"
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources"
ln -sfh A "$FW/Versions/Current"
ln -sfh Versions/Current/Resources "$FW/Resources"
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -223,24 +195,9 @@ PLIST
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
"$FW/Versions/A/chanora_bridge"
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). See the iOS podspec for the full
# rationale — same constraint applies to macOS notarisation
# and archive-based distribution.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
"$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed"
SCRIPT
:execution_position => :before_compile,
}
@@ -1,79 +0,0 @@
#!/bin/sh
# verify_silero_exports.sh
#
# Asserts that all six chanora_silero_vad_* C symbols that the Rust
# chanora_bridge framework resolves via dlsym(RTLD_DEFAULT) are present
# in the linked app binary's dynamic export table — verified per
# architecture slice for macOS universal builds, because `nm` on a
# universal Mach-O without -arch will succeed if a symbol exists in ANY
# slice, not every slice. A missing symbol in just the x86_64 slice
# would silently break Intel Macs.
#
# Without this check, Xcode Archive's -dead_strip can remove these
# Swift @_cdecl symbols (no Swift caller exists) and CoreML VAD falls
# back to WebRTC on TestFlight/App Store with no compile-time,
# link-time, or runtime warning. We hit that bug once; this script
# ensures we never ship it again.
#
# Runs as an Xcode build phase after Link Binary, on iOS and macOS.
set -e
if [ -z "${TARGET_BUILD_DIR}" ] || [ -z "${EXECUTABLE_PATH}" ]; then
echo "error: verify_silero_exports.sh requires TARGET_BUILD_DIR and EXECUTABLE_PATH (run from Xcode build phase)" >&2
exit 1
fi
BINARY="${TARGET_BUILD_DIR}/${EXECUTABLE_PATH}"
if [ ! -f "${BINARY}" ]; then
echo "error: app binary not found at ${BINARY}" >&2
exit 1
fi
REQUIRED_SYMBOLS="
_chanora_silero_vad_create
_chanora_silero_vad_destroy
_chanora_silero_vad_reset
_chanora_silero_vad_process
_chanora_silero_vad_last_error
_chanora_silero_vad_free_string
"
# Enumerate slices. `lipo -archs` prints arches space-separated for
# universal Mach-O; for thin binaries it prints the single arch.
ARCHS=$(xcrun lipo -archs "${BINARY}" 2>/dev/null || echo "")
if [ -z "${ARCHS}" ]; then
echo "error: xcrun lipo -archs failed for ${BINARY}; cannot enumerate slices" >&2
exit 1
fi
FAILED=0
for arch in ${ARCHS}; do
EXPORTED=$(xcrun nm -arch "${arch}" -gU "${BINARY}" 2>/dev/null | awk '{print $NF}')
if [ -z "${EXPORTED}" ]; then
echo "error: xcrun nm produced no output for arch=${arch} on ${BINARY}" >&2
FAILED=1
continue
fi
MISSING=""
for sym in ${REQUIRED_SYMBOLS}; do
if ! echo "${EXPORTED}" | grep -qx "${sym}"; then
MISSING="${MISSING} ${sym}"
fi
done
if [ -n "${MISSING}" ]; then
echo "error: chanora_silero_vad exports missing from $(basename "${BINARY}") [arch=${arch}]:${MISSING}" >&2
FAILED=1
fi
done
if [ "${FAILED}" -ne 0 ]; then
echo "error: Rust dlsym(RTLD_DEFAULT) resolution will fail and CoreML VAD will fall back to WebRTC." >&2
echo "error: Check OTHER_LDFLAGS -exported_symbol entries in Flutter/*.xcconfig and the @_cdecl exports in Runner/SileroCoreMLBridge.swift." >&2
exit 1
fi
echo "verify_silero_exports: all 6 chanora_silero_vad_* symbols present in $(basename "${BINARY}") [arches: ${ARCHS}]"
@@ -4,7 +4,7 @@ import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
test('parseBridgeAudioRoute maps iOS-classified route strings', () {
test('parseBridgeAudioRoute maps platform route names', () {
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
expect(
@@ -22,34 +22,4 @@ void main() {
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
});
test('parseBridgeAudioRoute maps Android UsbHeadset to wiredHeadset', () {
// AndroidAudioLifecycleController.classifyDevice emits 'UsbHeadset' for
// AudioDeviceInfo.TYPE_USB_HEADSET. USB audio is functionally a
// wired-class device — the Kotlin classifier's own preference ordering
// (line 176 of AndroidAudioLifecycleController.kt) groups it with
// WiredHeadset/BluetoothHfp/BluetoothA2dp.
expect(parseBridgeAudioRoute('UsbHeadset'),
rust.BridgeAudioRoute.wiredHeadset);
});
test('parseBridgeAudioRoute maps Android Hdmi to unknown', () {
// HDMI is a display-out transport, not a voice-call audio path; no
// existing BridgeAudioRoute variant fits. Treat as unknown rather
// than misclassify as Speaker.
expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown);
});
test('wireMacosAudioLifecycle is a no-op on non-macOS and registers on macOS', () {
// MethodChannel needs a binary messenger, which requires the test
// binding to be initialised first.
TestWidgetsFlutterBinding.ensureInitialized();
// Channel name constant matches the Swift side (MacOSAudioLifecycle.swift).
expect(macosAudioLifecycleChannelName, 'chanora/macos_audio_lifecycle');
// The wire is a no-op on the test platform (CI defaults to host OS
// which may be macOS or Linux). On Linux it returns early; on macOS
// it installs a handler. Either way, it must not throw.
expect(() => wireMacosAudioLifecycle(), returnsNormally);
});
}
@@ -301,61 +301,6 @@ void main() {
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() emits outbound '
'checkLocalNetworkAccess MethodCall with host and port arguments',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Denied';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: '192.168.1.42',
port: 9987,
);
final calls = outgoingCalls
.where((c) => c.method == methodCheckLocalNetworkAccess)
.toList();
expect(calls, hasLength(1));
final args = calls.single.arguments as Map;
expect(args['host'], '192.168.1.42');
expect(args['port'], 9987);
expect(result, MacOSLocalNetworkState.denied);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() parses Granted and updates '
'localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Granted';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: 'ts.example.com',
port: 9987,
);
expect(result, MacOSLocalNetworkState.granted);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
'requestNotifications MethodCall; returns the platform response',
@@ -557,30 +502,4 @@ void main() {
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
throw PlatformException(code: 'probe-failed');
}
return null;
};
// Probe path failures (e.g. NWConnection couldn't establish a
// listener, or the Swift side threw) must not crash callers
// — they must fall back to whatever the service already cached.
final result = await svc.checkLocalNetworkAccess(
host: '127.0.0.1',
port: 9987,
);
expect(result, isNotNull);
svc.dispose();
},
);
}
@@ -1,201 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/chat_panel.dart';
import 'package:chanora_flutter/widgets/chat_views.dart';
void main() {
rust.BridgeSnapshot snapshot() {
final channelId = BigInt.from(10);
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: channelId,
parent: BigInt.zero,
name: 'Lobby',
order: 0,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: [
rust.BridgeClient(
id: BigInt.one,
channel: channelId,
name: 'Me',
inputMuted: false,
outputMuted: false,
isSpeaking: false,
isServerQuery: false,
talkPower: 0,
talkPowerGranted: true,
),
],
ownClientId: BigInt.one,
);
}
testWidgets('inline chat panel renders target, messages, and close action', (
tester,
) async {
var closed = false;
final messages = [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alice',
message: 'Hello from channel',
target: const rust.BridgeMessageTarget.channel(),
),
];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatPanel(
messages: messages,
snapshot: snapshot(),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
onClose: () => closed = true,
),
),
),
);
expect(find.text('# Lobby'), findsOneWidget);
expect(find.text('Hello from channel'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(closed, isTrue);
});
testWidgets('chat detail restores target drafts when the target changes', (
tester,
) async {
String? savedDraft;
final messages = <ChatEntry>[];
Widget detail({
required rust.BridgeMessageTarget target,
required String? restoredDraft,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(),
target: target,
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
restoredDraft: restoredDraft,
onDraftChanged: (text) => savedDraft = text,
),
),
);
}
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.channel(),
restoredDraft: 'channel draft',
),
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'channel draft',
);
await tester.enterText(find.byType(TextField), 'typed channel draft');
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.server(),
restoredDraft: 'server draft',
),
);
expect(savedDraft, 'typed channel draft');
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'server draft',
);
});
testWidgets(
'chat detail propagates empty draft when the user clears it before switching target',
(tester) async {
// Regression: previously, _ChatDetailViewState only emitted
// onDraftChanged when the text was non-empty. If the user
// restored a saved draft, deleted it, then switched target,
// the stale entry stayed in the parent's draft map and
// resurrected on the next target swap.
String? savedDraft = 'sentinel-unset';
final messages = <ChatEntry>[];
Widget detail({
required rust.BridgeMessageTarget target,
required String? restoredDraft,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(),
target: target,
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
restoredDraft: restoredDraft,
onDraftChanged: (text) => savedDraft = text,
),
),
);
}
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.channel(),
restoredDraft: 'previously saved channel draft',
),
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'previously saved channel draft',
);
// User clears the field, then switches target.
await tester.enterText(find.byType(TextField), '');
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.server(),
restoredDraft: null,
),
);
// The empty string MUST reach the parent so the stale entry
// is overwritten in the draft map. With the previous guarded
// implementation, savedDraft would still hold the sentinel.
expect(
savedDraft,
'',
reason: 'empty draft must overwrite stale entry on target swap',
);
},
);
}
@@ -52,13 +52,11 @@ void main() {
String welcomeMessage = '',
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
Set<BigInt> unreadChannelIds = const {},
rust.BridgeAudioStats? audioStats,
bool enableClientLongPressMenu = false,
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
ValueChanged<rust.BridgeClient>? onOpenClientChat,
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
ValueChanged<rust.BridgeChannel>? onOpenChannelChat,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
@@ -81,14 +79,12 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: unreadChannelIds,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
enableClientLongPressMenu: enableClientLongPressMenu,
onOpenClientInfo: onOpenClientInfo,
onOpenClientChat: onOpenClientChat,
onOpenClientPoke: onOpenClientPoke,
onOpenChannelChat: onOpenChannelChat,
),
),
);
@@ -137,32 +133,6 @@ void main() {
);
});
testWidgets('renders unread dot on channels with unread chat messages', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Lobby')],
clients: const [],
unreadChannelIds: {BigInt.one},
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.constraints?.maxWidth == 8 &&
widget.constraints?.maxHeight == 8 &&
widget.decoration is BoxDecoration &&
(widget.decoration! as BoxDecoration).shape == BoxShape.circle,
),
findsOneWidget,
);
});
testWidgets('collapsing a channel hides users and child channels', (
tester,
) async {
@@ -580,7 +550,6 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {},
),
@@ -608,64 +577,6 @@ void main() {
expect(tapped!.neededTalkPower, 12);
});
testWidgets('channel context menu opens chat without replacing voice join', (
tester,
) async {
final lobby = channel(id: 1, name: 'Lobby');
rust.BridgeChannel? joined;
rust.BridgeChannel? openedChat;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [lobby],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => joined = channel,
onJoinChannelWithPassword: (_) {},
onOpenChannelChat: (channel) => openedChat = channel,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Lobby'));
await tester.pump();
expect(joined, lobby);
expect(openedChat, isNull);
expect(find.text('Chat'), findsNothing);
await tester.tap(find.text('Lobby'), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsOneWidget);
await tester.tap(find.text('Chat'));
await tester.pumpAndSettle();
expect(openedChat, lobby);
});
testWidgets('separator spacers render as line painters without raw text', (
tester,
) async {
@@ -700,7 +611,6 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
@@ -755,7 +665,6 @@ void main() {
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
+6 -6
View File
@@ -35,7 +35,7 @@ crossbeam = { version = "0.8", default-features = false, features = ["alloc", "c
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.18.0"
cpal = "0.17.3"
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
@@ -58,7 +58,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dyn
# 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.22.4", default-features = false }
jni = { version = "0.21", default-features = false }
ndk-context = "0.1"
# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
# Primary backend for SDD-111..SDD-115. The pre-compiled static library
@@ -84,7 +84,7 @@ bytemuck = { version = "1", features = ["derive"] }
# + WM_INPUT translation backed by a hidden message-only window, and
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
# require a per-backend OS thread that owns a message pump.
windows = { version = "0.62", features = [
windows = { version = "0.54", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
@@ -104,7 +104,7 @@ tracing-subscriber = { version = "0.3", features = ["registry"] }
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
# resampler). `harness = false` per bench entry below disables the
# default libtest harness so criterion can install its own.
criterion = "0.8"
criterion = "0.5"
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
# `benches/realtime_capture.rs` to count post-warmup heap allocations
# on the realtime capture path. Dev-dep only — does NOT affect
@@ -147,7 +147,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# Random token bytes for the portal handle_token / session_handle_token
# options. The portal recommends fresh tokens to scope its own
# object paths per call.
rand = "0.10"
rand = "0.8"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
@@ -166,4 +166,4 @@ rand = "0.10"
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency.
sdl2 = { version = "0.38", default-features = false }
sdl2 = { version = "0.37", default-features = false }
@@ -10,7 +10,6 @@ const PACKET_QUEUE_CAPACITY: usize = 100;
const CONTROL_QUEUE_CAPACITY: usize = 32;
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
#[derive(Debug)]
pub struct AudioPacket {
/// Client whose TeamSpeak audio packet this belongs to.
pub client_id: SessionAudioId,
@@ -19,14 +18,12 @@ pub struct AudioPacket {
}
/// Control commands from the main thread to the audio callback.
#[derive(Debug)]
pub enum AudioCommand {
/// Set a client's output volume.
SetVolume(SessionAudioId, f32),
/// Remove a client's decode queue.
// TODO: Wire to client disconnect path; handled in callback but no
// producer currently pushes this command.
#[allow(dead_code)]
RemoveClient(SessionAudioId),
}
@@ -121,64 +118,3 @@ impl AudioEventConsumer {
std::iter::from_fn(move || self.queue.control_queue.pop())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_packet(id: u64) -> AudioPacket {
let audio = chanora_protocol::AudioData::S2C {
codec: chanora_protocol::CodecType::OpusVoice,
id: 0x1234,
from: 0x5678,
data: &[1, 2, 3],
};
let out = chanora_protocol::OutAudio::new(&audio);
AudioPacket {
client_id: SessionAudioId(id),
data: InAudioBuf::try_new(chanora_protocol::Direction::S2C, out.data().to_vec())
.unwrap(),
}
}
#[test]
fn packet_overflow_increments_drop_counter() {
let queue = AudioEventQueue::new();
let producer = AudioEventQueue::producer(&queue);
for i in 0..PACKET_QUEUE_CAPACITY {
let packet = empty_packet(i as u64);
assert!(producer.push_packet(packet).is_ok());
}
let overflow = empty_packet(999);
assert!(producer.push_packet(overflow).is_err());
assert_eq!(queue.packets_dropped.load(Ordering::Relaxed), 1);
}
#[test]
fn consumer_drains_packets_and_controls() {
let queue = AudioEventQueue::new();
let producer = AudioEventQueue::producer(&queue);
let consumer = AudioEventQueue::consumer(&queue);
producer
.push_control(AudioCommand::SetVolume(SessionAudioId(7), 0.5))
.unwrap();
producer.push_packet(empty_packet(42)).unwrap();
let packets: Vec<_> = consumer.drain_packets(8).collect();
assert_eq!(packets.len(), 1);
assert_eq!(packets[0].client_id, SessionAudioId(42));
let controls: Vec<_> = consumer.drain_controls().collect();
assert_eq!(controls.len(), 1);
match controls[0] {
AudioCommand::SetVolume(id, vol) => {
assert_eq!(id, SessionAudioId(7));
assert_eq!(vol, 0.5);
}
AudioCommand::RemoveClient(_) => panic!("unexpected remove-client command"),
}
}
}
+9 -43
View File
@@ -1246,7 +1246,6 @@ impl AudioEngine {
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
#[cfg(any(target_os = "ios", target_os = "macos"))]
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
let voice_out_tx_for_backend = voice_out_tx.clone();
@@ -1279,8 +1278,8 @@ impl AudioEngine {
// VPIO render callback (commit 4) finds decoded frames
// waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let frames_received_for_task = frames_received.clone();
let handler_for_task = audio_handler.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
tokio::select! {
@@ -1293,15 +1292,10 @@ impl AudioEngine {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
let res = h.handle_packet(id, v.packet);
drop(h);
match res {
Ok(_) => {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
Err(e) => {
debug!(target: "chanora_audio", error = %e, "decode failed");
}
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
@@ -1818,27 +1812,8 @@ struct CaptureState {
/// after warmup. Same precedent as `mono_scratch` above.
frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
/// Time-based decimation for the level meter. The cpal callback
/// cadence depends on platform (256 frames at 48 kHz ≈ 187 Hz,
/// 512 frames ≈ 94 Hz, 1024 frames ≈ 47 Hz) and can change at
/// runtime on device or sample-rate switch. The bridge consumer
/// (`input_level_stream`) only reads at ~30 Hz, so computing
/// `sqrt()` + `log10()` on every callback wastes real-time budget
/// and caused buffer underruns on macOS CoreAudio with small
/// buffer sizes. We only emit a new dBFS sample after at least
/// [LEVEL_METER_INTERVAL] has elapsed since the previous emit,
/// which is platform-cadence-independent.
last_level_emit: std::time::Instant,
}
/// Minimum interval between input-level dBFS samples sent to the
/// bridge. Matches the consumer rate (`input_level_stream` at ~30 Hz).
/// Time-based gating is robust to cpal buffer-size and sample-rate
/// changes that a fixed callback-count would not be.
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
const LEVEL_METER_INTERVAL: std::time::Duration =
std::time::Duration::from_millis(33);
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
fn new(
@@ -1866,10 +1841,6 @@ impl CaptureState {
mono_scratch: Vec::with_capacity(4096),
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
// Start in the past so the first ingest emits immediately.
last_level_emit: std::time::Instant::now()
.checked_sub(LEVEL_METER_INTERVAL)
.unwrap_or_else(std::time::Instant::now),
}
}
@@ -1889,15 +1860,10 @@ impl CaptureState {
self.mono_scratch.push(sum / frame.len() as f32);
}
// Level meter: pay sqrt() + log10() only when at least
// LEVEL_METER_INTERVAL has elapsed, regardless of the
// platform's cpal callback cadence.
let now = std::time::Instant::now();
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
self.last_level_emit = now;
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
}
// Measure dBFS from pre-gain samples so the level meter
// reflects the raw mic input, not the amplified signal.
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
if mic_gain != 1.0 {
for s in &mut self.mono_scratch {
+37 -260
View File
@@ -74,7 +74,6 @@ use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use crossbeam::queue::ArrayQueue;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
@@ -551,13 +550,6 @@ pub struct IosVoiceUnit {
// wrapper's own Drop calls AudioComponentInstanceDispose
// after stop returns.
unit: Option<AudioUnit>,
// macOS-only: producer task (spawned in start_macos) polls
// this on every 20 ms tick and exits when set. Without it
// the tokio task captures `Arc<Mutex<AudioHandler>>` +
// `Arc<ArrayQueue<f32>>` and runs forever, leaking on every
// engine stop/restart cycle.
#[cfg(target_os = "macos")]
producer_shutdown: Arc<AtomicBool>,
}
impl IosVoiceUnit {
@@ -659,46 +651,6 @@ impl IosVoiceUnit {
)
.map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?;
// VPIO defaults to maximum "duck others" — when our voice plays,
// every other app's audio (Music, Safari, Discord, ...) is heavily
// attenuated. That is correct for a phone call but wrong for a
// chat client running alongside music or game audio. There is no
// public "off" switch; the lowest publicly-exposed level is Min
// and disabling Advanced Ducking turns off the voice-activity-
// driven dynamic ducking. This matches what Hume, Moonshine, and
// similar open-source VoIP/voice apps configure.
// Apple ref: kAUVoiceIOProperty_OtherAudioDuckingConfiguration
// Property ID 2108, Global scope, Output element (= 0).
// Struct layout matches AUVoiceIOOtherAudioDuckingConfiguration
// from <AudioToolbox/AUVoiceIOOtherAudioDuckingConfiguration.h>:
// Boolean (u8) mEnableAdvancedDucking + AUVoiceIOOtherAudio-
// DuckingLevel (u32) mDuckingLevel, #[repr(C)] yields 8 bytes
// with the 3-byte natural alignment pad before the u32.
#[repr(C)]
struct AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: u8,
m_ducking_level: u32,
}
const K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION: u32 = 2108;
const K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN: u32 = 10;
let ducking_config = AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: 0,
m_ducking_level: K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN,
};
// Apple introduced this property in iOS 17 / macOS 14. On older
// OS versions VPIO returns kAudioUnitErr_InvalidProperty (-10879)
// — log it but never fail VPIO startup over a ducking knob.
if let Err(e) = unit.set_property(
K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION,
Scope::Global,
Element::Output,
Some(&ducking_config),
) {
tracing::debug!(
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
);
}
// Note: we keep VPIO's voice processing chain ENABLED
// (AEC + AGC + NS on the mic path) because it gives us
// clean capture for free. The historical playback
@@ -785,14 +737,13 @@ impl IosVoiceUnit {
// buffer + mix. Same primitive cpal + SDL output
// paths use; this is the platform-neutral playback
// contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to mono i16 with master gain, then copy that
// mono sample across every output channel the callback
// exposes. We still request mono Int16 from VPIO, but
// the callback must respect the actual channel count it
// receives. The handler itself produces stereo f32, so
// we average L+R rather than dropping R — the cpal-side
// mono-output path made the same mistake briefly
// (commit 6a4dbad / fix) and lost half the spatial mix.
// 2. Downmix to i16 mono with master gain. VPIO expects
// mono int16 (the stream format we pinned above);
// the handler produces stereo f32. We average L+R
// to a single mono channel rather than dropping R —
// the cpal-side mono-output path made the same
// mistake briefly (commit 6a4dbad / fix) and lost
// half the spatial mix.
// 3. Local-mute zeroes the output but STILL drains
// AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the
@@ -821,185 +772,27 @@ impl IosVoiceUnit {
//
// Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and
// immediately downmixes to mono i16 replicated across the
// callback's actual output channels. Same as Linux/SDL,
// just stereo-f32 -> interleaved-i16 converted at the
// boundary.
// macOS cadence-fix (commit-f):
//
// The render callback is the wrong place to call AudioHandler::fill_buffer.
// macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
// is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
// inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
// satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
// which removes the talker and starts PLC silence/decay.
//
// Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
// WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
// Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
// tsclientlib's own SDL example, and the upstream tsclientlib::audio
// contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
// f32) from egress quantum (whatever VPIO asks for), connected by a
// lock-free ring of decoded PCM.
//
// Layout:
// * macOS: spawn a tokio producer task paced at 20 ms; each tick
// try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
// f32 into the ring. The render callback only pops — no lock,
// no Opus decode, no allocator on the audio thread. 60 ms
// prebuffer (3 × 20 ms) is held before the callback starts
// draining, matching Mumble's playout margin and WebRTC's
// kStartDelayMs order of magnitude. Crossbeam ArrayQueue
// is used because we already depend on crossbeam-queue.
// * iOS: keep the existing direct fill_buffer path — VPIO on iOS
// requests 480-frame slices that ARE 20 ms aligned so the
// cadence mismatch does not arise there.
#[cfg(target_os = "macos")]
let producer_shutdown = Arc::new(AtomicBool::new(false));
#[cfg(target_os = "macos")]
{
// 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
// prebuffer plus a few jitter spikes fit without forcing the
// producer to drop frames. crossbeam ArrayQueue is fixed-cap
// and lock-free SPSC-ish (MPMC but wait-free per end); for
// single producer + single consumer it's effectively SPSC.
const RING_CAPACITY: usize = 12000;
const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
let pcm_ring_producer = pcm_ring.clone();
let pcm_ring_consumer = pcm_ring.clone();
let handler_for_producer = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let producer_shutdown_for_task = producer_shutdown.clone();
tokio::spawn(async move {
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
let mut interval =
tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(
tokio::time::MissedTickBehavior::Delay,
);
loop {
interval.tick().await;
if producer_shutdown_for_task.load(Ordering::Relaxed) {
break;
}
match handler_for_producer.try_lock() {
Ok(mut h) => {
pull_scratch.fill(0.0);
let _ = h.fill_buffer(&mut pull_scratch[..]);
for &s in &pull_scratch {
pcm_ring_producer.force_push(s);
}
}
Err(std::sync::TryLockError::WouldBlock) => {}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio",
"producer: AudioHandler mutex poisoned: {e}");
break;
}
}
}
});
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data,
num_frames,
..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let needed = num_frames * out_channels;
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
for sample in &mut out[..needed] { *sample = 0; }
return Ok(());
}
// Pop L,R as a pair per frame. The pairing is
// load-bearing — `written += 2` half-buffer bug.
let mut written_frames: usize = 0;
while written_frames < num_frames {
let l = match pcm_ring_consumer.pop() {
Some(v) => v,
None => break,
};
let r = pcm_ring_consumer.pop().unwrap_or(0.0);
let mut frame = [l, r];
crate::voice_render::limit_peak_inplace(&mut frame, 0.99);
let (l_lim, r_lim) = (frame[0], frame[1]);
let base = written_frames * out_channels;
if out_channels == 1 {
let mono = (l_lim + r_lim) * 0.5;
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
} else {
out[base] =
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base + 1] =
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
}
written_frames += 1;
}
if written_frames < num_frames {
let remaining = num_frames - written_frames;
for f in 0..remaining {
let base = (written_frames + f) * out_channels;
for c in 0..out_channels { out[base + c] = 0; }
}
}
let gain = f32::from_bits(
output_gain_for_render.load(Ordering::Relaxed),
);
let muted =
output_muted_for_render.load(Ordering::Relaxed);
if muted {
for sample in &mut out[..needed] { *sample = 0; }
} else if gain != 1.0 {
for sample in &mut out[..needed] {
*sample = (((*sample as f32) * gain)
.clamp(i16::MIN as f32, i16::MAX as f32))
as i16;
}
}
Ok(())
})
.map_err(|e| AudioError::Backend(format!(
"audio unit set render callback: {e}"
)))?;
}
// iOS path: direct fill_buffer in callback. iOS VPIO
// requests 480-frame slices that align with tsclientlib's
// 20 ms Opus frame, so the cadence mismatch that macOS
// hits does not arise here. macOS has its own cfg-gated
// producer-task path above.
#[cfg(target_os = "ios")]
{
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
// immediately downmixes to i16 mono into the output
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
// Level meter decimation: the render callback fires ~93
// times/sec, but the bridge consumer reads at ~30 Hz.
let mut render_level_decimation: u32 = 0;
let wav_recorder_for_render = wav_recorder.clone();
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
let mut cb_count: u64 = 0;
let mut last_num_frames: usize = 0;
let mut num_frames_changes: u32 = 0;
let mut callbacks_with_audio: u64 = 0;
let mut callbacks_with_silence: u64 = 0;
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut render_ref_len: usize = 0;
let mut render_recorder_active = false;
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data,
num_frames,
..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let out: &mut [i16] = args.data.buffer;
let num_frames = out.len();
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
let needed = num_frames * 2;
if scratch_stereo.len() < needed {
@@ -1010,9 +803,13 @@ impl IosVoiceUnit {
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
// Non-blocking fill on the realtime callback thread.
// If the inbound forwarder currently owns this mutex,
// emit this period as silence instead of blocking and
// risking an AudioUnit underrun pop/click.
match handler_for_render.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats_for_render.increment_callback_xrun();
@@ -1023,28 +820,22 @@ impl IosVoiceUnit {
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
// Peak limiter — multi-client mixes can sum past 0 dBFS;
// without this the downmix helper would hard-clip to i16::MAX.
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
&scratch_stereo[..needed],
out,
out_channels,
gain,
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
}
render_level_decimation = render_level_decimation.wrapping_add(1);
if render_level_decimation % 3 == 0 {
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
}
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
if let Ok(guard) = wav_recorder_for_render.try_lock() {
if let Some(rec) = guard.as_ref() {
@@ -1070,17 +861,13 @@ impl IosVoiceUnit {
} else {
render_recorder_active = false;
}
// Track audio-vs-silence for the diagnostic.
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
audio_processing_stats_for_render.increment_output_underrun();
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
// Muted output writes intentional silence (peak_i16 == 0 by
// design), not a starved render path. Gate on !muted to avoid
// counting deliberate silence as an output underrun.
if !muted {
audio_processing_stats_for_render.increment_output_underrun();
}
}
// Diagnostic sampling.
@@ -1105,7 +892,6 @@ impl IosVoiceUnit {
Ok(())
})
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
} // end #[cfg(target_os = "ios")] block
// Finalise the unit — allocates internal buffers per the
// stream formats we set above. After initialize() most
@@ -1208,11 +994,7 @@ impl IosVoiceUnit {
),
}
Ok(Self {
unit: Some(unit),
#[cfg(target_os = "macos")]
producer_shutdown,
})
Ok(Self { unit: Some(unit) })
}
/// Restart the audio unit after route change handling.
@@ -1256,11 +1038,6 @@ impl IosVoiceUnit {
impl Drop for IosVoiceUnit {
fn drop(&mut self) {
// Signal the macOS producer task to exit on its next tick
// (up to 20 ms) so it releases its handler / ring clones.
#[cfg(target_os = "macos")]
self.producer_shutdown.store(true, Ordering::Relaxed);
// Stop the audio unit so the render callback no longer
// fires. The coreaudio-rs wrapper's own Drop calls
// AudioComponentInstanceDispose afterwards.
+1 -2
View File
@@ -28,8 +28,7 @@
#![warn(missing_docs)]
#[cfg(any(target_os = "android", test))]
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
#[cfg(target_os = "android")]
mod audio_event_queue;
pub mod audio_processing;
pub mod debug_wav;
@@ -67,7 +67,7 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
pub type AudioSessionId = i32;
/// Engine-owned state shared with mobile voice audio callbacks.
#[cfg_attr(target_os = "ios", derive(Clone))]
#[cfg_attr(not(target_os = "android"), derive(Clone))]
pub(crate) struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
@@ -85,10 +85,7 @@ pub(crate) struct VoiceAudioParams {
#[cfg(target_os = "android")]
pub event_producer: crate::audio_event_queue::AudioEventProducer,
/// AudioHandler that inbound decode+mix feeds into; the output
/// callback pulls mixed stereo f32 from it. iOS, macOS, and desktop
/// share this `Arc<Mutex<...>>` shape; the realtime callback uses
/// `try_lock` so it never blocks on the tokio decode task (see
/// `ios_voice_unit.rs` render callback).
/// callback pulls mixed stereo f32 from it.
#[cfg(not(target_os = "android"))]
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
-138
View File
@@ -12,7 +12,6 @@ pub(crate) struct RenderDownmixStats {
/// The helper is allocation-free and safe for realtime render callbacks.
/// If the stereo source is shorter than expected, the remainder of `out`
/// is filled with silence.
#[cfg(any(target_os = "ios", test))]
pub(crate) fn downmix_stereo_f32_to_mono_i16(
stereo: &[f32],
out: &mut [i16],
@@ -48,53 +47,6 @@ pub(crate) fn downmix_stereo_f32_to_mono_i16(
}
}
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
stereo: &[f32],
out: &mut [i16],
out_channels: usize,
gain: f32,
muted: bool,
) -> RenderDownmixStats {
if out_channels == 0 {
out.fill(0);
return RenderDownmixStats::default();
}
if muted {
out.fill(0);
return RenderDownmixStats::default();
}
let available_frames = stereo.len() / 2;
let requested_frames = out.len() / out_channels;
if available_frames < requested_frames {
out.fill(0);
}
let mut peak = 0_u16;
let mut clipped_samples = 0_u64;
for (dst_frame, lr) in out
.chunks_exact_mut(out_channels)
.zip(stereo.chunks_exact(2))
{
let mono = (lr[0] + lr[1]) * 0.5 * gain;
let clamped = mono.clamp(-1.0, 1.0);
if (mono - clamped).abs() > f32::EPSILON {
clipped_samples = clipped_samples.saturating_add(1);
}
let sample = (clamped * i16::MAX as f32) as i16;
for dst in dst_frame.iter_mut() {
*dst = sample;
}
peak = peak.max(sample.unsigned_abs());
}
RenderDownmixStats {
peak_i16: peak.min(i16::MAX as u16) as i16,
clipped_samples,
}
}
/// Downmix interleaved stereo f32 samples into mono f32 samples.
///
/// Used for software-AEC render references and debug WAV taps.
@@ -110,32 +62,6 @@ pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
}
}
/// In-place per-frame peak limiter. Scales the entire buffer so the
/// absolute peak equals `threshold`; returns the applied gain (1.0 =
/// no reduction). Used on the render path between `AudioHandler` and
/// the i16 downmix to prevent hard clipping when a multi-client mix
/// exceeds 0 dBFS. Per-frame scaling is sub-millisecond at 48 kHz, so
/// the pumping risk is negligible for speech; a look-ahead design was
/// rejected because it would add latency on top of the existing
/// jitter buffer.
pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
if threshold <= 0.0 || !threshold.is_finite() {
return 1.0;
}
let peak = samples
.iter()
.map(|s| s.abs())
.fold(0.0_f32, f32::max);
if peak <= threshold {
return 1.0;
}
let gain = threshold / peak;
for s in samples.iter_mut() {
*s *= gain;
}
gain
}
#[cfg(test)]
mod tests {
use super::*;
@@ -165,29 +91,6 @@ mod tests {
assert_eq!(stats, RenderDownmixStats::default());
}
#[test]
fn downmix_interleaved_i16_copies_mono_to_each_channel() {
let stereo = [1.0_f32, -1.0, 0.25, 0.25];
let mut out = [0_i16; 4];
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 2, 1.0, false);
assert_eq!(out, [0, 0, 8191, 8191]);
assert_eq!(stats.peak_i16, 8191);
assert_eq!(stats.clipped_samples, 0);
}
#[test]
fn downmix_interleaved_i16_mutes_all_channels() {
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
let mut out = [123_i16; 6];
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 3, 1.0, true);
assert_eq!(out, [0, 0, 0, 0, 0, 0]);
assert_eq!(stats, RenderDownmixStats::default());
}
#[test]
fn downmix_f32_fills_missing_tail_with_silence() {
let stereo = [1.0_f32, -1.0];
@@ -197,45 +100,4 @@ mod tests {
assert_eq!(out, [0.0, 0.0]);
}
#[test]
fn limit_peak_is_noop_below_threshold() {
let mut samples = [0.1_f32, -0.2, 0.3, -0.4];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert_eq!(gain, 1.0);
assert_eq!(samples, [0.1, -0.2, 0.3, -0.4]);
}
#[test]
fn limit_peak_scales_above_threshold() {
let mut samples = [0.5_f32, 1.0, 2.0, -1.5];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert!((gain - 0.475).abs() < 1e-6, "gain = {gain}");
assert!((samples[0] - 0.2375).abs() < 1e-6);
assert!((samples[1] - 0.475).abs() < 1e-6);
assert!((samples[2] - 0.95).abs() < 1e-6);
assert!((samples[3] - (-0.7125)).abs() < 1e-6);
}
#[test]
fn limit_peak_handles_zero_and_invalid_thresholds() {
let mut samples = [0.5_f32, 1.0];
assert_eq!(limit_peak_inplace(&mut samples, 0.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, -1.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, f32::NAN), 1.0);
assert_eq!(samples, [0.5, 1.0]);
}
#[test]
fn limit_peak_then_downmix_produces_no_clipping() {
// Regression: multi-client mix previously hard-clamped to i16::MAX.
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
limit_peak_inplace(&mut scratch, 0.95);
let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
assert_eq!(stats.clipped_samples, 0);
assert!(stats.peak_i16 < i16::MAX);
}
}
-5
View File
@@ -63,13 +63,8 @@ Design rules:
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
| Render peak limiter | `voice_render::limit_peak_inplace` is a single-pass, allocation-free per-frame peak scaler applied in both the macOS and iOS render callbacks before the i16 downmix; default threshold 0.99 prevents hard clipping on multi-client mixes that sum past 0 dBFS while remaining transparent for normal voice levels (allocation-free, lock-free, safe on the realtime audio thread) |
| macOS render cadence (producer + ring) | `ios_voice_unit.rs:851-961` runs a 20 ms tokio producer task that calls `AudioHandler::fill_buffer(1920)` and `force_push`es each sample into a `crossbeam ArrayQueue<f32>` (SPSC-effective, MPMC-but-wait-free-per-end); ring capacity 12000 samples ≈ 6.25× pull quantum; 100 ms prebuffer (`PREBUFFER_SAMPLES = 9600` stereo f32) before the VPIO render callback starts draining, matching Mumble's playout margin and WebRTC's kStartDelayMs order of magnitude |
| iOS render cadence (direct-fill) | `ios_voice_unit.rs:968-1034` does `AudioHandler::fill_buffer` directly in the VPIO render callback (VPIO on iOS requests 480-frame ≈ 10 ms slices that align with tsclientlib's 20 ms Opus frame); scratch buffer preallocated to 4096×2 f32 at setup time so the realtime callback never `resize()`s; `try_lock` (not `lock`) on the AudioHandler mutex so contention never stalls the realtime IO thread; on `WouldBlock` the callback emits silence and increments `callback_xrun` |
| VPIO ducking config (macOS 14+) | `ios_voice_unit.rs` writes an 8-byte `AuVoiceIoOtherAudioDuckingConfiguration` struct (`m_enable_advanced_ducking = 0` disables dynamic voice-activity-driven ducking; `m_ducking_level = kAUVoiceIOOtherAudioDuckingLevelMin = 10`) to selector `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` (= 2108) on the VoiceProcessingIO AudioUnit at startup, minimising the ducking of other apps' audio during a voice session; on macOS 13 the property is silently ignored (VPIO returns the default ducking behaviour) and the code logs a debug message and continues |
| Benchmarks | Realtime capture, Opus, and resampler benchmarks provide advisory baseline evidence |
## 6. Storage and Secret Design
@@ -16,7 +16,6 @@ This register records product and engineering decisions referenced by the DV doc
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
| DEC-030 VAD deferral | Accepted as deferral | `VoiceActivity` remains disabled/coming-soon |
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
## 3. DV Rule
-148
View File
@@ -1,148 +0,0 @@
# iOS / macOS Export Compliance
Status: TestFlight workaround applied. Production submission requires the steps below.
## Current state (2026-06-08)
Both `apps/chanora_flutter/ios/Runner/Info.plist` and `apps/chanora_flutter/macos/Runner/Info.plist` declare:
```xml
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
```
This is a **temporary workaround** to unblock TestFlight uploads. It is NOT correct for public App Store release because Chanora ships non-exempt encryption (see "Why Chanora is not exempt" below). The workaround is acceptable for internal testing under TestFlight while the BIS self-classification and Apple export-compliance documentation are being prepared.
## Why Chanora is not exempt
Chanora is a **closed-source proprietary** application that uses encryption beyond Apple's "standard system encryption" exemption:
1. `chacha20poly1305` in `crates/chanora_storage` — AEAD encryption of the local Data Encryption Key (DEK) for secret storage.
2. `tsclientlib` in `crates/chanora_protocol` and `crates/chanora_audio` — implements TeamSpeak's custom Ed25519 / P-256 / EAX cryptographic handshake. This is NOT standard IETF TLS.
3. `rustls` in `crates/chanora_protocol` and `crates/chanora_resolver` — ships its own TLS implementation rather than using the system stack.
Apple's exemption (HTTPS via system networking only) does not apply. Because Chanora is closed-source, the "publicly available source code" path (§734.3(b)(3) + §742.15(b)) is also unavailable — that route is reserved for open-source distributions whose cryptographic source is publicly accessible without restriction.
Chanora's path is therefore the **mass-market self-classification** route under §740.17(b)(1) with Note 3 to Category 5, Part 2 of the CCL — closed-source consumer apps with standard published algorithms qualify as mass-market and self-classify under **ECCN 5D992.c**. `ITSAppUsesNonExemptEncryption` must be `<true/>` for any public release, and `ITSEncryptionExportComplianceCode` must accompany it.
## The submission flow (in order)
### Step 1: File the BIS annual self-classification report
US Department of Commerce, Bureau of Industry and Security (BIS), requires an annual self-classification report for mass-market encryption software under EAR §740.17(b)(1). Since Chanora is **closed-source** but distributed as a mass-market consumer app with standard cryptographic algorithms, it qualifies for self-classification under the **mass-market note (Note 3 to Category 5, Part 2 of the CCL)**, which reclassifies it from ECCN 5D002.c.1 to **ECCN 5D992.c**.
There is **no fee**, **no review**, **no pre-approval needed**. You export immediately under (b)(1) and file the report within 30 days.
The format is a **CSV file** per Supplement No. 8 to Part 742, not a prose email body. Send it as an attachment:
**Recipients** (CC both):
- `crypt@bis.doc.gov`
- `enc@nsa.gov`
**Subject**: `Self-Classification Report — Chanora — [YEAR]`
**CSV template** (fill in `[BRACKETS]`):
```csv
Submitter Name,Submitter Email,Submitter Phone,Submitter Address,Item Type,Item Name,Item Version,ECCN,Authorization Type,Encryption Algorithm,Key Length,Item Description
[YOUR NAME],[YOU@EMAIL.COM],[PHONE],[ADDRESS],Mass Market Encryption Software,Chanora,[VERSION e.g. v1.0.0-rc.1],5D992.c,MMKT,ChaCha20-Poly1305; Ed25519; P-256 ECDH; EAX-AES; TLS 1.2/1.3,256,Cross-platform voice communication client for TeamSpeak-compatible servers. Cryptographic algorithms are all published standards; the application is closed-source proprietary software distributed via mass-market channels (Apple App Store).
```
**Notes on the CSV**:
- **Authorization Type** `MMKT` signals the mass-market self-classification under Note 3.
- **ECCN** `5D992.c` — the mass-market note reclassifies closed-source mass-market software with standard crypto from 5D002.c.1 to 5D992.c, removing it from "EI" and "NS" controls.
- The CSV is a **single row** for one product. For multiple products, add one row per product.
- Send it **once per calendar year** (BIS deadline: February 1 for prior calendar year).
- Keep the sent-mail receipt — Apple may ask to see proof during the App Store review.
- BIS does not reply. Filing the report is the compliance event; no approval or confirmation is needed.
### Step 2: App Store Connect — encryption questionnaire
Path: `App Store Connect → My Apps → Chanora → App Information → Encryption` (or during version submission, the same questionnaire appears).
Answer the questions as follows for Chanora:
1. "Does your app use encryption?" → **Yes**
2. "Does your app qualify for any of the exemptions provided in Category 5, Part 2 of the U.S. Export Administration Regulations?" → **No**
(We do use non-exempt crypto — see "Why Chanora is not exempt".)
3. "Does your app implement any encryption algorithms that are proprietary or not accepted as standard by international standard bodies (IEEE, IETF, ITU, etc.)?" → **No**
(ChaCha20-Poly1305 is RFC 8439; Ed25519 is RFC 8032; P-256 is FIPS 186-4. TeamSpeak's handshake uses standard primitives even though the orchestration is custom.)
4. "Does your app implement any standard encryption algorithms instead of, or in addition to, using or accessing the encryption in Apple's operating system?" → **Yes**
5. "Is your app made available on the French App Store?" → answer per your distribution plan (Yes if you want EU/FR availability).
Apple will then ask you to upload supporting documentation. Upload:
- A PDF copy of your sent email from Step 1 (showing the date sent and both recipients), with the CSV attachment shown or attached.
- A short cover sheet identifying: product name (Chanora), ECCN (`5D992.c`), authorization basis (`§740.17(b)(1)` mass-market self-classification under Note 3 to Category 5, Part 2 of the CCL), and the date the report was sent to BIS.
### Step 3: Receive the code from Apple
After review (typical turnaround: a few business days to ~2 weeks), Apple sends an email with the export compliance code. The code is a UUID, lowercase, 36 characters, e.g. `aeafaa12-a017-48c8-a29a-bdd3061f93e3` (this is Keybase's actual code, used here as a format example only).
The code is **per-app, not per-version**. Once issued it can be reused for every subsequent build until your encryption usage materially changes.
### Step 4: Revert the workaround and patch in the code
Edit `apps/chanora_flutter/ios/Runner/Info.plist`:
```xml
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>PASTE_APPLE_ISSUED_UUID_HERE</string>
```
Do the same for `apps/chanora_flutter/macos/Runner/Info.plist`.
Optionally — to avoid committing the literal UUID to git — use a build variable instead (matches the `procivis/one-wallet` pattern):
```xml
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>$(CHANORA_IOS_EXPORT_COMPLIANCE_CODE)</string>
```
Then set `CHANORA_IOS_EXPORT_COMPLIANCE_CODE` as a build setting (in `Runner.xcodeproj` user-defined settings, in a non-tracked `.xcconfig`, or via the CI/Fastlane environment).
Validate after editing:
```bash
plutil -lint apps/chanora_flutter/ios/Runner/Info.plist
plutil -lint apps/chanora_flutter/macos/Runner/Info.plist
```
### Step 5: Submit and confirm
Re-upload the build via Xcode → Organizer → Distribute App, or via Fastlane / CI. App Store Connect should no longer reject the build with the "Invalid Export Compliance Code" error.
If you use Fastlane, you can also pass `uses_non_exempt_encryption: true` to `upload_to_testflight` / `upload_to_app_store` as belt-and-suspenders; the Info.plist value is authoritative.
## Annual maintenance
- Every January, re-send the BIS self-classification CSV (Step 1) with the current year and the latest released version. Deadline: **February 1** for the prior calendar year. Keep the receipt.
- The Apple-issued code does NOT need re-issuance unless Chanora adds a fundamentally new encryption capability (e.g. end-to-end encrypted DMs would qualify; bumping an existing AEAD library version would not).
- If you add E2EE for direct messages, change the at-rest encryption algorithm, or introduce proprietary (non-standard) cryptography, the mass-market classification may no longer apply — you would need to either re-self-classify or file a CCATS (Commodity Classification) request via SNAP-R, and Apple will need updated documentation and a new code.
## References
- Apple — `ITSAppUsesNonExemptEncryption` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption>
- Apple — `ITSEncryptionExportComplianceCode` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsencryptionexportcompliancecode>
- Apple — Complying with encryption export regulations: <https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations>
- Apple — App Store Connect Help, export compliance overview: <https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance/>
- Apple — App Store Connect Help, determine and upload encryption documentation: <https://developer.apple.com/help/app-store-connect/manage-app-information/determine-and-upload-app-encryption-documentation/>
- BIS — Encryption FAQs: <https://www.bis.doc.gov/index.php/policy-guidance/encryption>
- BIS — EAR §740.17 (License Exception ENC, self-classification): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-740/section-740.17>
- BIS — Supplement No. 8 to Part 742 (self-classification report CSV format): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742>
- BIS — Note 3 to Category 5, Part 2 of the CCL (mass-market note): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-774>
- Real-world examples (closed-source iOS apps with `ITSEncryptionExportComplianceCode`):
- Keybase: <https://github.com/keybase/client/blob/master/shared/ios/Keybase/Info.plist>
- Status.im: <https://github.com/status-im/status-mobile/blob/develop/ios/StatusIm/Info.plist>
- Cryptomator: <https://github.com/cryptomator/ios/blob/develop/Cryptomator/Info.plist>
- Build-variable pattern (avoid committing UUID to git):
- Procivis One Wallet: <https://github.com/procivis/one-wallet/blob/main/ios/Wallet/Info.plist>
## Decision log
| Date | Decision | Rationale |
|---|---|---|
| 2026-06-08 | Temporarily set `ITSAppUsesNonExemptEncryption=<false/>` on both iOS and macOS Info.plist | Unblock TestFlight internal testing while BIS / Apple paperwork is being prepared. Re-do per Steps 1-5 above before any public App Store release. |
+12 -6
View File
@@ -16,7 +16,7 @@ those terms.
| License | Crate count |
|---------|-------------|
| `Apache License 2.0` | 327 |
| `Apache License 2.0` | 333 |
| `MIT License` | 74 |
| `Unicode License v3` | 19 |
| `BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License` | 14 |
@@ -40,6 +40,7 @@ those terms.
| zeroize | 1.8.2 | `Apache License 2.0` | <https://github.com/RustCrypto/utils> |
| zeroize_derive | 1.4.3 | `Apache License 2.0` | <https://github.com/RustCrypto/utils/tree/master/zeroize/derive> |
| windows-collections | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-core | 0.54.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-core | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-future | 0.3.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-implement | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
@@ -47,23 +48,28 @@ those terms.
| windows-link | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-numerics | 0.3.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-registry | 0.6.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-result | 0.1.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-result | 0.4.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-strings | 0.5.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-sys | 0.45.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-sys | 0.60.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-sys | 0.61.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-targets | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-targets | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-targets | 0.53.5 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows-threading | 0.2.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows | 0.54.0 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows | 0.62.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows_x86_64_gnu | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows_x86_64_gnu | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows_x86_64_msvc | 0.42.2 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows_x86_64_msvc | 0.52.6 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| windows_x86_64_msvc | 0.53.1 | `Apache License 2.0` | <https://github.com/microsoft/windows-rs> |
| moka | 0.12.15 | `Apache License 2.0` | <https://github.com/moka-rs/moka> |
| tinyvec_macros | 0.1.1 | `Apache License 2.0` | <https://github.com/Soveu/tinyvec_macros> |
| zerocopy | 0.8.50 | `Apache License 2.0` | <https://github.com/google/zerocopy> |
| allo-isolate | 0.1.27 | `Apache License 2.0` | <https://github.com/sunshine-protocol/allo-isolate> |
| cpal | 0.18.0 | `Apache License 2.0` | <https://github.com/RustAudio/cpal> |
| cpal | 0.17.3 | `Apache License 2.0` | <https://github.com/RustAudio/cpal> |
| rustls-platform-verifier | 0.7.0 | `Apache License 2.0` | <https://github.com/rustls/rustls-platform-verifier> |
| self_cell | 1.2.2 | `Apache License 2.0` | <https://github.com/Voultapher/self_cell> |
| tracing-android | 0.2.0 | `Apache License 2.0` | <https://github.com/Actyx/tracing-android> |
@@ -309,7 +315,7 @@ those terms.
| jni | 0.22.4 | `Apache License 2.0` | <https://github.com/jni-rs/jni-rs> |
| libc | 0.2.186 | `Apache License 2.0` | <https://github.com/rust-lang/libc> |
| linux-keyutils | 0.2.5 | `Apache License 2.0` | <https://github.com/landhb/linux-keyutils> |
| mach2 | 0.6.0 | `Apache License 2.0` | <https://github.com/JohnTitor/mach2> |
| mach2 | 0.5.0 | `Apache License 2.0` | <https://github.com/JohnTitor/mach2> |
| miniz_oxide | 0.8.9 | `Apache License 2.0` | <https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide> |
| ndk-context | 0.1.1 | `Apache License 2.0` | <https://github.com/rust-windowing/android-ndk-rs> |
| ndk-sys | 0.6.0+11769913 | `Apache License 2.0` | <https://github.com/rust-mobile/ndk> |
@@ -380,7 +386,7 @@ those terms.
| aws-lc-rs | 1.17.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
| aws-lc-sys | 0.41.0 | `ISC License` | <https://github.com/aws/aws-lc-rs> |
| uds_windows | 1.2.1 | `MIT License` | <https://github.com/haraldh/rust_uds_windows> |
| sdl2-sys | 0.38.0 | `MIT License` | <https://github.com/rust-sdl2/rust-sdl2> |
| sdl2-sys | 0.37.0 | `MIT License` | <https://github.com/rust-sdl2/rust-sdl2> |
| openssl-sys | 0.9.116 | `MIT License` | <https://github.com/rust-openssl/rust-openssl> |
| mio | 1.2.1 | `MIT License` | <https://github.com/tokio-rs/mio> |
| nom | 7.1.3 | `MIT License` | <https://github.com/Geal/nom> |
@@ -437,7 +443,7 @@ those terms.
| zmij | 1.0.21 | `MIT License` | <https://github.com/dtolnay/zmij> |
| zvariant_utils | 3.4.0 | `MIT License` | <https://github.com/z-galaxy/zbus/> |
| winnow | 1.0.3 | `MIT License` | <https://github.com/winnow-rs/winnow> |
| sdl2 | 0.38.0 | `MIT License` | <https://github.com/Rust-SDL2/rust-sdl2> |
| sdl2 | 0.37.0 | `MIT License` | <https://github.com/Rust-SDL2/rust-sdl2> |
| aho-corasick | 1.1.4 | `MIT License` | <https://github.com/BurntSushi/aho-corasick> |
| byteorder | 1.5.0 | `MIT License` | <https://github.com/BurntSushi/byteorder> |
| csv-core | 0.1.13 | `MIT License` | <https://github.com/BurntSushi/rust-csv> |
@@ -472,7 +478,7 @@ those terms.
| zerotrie | 0.2.4 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
| zerovec-derive | 0.11.3 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
| zerovec | 0.11.6 | `Unicode License v3` | <https://github.com/unicode-org/icu4x> |
| sdl2-sys | 0.38.0 | `zlib License` | <https://github.com/rust-sdl2/rust-sdl2> |
| sdl2-sys | 0.37.0 | `zlib License` | <https://github.com/rust-sdl2/rust-sdl2> |
## Full license texts
@@ -1,642 +0,0 @@
# Chat Panel Switching Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element.
**Architecture:** The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (`_inlineChatTarget` + `_chatOpen`) already supports switching — we just need UI affordances and draft persistence.
**Tech Stack:** Flutter/Dart, existing `BridgeMessageTarget` sealed class, existing `ChatDetailView` / `ChatPanel` / `SnapshotView` widgets.
**Design research basis:** Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior.
---
## Current State
| UX Element | Status |
|---|---|
| Unread indicator | Single global badge count on app bar chat button |
| Channel → chat trigger | **None.** Channel tiles only join voice |
| Client → DM trigger | Works (right-click → "Direct Message") |
| Header chat button when panel open | Idempotent — re-uses same `_inlineChatTarget` |
| Draft persistence | None — single `TextEditingController`, lost on switch |
| Scroll position memory | None — always auto-scrolls to bottom |
| Per-conversation unread | None |
## Scope
**In scope (this plan):**
- Channel → chat switching in expanded layout
- Channel right-click → "Chat" option
- Header chat button → switch to current voice channel chat when panel already open
- Per-target draft persistence (in-memory `Map`)
- Close = dismiss (remember last target and draft)
- Unread dot indicator on channels in `SnapshotView`
**Out of scope (future):**
- Scroll position memory per target
- "New messages" divider
- Per-target unread counts / badge numbers
- Notification tiering (dot/badge/mention)
- Split view (Slack power-user feature)
## Responsive Behavior
| Tier | Width | Chat Mode | Changes in this plan |
|---|---|---|---|
| **Expanded** | ≥1024dp | Inline `ChatPanel` (right column) | ✅ All changes apply here |
| **Medium** | 6001023dp | Full-screen `ChatPage` route | No changes needed (already works) |
| **Compact** | <600dp | Full-screen `ChatPage` route | No changes needed (already works) |
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `apps/chanora_flutter/lib/widgets/snapshot_view.dart` | **Modify** | Add `onOpenChannelChat` callback, channel context menu with "Chat" option, unread dot on channels |
| `apps/chanora_flutter/lib/main.dart` | **Modify** | Add `_chatDrafts` map, wire `onOpenChannelChat`, fix header chat button to switch to current channel, fix `_closeInlineChat` to preserve last target |
| `apps/chanora_flutter/lib/widgets/chat_panel.dart` | **Modify** | Accept `onSwitchTarget` callback, pass draft state through |
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | **Modify** | `ChatDetailView` accepts external draft text, exposes draft text on target change |
| `apps/chanora_flutter/lib/design/breakpoints.dart` | **No changes** | Breakpoints unchanged |
---
### Task 1: Add `onOpenChannelChat` callback to `SnapshotView`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63` (constructor params)
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284` (`_channelTile`)
- [ ] **Step 1: Add the callback field to `SnapshotView` widget**
In `snapshot_view.dart`, add a new optional callback field after `onOpenClientPoke` (around line 71):
```dart
/// Open chat for a channel.
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
```
Update the constructor to include it (around line 32, after `onOpenClientPoke`):
```dart
this.onOpenChannelChat,
```
- [ ] **Step 2: Add a right-click/long-press context menu to `_channelTile`**
Replace the `InkWell` in `_channelTile` (lines 243-284) with a context menu wrapper. The channel tile should support:
- **Tap**: join voice (existing behavior, unchanged)
- **Right-click / long-press**: show a popup menu with "Open chat" option
```dart
return InkWell(
onTap: onTap,
onLongPress: widget.onOpenChannelChat != null
? () => widget.onOpenChannelChat!(channel)
: null,
child: PopupMenuButton<String>(
position: PopupMenuPosition.under,
enabled: widget.onOpenChannelChat != null,
onSelected: (value) {
if (value == 'chat') {
widget.onOpenChannelChat?.call(channel);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'chat',
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 18),
const SizedBox(width: 12),
Text(AppLocalizations.of(context)!.chatAction),
],
),
),
],
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
),
);
```
Note: The `PopupMenuButton` wraps the existing content as its `child`, so the tile looks identical until right-clicked. The `onTap` on `InkWell` continues to handle voice join.
- [ ] **Step 3: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors (the callback is optional, so existing call sites compile without changes)
- [ ] **Step 4: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart
git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles"
```
---
### Task 2: Wire `onOpenChannelChat` in `main.dart` and add per-target draft storage
**Files:**
- Modify: `apps/chanora_flutter/lib/main.dart:370-374` (state fields)
- Modify: `apps/chanora_flutter/lib/main.dart:2521-2540` (SnapshotView constructor)
- [ ] **Step 1: Add draft storage map**
Add a new state field near line 374 (after `_inlineChatCollapseNoticeShown`):
```dart
/// Per-target draft text. Populated when switching away from a conversation
/// so the user's unfinished message is preserved.
final Map<String, String> _chatDrafts = {};
```
- [ ] **Step 2: Add `_lastDismissedTarget` field**
Add a new state field to remember the last dismissed target so reopening returns to it:
```dart
/// The last chat target before the panel was closed. Used to restore the
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
```
- [ ] **Step 3: Wire `onOpenChannelChat` in `SnapshotView` constructor**
In the `SnapshotView(...)` constructor around line 2512, add the new callback:
```dart
onOpenChannelChat: (channel) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.channel(channel.id),
),
),
```
- [ ] **Step 4: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 5: Commit**
```bash
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat"
```
---
### Task 3: Fix `_onOpenChat` to support switching and draft save/restore
**Files:**
- Modify: `apps/chanora_flutter/lib/main.dart:1628-1685` (`_onOpenChat` and `_closeInlineChat`)
- [ ] **Step 1: Update `_onOpenChat` to save current draft and restore new target's draft**
Replace the `_onOpenChat` method (lines 1628-1676) with logic that:
1. Saves the current `_inlineChatTarget` draft before switching
2. Restores the new target's draft (if any)
3. When called with no explicit target and panel is already open, switches to current voice channel's chat
```dart
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
// Resolve the new target.
// If no target passed and panel is already open, switch to current voice channel.
// If no target passed and panel is closed, resolve from history or default.
rust.BridgeMessageTarget newTarget;
if (target != null) {
newTarget = target;
} else if (_chatOpen && _currentVoiceChannelId != null) {
newTarget = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!);
} else if (_inlineChatTarget != null) {
newTarget = _inlineChatTarget!;
} else {
newTarget = resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
) ?? const rust.BridgeMessageTarget.server();
}
final newClientName = clientName.isNotEmpty
? clientName
: (newTarget == _inlineChatTarget) ? _inlineChatClientName : '';
final isExpanded =
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
LayoutClass.expanded;
if (isExpanded) {
setState(() {
// Save draft for the current target before switching.
_saveCurrentDraft();
_chatUnread = 0;
_chatOpen = true;
_inlineChatTarget = newTarget;
_inlineChatClientName = newClientName;
_inlineChatCollapseNoticeShown = false;
});
return;
}
setState(() {
_chatUnread = 0;
_chatOpen = true;
});
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatPage(
messages: _chatMessages,
snapshot: initialSnapshot,
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget: newTarget,
initialClientName: newClientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
);
if (mounted) setState(() => _chatOpen = false);
}
```
- [ ] **Step 2: Add `_saveCurrentDraft` and `_draftKeyForTarget` helper methods**
Add these near `_onOpenChat`:
```dart
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Server() => 'server',
rust.BridgeMessageTarget_Channel(:final id) => 'channel:$id',
rust.BridgeMessageTarget_Client(:final id) => 'client:$id',
rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id',
};
}
/// Saves the current draft text (if any) for the current inline chat target.
/// Called before switching targets or closing the panel.
void _saveCurrentDraft() {
// Note: The actual draft text is read from ChatDetailView's
// TextEditingController via a callback. This is wired in Task 4.
}
```
Note: `_saveCurrentDraft` will be completed in Task 4 when we wire the draft callback from `ChatDetailView`.
- [ ] **Step 3: Update `_closeInlineChat` to preserve last target instead of nulling it**
Replace `_closeInlineChat` (lines 1678-1685):
```dart
void _closeInlineChat() {
setState(() {
// Save draft before closing.
_saveCurrentDraft();
// Remember the last target so reopening returns to it.
_lastDismissedTarget = _inlineChatTarget;
_lastDismissedClientName = _inlineChatClientName;
_chatOpen = false;
// Do NOT null _inlineChatTarget — we want to remember it for reopen.
});
}
```
- [ ] **Step 4: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 5: Commit**
```bash
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close"
```
---
### Task 4: Add draft save/restore callback to `ChatDetailView` and `ChatPanel`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098` (`ChatDetailView` constructor + state)
- Modify: `apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75` (`ChatPanel` constructor + build)
- [ ] **Step 1: Add draft callbacks to `ChatDetailView`**
Add two new optional callbacks to `ChatDetailView` (after `messageMaxWidth` around line 1066):
```dart
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is disposed.
final ValueChanged<String>? onDraftChanged;
```
- [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`**
In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts:
```dart
@override
void initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
// Save draft for old target before switching.
if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
oldWidget.onDraftChanged!(_textCtl.text);
}
// Restore draft for new target.
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override
void dispose() {
// Emit the current draft so the parent can save it.
if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
widget.onDraftChanged!(_textCtl.text);
}
_textCtl.dispose();
_scrollCtl.dispose();
super.dispose();
}
```
Remove the existing `dispose` method (lines 1127-1132) — it's replaced by the new one above.
- [ ] **Step 3: Thread draft callbacks through `ChatPanel`**
Update `ChatPanel` to accept and pass through the new callbacks. Add fields:
```dart
/// External draft text to restore in the chat detail view.
final String? restoredDraft;
/// Called when the draft text changes.
final ValueChanged<String>? onDraftChanged;
```
Pass them through in `build()` where `ChatDetailView` is constructed (line 58):
```dart
child: ChatDetailView(
messages: messages,
snapshot: snapshot,
target: target,
clientName: clientName,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: onTs3ServerLink,
restoredDraft: restoredDraft,
onDraftChanged: onDraftChanged,
messageMaxWidth: 500,
headerTrailing: IconButton(
tooltip: 'Close chat',
icon: const Icon(Icons.close),
onPressed: onClose,
),
),
```
- [ ] **Step 4: Wire draft callbacks in `main.dart`**
In the `ChatPanel(...)` constructor around line 2567, add the draft callbacks:
```dart
ChatPanel(
messages: _chatMessages,
snapshot: _snapshot!,
target: inlineChatTarget,
clientName: _inlineChatClientName,
onTs3ServerLink: _onTs3ServerLink,
restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)],
onDraftChanged: (text) {
_chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text;
},
onClose: _closeInlineChat,
),
```
- [ ] **Step 5: Complete `_saveCurrentDraft` in `main.dart`**
The `_saveCurrentDraft` method is called from `_onOpenChat` (before switching) and `_closeInlineChat`. Since `ChatDetailView` emits drafts via `onDraftChanged` and `dispose`, the parent always has the latest draft in `_chatDrafts`. The method body stays as a no-op safety net:
```dart
void _saveCurrentDraft() {
// Drafts are continuously saved via onDraftChanged callback.
// This method exists as an explicit save point for any future
// snapshot-based draft capture.
}
```
- [ ] **Step 6: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 7: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): per-target draft persistence with save/restore on switch"
```
---
### Task 5: Add unread dot indicator to channel tiles in `SnapshotView`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart` (add unread indicator)
- Modify: `apps/chanora_flutter/lib/main.dart` (pass unread channel set)
- [ ] **Step 1: Add unread channel IDs parameter to `SnapshotView`**
Add a new required field to `SnapshotView` (after `canJoinVoiceChannel` around line 57):
```dart
/// Set of channel IDs that have unread chat messages.
final Set<BigInt> unreadChannelIds;
```
- [ ] **Step 2: Add unread dot to `_channelTile`**
In `_channelTile`, inside the `Row` children (after the channel name `Expanded` widget, around line 273), add an unread dot:
```dart
// Unread indicator.
if (widget.unreadChannelIds.contains(channel.id)) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
```
This must come before the password lock icon check (line 274).
- [ ] **Step 3: Compute unread channel set in `main.dart`**
Add a getter in `_BetaHomeState` that computes which channels have unread messages:
```dart
/// Channel IDs that have unread chat messages (used for dot indicators).
Set<BigInt> get _unreadChannelIds {
if (_chatOpen) return const {};
final ids = <BigInt>{};
for (final entry in _chatMessages) {
if (!entry.countsTowardUnread || entry.isSelf) continue;
if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) {
ids.add(id);
}
}
return ids;
}
```
- [ ] **Step 4: Pass unread channel set to `SnapshotView`**
In the `SnapshotView(...)` constructor around line 2512, add:
```dart
unreadChannelIds: _unreadChannelIds,
```
- [ ] **Step 5: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 6: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages"
```
---
### Task 6: End-to-end verification
**Files:** All modified files.
- [ ] **Step 1: Run `flutter analyze` on the full project**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: Zero issues
- [ ] **Step 2: Run `flutter test`**
Run: `cd apps/chanora_flutter && flutter test`
Expected: All tests pass (same baseline as before — 180 passed, 2 skipped)
- [ ] **Step 3: Build macOS release**
Run: `bash tools/build-macos.sh`
Expected: Successful build producing `chanora-v0.2.0-beta.1-macos-aarch64.zip`
- [ ] **Step 4: Manual QA checklist**
Launch the app and verify:
1. **Channel → chat switching**: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen.
2. **Channel right-click → Chat**: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.
3. **Header chat button toggle**: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat.
4. **Draft persistence**: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello".
5. **Close and reopen**: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.
6. **Unread dots**: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot.
7. **Medium/compact unchanged**: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.
8. **DM switching still works**: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again.
---
## Self-Review
### Spec coverage
| Requirement | Task |
|---|---|
| Channel → chat switching (tap) | Task 2 (wiring) + Task 3 (target resolution) |
| Channel → chat (context menu) | Task 1 |
| Header button switches when open | Task 3 |
| Per-target draft persistence | Task 4 |
| Close = dismiss (remember state) | Task 3 |
| Unread dot on channels | Task 5 |
| Medium/compact unchanged | No changes to those paths |
### Placeholder scan
No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations.
### Type consistency
- `BridgeMessageTarget.channel(id)` uses `BigInt` — matches `channel.id` type
- `_chatDrafts` uses `String` keys from `_draftKeyForTarget` — consistent
- `onOpenChannelChat` callback type `ValueChanged<rust.BridgeChannel>?` — matches widget pattern
- `unreadChannelIds` uses `Set<BigInt>` — matches channel ID type
@@ -1,266 +0,0 @@
# Chanora Adaptive 3-Panel Layout Design
**Date:** 2026-06-05
**Status:** Draft
**Scope:** Desktop adaptive layout for ≥1024dp three-panel mode, centralized breakpoint system, and chat panel integration.
---
## 1. Problem Statement
Chanora's current responsive layout uses a single breakpoint (`_wideBreakpoint = 600dp`) scattered across 9 files in 8 duplicatable clusters (5 `LayoutBuilder` sites, 7 `MediaQuery.sizeOf` sites). The desktop layout is a 2-panel split (VoiceBar 320px + SnapshotView flex) with no persistent chat surface.
Research across Discord, Mattermost, Rocket.Chat, Element, and hardware resolution data shows:
- **1024dp** is the industry-standard threshold where a third panel becomes viable (Discord member list, Mattermost RHS, Rocket.Chat contextual bar all use this value).
- At 1024dp, Chanora's math works: `320 + 12 + 300 + 12 + 380 = 1024` — minimum viable for VoicePanel + ChannelTree + ChatPanel.
- Production apps use **push/replace navigation** for chat on constrained widths, reserving persistent panels for ≥1024dp.
- Centralized breakpoint logic is standard practice (Rocket.Chat `LayoutProvider`, Mattermost `WindowSizes`).
## 2. Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| 3-panel activation threshold | **1024dp** | Industry consensus (Discord, Mattermost, Rocket.Chat). Chanora math: center pane = 300dp minimum. |
| Chat behavior at 6001023dp | **Push route (unchanged)** | Research validates current pattern. Overlays are for contextual info, not primary conversation. |
| Chat behavior at ≥1024dp | **Inline panel** | Chat renders in a 380dp right panel alongside the channel tree. No route push. |
| Centralized breakpoints | **New `ChanoraBreakpoints` + `ViewportInfo`** | Replaces 8 duplicated responsive clusters with single source of truth. |
| Architecture approach | **Adaptive Scaffold Shell** | Extends existing widget tree with centralized layout logic. Not a full rewrite. |
| AdaptiveScaffold package | **Not used** | Package discontinued (flutter/flutter#162965). Manual layout gives better control for voice-first UX. |
## 3. Breakpoint System
### 3.1 Layout Classes
Three tiers, aligned with Material 3 adaptive guidance:
| Class | Width Range | Primary Behavior |
|---|---|---|
| `compact` | < 600dp | Single column. VoiceStatusChip at bottom. Chat as pushed route. |
| `medium` | 6001023dp | 2-panel row (VoicePanel 320px + SnapshotView flex). Chat as pushed route. |
| `expanded` | ≥ 1024dp | 3-panel row (VoicePanel 320px + SnapshotView flex + ChatPanel 380px). Chat inline. |
### 3.2 New Files
**`lib/design/breakpoints.dart`** — canonical breakpoint tokens:
```dart
class ChanoraBreakpoints {
static const double compact = 0;
static const double medium = 600;
static const double expanded = 1024;
static const double voicePanelWidth = 320;
static const double chatPanelWidth = 380;
static const double panelGap = 12;
}
enum LayoutClass { compact, medium, expanded }
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
```
**`lib/design/viewport_info.dart`** — inherited widget that computes layout class once per frame:
```dart
class ViewportInfo extends InheritedWidget {
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
final LayoutClass layoutClass;
final double width;
final double height;
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
bool get isCompact => layoutClass == LayoutClass.compact;
bool get isMedium => layoutClass == LayoutClass.medium;
bool get isExpanded => layoutClass == LayoutClass.expanded;
@override
bool updateShouldNotify(ViewportInfo old) =>
layoutClass != old.layoutClass ||
width != old.width ||
height != old.height;
}
```
### 3.3 What This Replaces
The audit identified these duplicatable clusters that get consolidated:
| Cluster | Current Locations | Replacement |
|---|---|---|
| 600dp breakpoint (×3) | `main.dart:304,2280,2468` | `ChanoraBreakpoints.medium` |
| 400px cap (×2) | `connect_widgets.dart`, `voice_settings.dart` | Named token in `ChanoraBreakpoints` |
| 72% modal height (×2) | `audio_output_tile.dart`, `ptt_capability_badge.dart` | Named token |
| 320px voice bar width | `main.dart:2467` | `ChanoraBreakpoints.voicePanelWidth` |
| Platform capability branching | `voice_settings.dart`, `voice_compact.dart`, `audio_processing_config_state.dart` | Centralized capability helper |
## 4. Adaptive Shell
### 4.1 Widget Tree
The existing `_BetaHome` widget tree is restructured to use `ViewportInfo`:
```
_BetaHome
├─ macOS: Scaffold with traffic-light padding (unchanged)
├─ Mobile: ChanoraMobileScaffold (unchanged)
└─ bodyContent:
└─ LayoutBuilder
└─ ViewportInfo (computes layoutClass from constraints)
├─ compact: Column [SnapshotView, VoiceStatusChip, PTT]
├─ medium: Row [VoicePanel, SnapshotView]
└─ expanded: Row [VoicePanel, SnapshotView, ChatPanel]
```
`AdaptiveShell` is a pure layout widget — it reads `ViewportInfo` and composes the appropriate children. All state remains in `_BetaHome`.
### 4.2 Platform Handling
Platform-specific scaffolding stays at the top level, unchanged:
- **macOS**: `Scaffold` with `_macOSTrafficLightPad` top padding (28dp)
- **Mobile**: `ChanoraMobileScaffold` with compact idle chrome
- **Windows/Linux**: Default `Scaffold`
The `ViewportInfo` + layout switch only affects the body content inside the scaffold.
## 5. Chat Panel Behavior
### 5.1 Compact (< 600dp)
No change. Chat opens as a pushed `MaterialPageRoute`:
```
main.dart:_onOpenChat → Navigator.push(ChatPage)
```
Channel tree is fully replaced. Back button returns to main view.
### 5.2 Medium (6001023dp)
Same as compact. Chat is a pushed route. The 2-panel layout (VoicePanel + SnapshotView) stays as the home screen.
### 5.3 Expanded (≥ 1024dp)
Chat renders inline in a 380dp right panel. The flow:
1. User taps "Open Text Chat" on a client, or taps the chat badge
2. `_onOpenChat` reads `ViewportInfo.of(context).isExpanded`
3. If expanded: sets `_inlineChatTarget` state → `ChatPanel` renders in the third column
4. If not expanded: pushes `ChatPage` route (unchanged behavior)
### 5.4 ChatPanel Widget
New widget for ≥1024dp only:
```
ChatPanel (380dp fixed width)
├─ Header: target name + close button
├─ Message list (scrollable, max-width ~500dp for readability)
└─ Input field
```
**State sharing:** The `_chatMessages` list and `_chatFeedRevision` listenable in `_BetaHome` already track all messages. `ChatPanel` reads from the same source — no duplication.
**Close behavior:** User taps close button → `_inlineChatTarget` set to null → `ChatPanel` removed from tree.
### 5.5 Width Transition
When the user resizes from ≥1024dp to <1024dp while chat is open inline:
1. `ChatPanel` disappears (it's only in the expanded layout branch)
2. A brief snackbar appears: "Tap the chat button to continue your conversation"
3. The `_inlineChatTarget` state is preserved — tapping the chat button reopens the pushed `ChatPage` route with the same target
This matches Discord's behavior when the member list collapses on resize.
## 6. Panel Sizing
| Element | Width | Behavior |
|---|---|---|
| VoicePanel (left) | 320dp fixed | VoiceBar, connection status, PTT controls. Unchanged. |
| Panel gaps | 12dp | Between each panel. Unchanged. |
| SnapshotView (center) | flex (1fr) | Grows to fill remaining space. |
| ChatPanel (right) | 380dp fixed | Only rendered at ≥1024dp. |
| Chat messages | max-width ~500dp | Centered within ChatPanel for readability. |
| macOS traffic light pad | 28dp top | Unchanged. Only affects height. |
**Center pane widths at common viewports:**
| Viewport | Center Width | Feel |
|---|---|---|
| 1024dp | 300dp | Minimum viable (matches Discord at same width) |
| 1200dp | 476dp | Comfortable |
| 1280dp | 556dp | Spacious (Chanora's default window size) |
| 1440dp | 716dp | Very spacious |
| 1920dp | 1184dp | Ultra-wide — consider capping center max-width post-MVP |
## 7. Migration Map
| File | Change | Scope |
|---|---|---|
| `lib/design/breakpoints.dart` | **New** — breakpoint tokens + `LayoutClass` enum | New file |
| `lib/design/viewport_info.dart` | **New**`ViewportInfo` inherited widget | New file |
| `lib/main.dart` | Replace `_wideBreakpoint = 600.0` with `ChanoraBreakpoints.medium`. Wrap body in `ViewportInfo`. Add `_inlineChatTarget` state. Branch `_onOpenChat` for expanded vs compact/medium. Add `ChatPanel` to expanded Row. | Significant |
| `lib/widgets/chat_views.dart` | Replace `_chatMobileBreakpoint` with `ChanoraBreakpoints.medium`. No structural changes. | Token swap |
| `lib/widgets/connect_widgets.dart` | Replace hardcoded 400px with token. | Token swap |
| `lib/widgets/app_snack_bar.dart` | Replace hardcoded 600/560px with tokens. | Token swap |
| `lib/widgets/snapshot_view.dart` | No changes. Local spacer math stays local. | None |
| `lib/widgets/voice_compact.dart` | Replace platform branching with centralized helper (optional, post-MVP). | Optional |
**Unchanged:** macOS scaffold, ChanoraMobileScaffold, all voice controls, channel tree, chat route for compact/medium, all Rust bridge code.
## 8. Hardware Coverage
The 1024dp threshold coverage based on 2026 resolution data:
| Setup | Logical Width | Sees 3-Panel? |
|---|---|---|
| 1920×1080 @100% fullscreen | 1920dp | Yes |
| 1920×1080 @125% fullscreen | 1536dp | Yes |
| 1920×1080 @150% fullscreen | 1280dp | Yes |
| 1366×768 @100% fullscreen | 1366dp | Yes |
| 1366×768 @125% fullscreen | 1093dp | Yes |
| 1366×768 @125% windowed (~85%) | ~930dp | No (2-panel) |
| 2560×1440 @100% half-screen | ~1280dp | Yes |
| 2560×1440 @125% half-screen | ~1024dp | Yes (edge) |
| MacBook 13" Split View | ~708dp | No (2-panel) |
| MacBook 14" Split View | ~744dp | No (2-panel) |
| MacBook 16" Split View | ~852dp | No (2-panel) |
Chanora's default window (1280×720 on Windows/Linux) starts in 3-panel mode immediately.
## 9. Out of Scope (Post-MVP)
- Resizable panels (drag-to-resize VoicePanel/ChatPanel width)
- NavigationRail for ultra-wide monitors
- ChatPanel showing user profile or channel info
- Center pane max-width cap for ultra-wide monitors
- Centralized platform capability helper (consolidating voice_settings/voice_compact/audio_processing branching)
- Animated transitions between layout classes
- ChatPanel as a sheet/drawer on medium widths
## 10. References
- Discord member list collapse at 1024px: [compact-discord](https://github.com/asportnoy/compact-discord)
- Mattermost RHS persistent at ≥1024px: [structure.scss](https://github.com/mattermost/mattermost/blob/3440453d82613b1d8d67c93011c11d56a1380869/webapp/channels/src/sass/base/_structure.scss)
- Rocket.Chat contextual bar persistent at ≥1024px (lg breakpoint): [fuselage-tokens](https://github.com/RocketChat/fuselage/blob/ed91cb04db9fd6c35b43390190cbf7327c3eab9e/packages/fuselage-tokens/src/breakpoints.jsonc)
- Flutter AdaptiveScaffold discontinued: [flutter/flutter#162965](https://github.com/flutter/flutter/issues/162965)
- Material 3 canonical breakpoints: [m3.material.io/foundations/layout](https://m3.material.io/foundations/layout/breakpoints/overview)
- Chanora adaptive layout policy: [docs/ui-ux/adaptive-layout-platform-guide.md](../ui-ux/adaptive-layout-platform-guide.md)
+3 -10
View File
@@ -500,7 +500,7 @@ External compatible server
- Priority: P0
- Verification: Test
**SysRS-051**: The macOS runtime environment shall provide microphone permission prompts, microphone capture, speaker/headphone playback, audio device selection, and Keychain access to the Chanora application. Note: macOS has no AVAudioSession-equivalent surface; the macOS audio lifecycle in the current baseline is limited to launch-time microphone permission (`AVCaptureDevice.requestAccess`), the VPIO engine restart on Core Audio HAL default-device change (`kAudioHardwarePropertyDefaultInputDevice` / `DefaultOutputDevice` listeners), and the VPIO startup readback at engine start. Full audio route change + interruption handling — as available on iOS via AVAudioSession observers in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` — is not present in the current macOS baseline; closing the gap is DEC-level scope, not a P0-MVP item.
**SysRS-051**: The macOS runtime environment shall provide microphone permission prompts, microphone capture, speaker/headphone playback, audio device selection, and Keychain access to the Chanora application.
- Priority: P0
- Verification: Test
@@ -1959,10 +1959,10 @@ This section converts the baseline product decisions into auditable system-level
- Priority: P0
- Verification: Review (SysDes/SAD declaration of tolerance value is present and is referenced by the CI workflow definition)
**SysRS-310**: The Chanora application system shall support macOS runtime deployment on macOS 13.0 (Ventura) or later as the minimum supported runtime, unless Flutter, plugin, audio, or platform constraints require raising the minimum version. This clause ratifies at the SysRS layer the existing macOS baseline already encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 13.0`) and provides explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Rationale: macOS 13.0 is the floor that supports the modern CoreAudio / VoiceProcessingIO surfaces relied on by the realtime audio path, native arm64 Apple Silicon builds (no Rosetta dependence), and the SDK version used by the existing podspec lipo step that produces the universal binary; a lower minimum would require backporting audio code paths or shipping a non-universal build, neither of which is in P0-MVP scope. The minimum may be raised further (for example, to macOS 14 / Sonoma to gain the `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` AudioUnit property) only via a superseding accepted decision recorded as a DEC entry; it shall not be lowered without a superseding accepted decision. Cross-references: SAD-087 (architectural macOS runtime allocation), SDD-119 (macOS bridge build pipeline consuming this baseline), SysRS-286 (iOS minimum runtime parallel), SysRS-288 (Android minimum runtime parallel). ID-allocation note: this clause uses SysRS-310 rather than the structurally parallel SysRS-290 because SysRS-290 is already allocated (MVP single-active-server-connection scope); monotonic numbering convention is preserved.
**SysRS-310**: The Chanora application system shall support macOS runtime deployment on macOS 10.15 (Catalina) or later as the minimum supported runtime, unless Flutter, plugin, audio, or platform constraints require raising the minimum version. This clause ratifies at the SysRS layer the existing macOS baseline already encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 10.15`) and provides explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Rationale: macOS 10.15 is the floor that supports the modern CoreAudio / AVAudioEngine voice-processing-IO surfaces relied on by the realtime audio path, native arm64 Apple Silicon builds (no Rosetta dependence), and the SDK version used by the existing podspec lipo step that produces the universal binary; a lower minimum would require backporting audio code paths or shipping a non-universal build, neither of which is in P0-MVP scope. The minimum may be raised further (for example, to macOS 11.0 / Big Sur for native Apple Silicon performance gains) only via a superseding accepted decision recorded as a DEC entry; it shall not be lowered without a superseding accepted decision. Cross-references: SAD-087 (architectural macOS runtime allocation), SDD-119 (macOS bridge build pipeline consuming this baseline), SysRS-286 (iOS minimum runtime parallel), SysRS-288 (Android minimum runtime parallel). ID-allocation note: this clause uses SysRS-310 rather than the structurally parallel SysRS-290 because SysRS-290 is already allocated (MVP single-active-server-connection scope); monotonic numbering convention is preserved.
- Priority: P0
- Verification: Review (the SysRS baseline matches the podspec `MACOSX_DEPLOYMENT_TARGET` setting), Platform Test (the produced macOS binary runs on a macOS 13.0 system)
- Verification: Review (the SysRS baseline matches the podspec `MACOSX_DEPLOYMENT_TARGET` setting), Platform Test (the produced macOS binary runs on a macOS 10.15 system)
## 32. Change History Addendum
@@ -2005,10 +2005,3 @@ This section converts the baseline product decisions into auditable system-level
|---|---|---|
| 0.9.11 | 2026-05-18 | Closed the Wave 1.5 traceability-audit deferred-but-optional follow-up by adding SysRS-310 (macOS minimum runtime baseline at macOS 10.15 / Catalina), ratifying at SysRS layer the existing `apps/chanora_flutter/macos/chanora_bridge.podspec` `MACOSX_DEPLOYMENT_TARGET = 10.15` setting and providing explicit cross-platform-baseline coverage parallel to SysRS-286 (iOS) and SysRS-288 (Android). Allocated SysRS-310 rather than SysRS-290 because SysRS-290 is already taken (MVP single-active-server-connection scope); monotonic numbering convention preserved. No change to the numeric baseline value; any future raise (e.g., to macOS 11.0 / Big Sur for native Apple Silicon performance gains) is flagged as a DEC-level change, not authored here. |
| 0.9.10 | 2026-05-18 | Authorized Option B of the benchmark-infrastructure decision for the realtime audio path. Added SysRS-307 (maintained numeric performance baselines for the realtime audio capture and playback paths — heap allocation count per callback after warmup, per-callback wall-clock budget relative to the audio frame period, Opus encode/decode latency, and resampler throughput at common rate-pair conversions; extends SysRS-180/SysRS-181 from prescriptive intent into a measurable contract; consistent with SysRS-186 and SysRS-236). Added SysRS-308 (advisory CI regression reporting executing the benchmark suite on every PR and every merge to the default branch on at least one host architecture, surfacing results in the PR status-check view; explicitly **advisory only** — does not fail the build, does not block merge; escalation to a build-failing hard gate is out of scope and deferred to a future SysRS clause after baseline maturity; consistent with the SysRS-234..239 verification-family pattern). Added SysRS-309 (explicitly declared numeric tolerance window for the advisory comparison; numeric value(s) and comparison methodology delegated to SysDes/SAD; suggested starting value +20% recorded as non-binding downstream guidance). All three new clauses are consistent with SysRS-295 (no automatic telemetry / diagnostic upload in MVP) — they neither authorize nor require off-device transmission of measurement data beyond the existing CI provider surface visible to repository collaborators. Explicitly **NOT** authored in this update: (a) Dimension 3 production telemetry export of timing histograms (deferred to P1; any future opt-in performance-evidence export through the user-initiated diagnostic-export path requires a separate P1 SysRS clause and must be reconciled with SysRS-295 at that time); (b) build-failing hard CI gate (deferred to a future SysRS clause). |
## Baseline Candidate 0.9.12 Update
| Version | Date | Description |
|---|---|---|
| 0.9.12 | 2026-06-07 | Raised the macOS minimum runtime baseline in `SysRS-310` from macOS 10.15 (Catalina) to macOS 13.0 (Ventura) to match the actual floor encoded in `apps/chanora_flutter/macos/chanora_bridge.podspec` (`MACOSX_DEPLOYMENT_TARGET = 13.0`) and `apps/chanora_flutter/macos/macos_deployment_target.rb`. The previous 10.15 text was a documentation lag; no behavioural change, no DEC-level raise, and the podspec + Xcode project are unchanged. macOS 13.0 is the floor that supports native arm64 Apple Silicon without Rosetta, modern CoreAudio / VoiceProcessingIO surfaces, and the podspec lipo step that produces the universal binary. Strict layered sourcing preserved (`SysDes -> SysRS` only). |
-206
View File
@@ -1,206 +0,0 @@
#!/usr/bin/env bash
# Chanora macOS build helper.
#
# Run this on a macOS host that has already completed §4 of
# docs/release/macos-build.md:
# - Xcode 26+ with Command Line Tools
# - Rust stable with aarch64-apple-darwin and/or x86_64-apple-darwin targets
# - Flutter 3.41.9+ stable on PATH
# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings)
#
# Usage (from the repo root ~/chanora):
# ./tools/build-macos.sh
# ./tools/build-macos.sh --no-rust # skip Rust rebuild (cached)
# ./tools/build-macos.sh --regenerate-bindings # rerun FRB codegen first
# ./tools/build-macos.sh --version v0.2.0-beta.1
# ./tools/build-macos.sh --target aarch64-apple-darwin
#
# Produces:
# target/<triple>/release/libchanora_bridge.dylib
# apps/chanora_flutter/build/macos/Build/Products/Release/chanora_flutter.app
# chanora-<version>-macos-<arch>.zip (release bundle)
set -euo pipefail
VERSION="v0.2.0-beta.1"
SKIP_RUST=0
REGEN=0
TARGET="$(rustc -vV | grep '^host:' | cut -d' ' -f2)"
while [[ $# -gt 0 ]]; do
case "$1" in
--version) VERSION="$2"; shift 2;;
--no-rust) SKIP_RUST=1; shift;;
--regenerate-bindings) REGEN=1; shift;;
--target) TARGET="$2"; shift 2;;
*) echo "unknown arg: $1" >&2; exit 2;;
esac
done
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
bar() { printf '%s\n' '========================================================================'; }
ARCH="${TARGET%%-*}"
bar
echo "Chanora macOS build"
echo "Repo root : $REPO_ROOT"
echo "Version : $VERSION"
echo "Target : $TARGET"
echo "Arch : $ARCH"
echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
bar
# ---------- 1. Verify toolchain ----------
echo "[1/7] Verify toolchain"
for cmd in cargo rustc flutter xcodebuild xcrun codesign install_name_tool; do
if ! command -v "$cmd" >/dev/null; then
echo " ERROR: '$cmd' not on PATH. See docs/release/macos-build.md." >&2
exit 1
fi
printf " %-20s : %s\n" "$cmd" "$($cmd --version 2>&1 | head -1)"
done
# ---------- 2. Verify Rust target ----------
if [[ "$TARGET" != "$(rustc -vV | grep '^host:' | cut -d' ' -f2)" ]]; then
if ! rustup target list --installed | grep -q "^$TARGET$"; then
echo " Adding rustup target $TARGET..."
rustup target add "$TARGET"
fi
else
echo "[2/7] Rust target $TARGET is the host triple (already available)"
fi
# ---------- 3. Add macOS platform if missing ----------
FLUTTER_APP="$REPO_ROOT/apps/chanora_flutter"
if [[ ! -d "$FLUTTER_APP/macos" ]]; then
echo "[3/7] Add macOS platform to apps/chanora_flutter"
(cd "$FLUTTER_APP" && flutter create --platforms=macos .)
else
echo "[3/7] macOS platform already present in apps/chanora_flutter"
fi
# ---------- 4. (Optional) regenerate FRB bindings ----------
if [[ $REGEN -eq 1 ]]; then
echo "[4/7] Regenerate flutter_rust_bridge bindings"
if ! command -v flutter_rust_bridge_codegen >/dev/null; then
echo " flutter_rust_bridge_codegen not on PATH; install it first." >&2
exit 1
fi
flutter_rust_bridge_codegen generate
else
echo "[4/7] Skipping FRB codegen (use --regenerate-bindings if api.rs changed)"
fi
# ---------- 5. Build the Rust cdylib ----------
DYLIB="$REPO_ROOT/target/$TARGET/release/libchanora_bridge.dylib"
if [[ $SKIP_RUST -eq 1 ]]; then
echo "[5/7] Skipping Rust build (cached)"
if [[ ! -f "$DYLIB" ]]; then
echo " ERROR: --no-rust but $DYLIB not found. Run without --no-rust first." >&2
exit 1
fi
else
echo "[5/7] cargo build --release -p chanora_bridge --target $TARGET"
export CMAKE_POLICY_VERSION_MINIMUM=3.5
cargo build --release --target "$TARGET" -p chanora_bridge
fi
echo " Bridge dylib: $DYLIB"
if [[ ! -f "$DYLIB" ]]; then
echo " ERROR: $DYLIB not found after build." >&2
exit 1
fi
# ---------- 6. flutter build macos --release ----------
echo "[6/7] flutter build macos --release"
cd "$FLUTTER_APP"
flutter pub get
flutter build macos --release
APP="$FLUTTER_APP/build/macos/Build/Products/Release/chanora_flutter.app"
if [[ ! -d "$APP" ]]; then
echo " ERROR: $APP not found after flutter build." >&2
exit 1
fi
# Wrap the bridge dylib into a proper .framework bundle, rewrite its
# LC_ID_DYLIB, and ad-hoc codesign — same steps as macos-postbuild.sh.
FW="$APP/Contents/Frameworks/chanora_bridge.framework"
echo " Wrapping bridge into chanora_bridge.framework"
rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources"
cp "$DYLIB" "$FW/Versions/A/chanora_bridge"
cat > "$FW/Versions/A/Resources/Info.plist" << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>chanora_bridge</string>
<key>CFBundleIdentifier</key>
<string>app.chanora.bridge</string>
<key>CFBundleName</key>
<string>chanora_bridge</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
PLIST
ln -sf A "$FW/Versions/Current"
ln -sf Versions/Current/chanora_bridge "$FW/chanora_bridge"
ln -sf Versions/Current/Resources "$FW/Resources"
echo " install_name_tool -id @rpath/chanora_bridge.framework/chanora_bridge"
install_name_tool \
-id "@rpath/chanora_bridge.framework/chanora_bridge" \
"$FW/Versions/A/chanora_bridge"
# Drop a stale plain dylib if a previous build left one alongside the framework.
rm -f "$APP/Contents/Frameworks/libchanora_bridge.dylib"
echo " codesigning framework + app (ad-hoc)"
codesign --force -s - --timestamp=none "$FW/Versions/A/chanora_bridge"
codesign --force -s - --timestamp=none "$FW"
codesign --force --deep -s - --timestamp=none "$APP"
codesign --verify --deep --strict "$APP"
echo " App bundle : $APP"
# ---------- 7. Package the bundle ----------
echo "[7/7] Package release zip"
cd "$REPO_ROOT"
ZIP_NAME="chanora-$VERSION-macos-$ARCH.zip"
ZIP_PATH="$REPO_ROOT/$ZIP_NAME"
rm -f "$ZIP_PATH"
# zip the .app bundle from inside the Products directory so the archive
# contains chanora_flutter.app/ at the top level.
(cd "$FLUTTER_APP/build/macos/Build/Products/Release" && \
zip -r -q "$ZIP_PATH" chanora_flutter.app)
echo " Zip : $ZIP_PATH"
echo " Size : $(du -h "$ZIP_PATH" | cut -f1)"
shasum -a 256 "$ZIP_PATH"
bar
echo "macOS build complete."
bar
echo "Artefacts:"
echo " Rust lib:"
echo " target/$TARGET/release/libchanora_bridge.dylib"
echo " App bundle:"
echo " $APP"
echo " Zip:"
echo " $ZIP_PATH"