Commit Graph
14 Commits
Author SHA1 Message Date
EdisonJwa c89acacc74 fix(ios,audio): pair VPIO with AVAudioSession mode .voiceChat (rc.8+67)
User report at +66: capture (mic -> remote) is clean, but local
playback (remote -> speaker) is 'broken and poor', particularly
for human voice. Musicbot audio (loud, near-continuous) plays
correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic
20 dB peak-to-average ratio) sounds gated out so most inter-
phoneme content is unintelligible.

Diagnostic at +66 (render callback peak sampling every 100
callbacks during a 60-second talker session) showed peak_stereo
values in the 0.005-0.01 range with occasional 0.13-0.49 spikes
\u2014 i.e. the signal is REAL and reaching the device, but VPIO's
output-side voice processing chain is gating the average-level
content.

Root cause: AVAudioSession mode .default + VPIO is a mismatched
pairing. Under .default mode the VPIO unit's internal AGC/NS
thresholds are tuned wrong for telephony-style speech and treat
quiet inter-phoneme content as noise to gate out.

Fix: switch back to mode .voiceChat which is Apple's documented
pair for VoiceProcessingIO. WebRTC's reference iOS audio device
manager (chromium googlesource voice_processing_audio_unit.mm)
also uses this pair. VPIO under .voiceChat tunes its processing
chain for speech and passes quiet content through cleanly.

The original 'speaker/receiver toggle is silent under .voiceChat'
bug was caused by cpal's RemoteIO unit binding to a stale
physical transducer at construction time, not by .voiceChat
itself. After migrating to VPIO at commits 1-4 (af686ca through
e7c3ffa) the route binding is correct under either mode because
VPIO natively re-binds on overrideOutputAudioPort \u2014 it IS the
canonical voice unit. So .default lost its only benefit and we
revert to the Apple-documented pairing.

Category options unchanged: .allowBluetoothHFP +
.allowBluetoothA2DP \u2014 BT headsets still permitted in both
directions regardless of mode.

Build counter 66 -> 67.
2026-05-17 01:44:05 +08:00
EdisonJwa 6a4dbad60e fix(ios,audio): use AVAudioSession mode .default + correct mono downmix
Two changes that together address the user-reported 'speaker selector
not working' AND 'audio quality bad' symptoms on iPhone:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default

   .voiceChat binds the underlying AudioUnit's output element to a
   SINGLE physical transducer (the receiver/earpiece) at session-
   configure time. overrideOutputAudioPort updates AVAudioSession's
   route metadata so currentRoute.outputs reports Speaker, but the
   AudioUnit's output binding is stale and audio keeps routing to
   the original transducer. Net: tapping Speaker in the picker
   flipped the route in our log but produced no audible change.

   .voiceChat also enables iOS's telephony processing chain (forced
   mono output, aggressive AGC, heavy noise gating) which explains
   the 'garbled / watery / metallic' quality complaints.

   .default mode uses iOS's standard audio graph: stereo output, no
   AGC, no telephony post-processing, AudioUnit re-binds live when
   the route changes. Same mode Music.app and most non-telephony
   apps use. Trade-off: we lose iOS hardware AEC. If users report
   speakerphone echo we'll add software AEC (DEC-030).

   Category options unchanged \u2014 .allowBluetoothHFP +
   .allowBluetoothA2DP still permit BT headsets in both directions.

2. engine.rs::build_output_stream: mono device downmix fix

   The mixing path at dev_channels==1 previously wrote only the L
   channel of AudioHandler's stereo output into the single mono
   device channel and discarded R entirely. Anything panned right
   in the stereo voice mix was silently lost \u2014 on .voiceChat
   speakerphone (forced mono device) this manifested as quiet
   remote speakers being inaudible. Fix: when dev_channels==1,
   output = (L + R) * 0.5 instead of just L. The dev_channels>=2
   branch is unchanged.

   With change #1 iOS will typically expose stereo so this branch
   is rarely hit, but the fix is correct for any genuinely-mono
   sink (some BT car-audio profiles, USB mono headsets).
2026-05-17 00:18:48 +08:00
EdisonJwa 2f6cdd3ad4 Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides"
This reverts commit da631a2bef.
2026-05-17 00:17:10 +08:00
EdisonJwa da631a2bef diag(ios,audio): log AVAudioSession state + route changes around picker overrides
Add structured NSLog instrumentation to AppDelegate.swift and debugPrint
chains in voice_compact.dart::_AudioOutputPickerSheetState so we can
correlate user picker taps with what iOS actually does to the route.

Three diagnostic streams:

* 'chanora.session[<tag>]' from Swift — full session snapshot (category,
  mode, sampleRate, ioBufferDuration, current route inputs+outputs,
  preferredInput) emitted on every setActive and every
  AVAudioSession.routeChangeNotification with the reason decoded
  (override / routeConfigurationChange / newDeviceAvailable / etc).
* 'chanora.route[<tag>]' from Dart — current route's inputs+outputs
  emitted before/after every overrideOutputAudioPort or
  setPreferredInput call, plus a delayed re-check at +250 ms to detect
  silent reverts.
* Existing 'chanora: ...' debugPrint lines from the picker now include
  the OK case (override returned, setPreferredInput returned) so we see
  a positive signal in the log when the API didn't throw.

Used to root-cause the 'speaker selector not working' issue: the
hypothesis is that cpal's RemoteIO AudioUnit reacts to its own format
configuration notifications by triggering routeConfigurationChange
that reverts our Dart-side override. The logs will confirm or deny
this — if we see 'chanora.session[routeChange.override] out=Speaker'
followed by 'chanora.session[routeChange.routeConfigurationChange]
out=Receiver' within a few hundred ms, that's the smoking gun.

Pure diagnostic commit. No behavioural change. Logs are NSLog +
debugPrint so they appear in Xcode console / 'flutter logs' / the
device log via Console.app or 'devicectl device log'.
2026-05-16 23:57:50 +08:00
EdisonJwa 9413703372 fix(ios,audio): drop .defaultToSpeaker; speakerphone toggle now actually switches
User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.

Root cause:

AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'

When the user picked Speaker:
  overrideOutputAudioPort(.speaker)   <- already at speaker baseline; no-op
When the user picked iPhone receiver:
  overrideOutputAudioPort(.none)       <- removes speaker OVERRIDE,
                                          restores baseline = .defaultToSpeaker
                                          = speakerphone. Receiver
                                          row silently mapped to speaker.

So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.

Fix:

  1. AppDelegate.swift: drop .defaultToSpeaker from options. With
     pure .voiceChat mode (no .defaultToSpeaker), the baseline is
     the receiver/earpiece. overrideOutputAudioPort then works as
     documented:
       Default                      = receiver
       overrideOutputAudioPort(.speaker)  -> speakerphone
       overrideOutputAudioPort(.none)     -> back to receiver
       BT/AirPods connected         -> automatic
       Wired headphones plugged in  -> automatic

  2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
     swallow with debugPrint logging of (a) the actual exception
     and (b) which route was selected. So if iOS rejects an
     override (e.g. wired headphones plugged in), we can see WHY
     in the device log instead of a silent picker no-op.

  3. _selectSpeaker also now sets preferredInput to the built-in
     mic so input + output stay consistent. Previously the
     speakerphone override could leave the mic still routed to a
     previously-selected BT input \u2014 user hears self through
     speaker but server hears nothing.

flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
2026-05-16 21:32:41 +08:00
EdisonJwa 4ee2b3850a fix(ios): rename allowBluetooth -> allowBluetoothHFP (iOS 26 SDK)
Xcode warning on iOS SDK 26+:
  'allowBluetooth' was deprecated in iOS 8.0: renamed to
  'AVAudioSession.CategoryOptions.allowBluetoothHFP'

The flag was renamed in iOS 8 (a decade ago) but the old name has
been kept as a soft-deprecated alias. iOS 26 SDK finally emits the
warning, and -Werror builds would fail on it. Same semantics:
permit HFP-profile Bluetooth headsets as input + output. Kept
.allowBluetoothA2DP alongside for higher-quality output-only A2DP
devices.

flutter build ios --release --no-codesign: 10.7 s, Runner.app
30.0 MB.
2026-05-16 17:56:25 +08:00
EdisonJwa 0466000733 fix(ios): defer AVAudioSession.setActive(true) to didBecomeActive
From iPhone log:
  chanora_flutter: AVAudioSession setup failed:
    Error Domain=NSOSStatusErrorDomain Code=561017449
    'Session activation failed'

Error code 561017449 = AVAudioSessionErrorCodeCannotStartPlaying
(ASCII '!cat' big-endian). iOS 17+ refuses setActive(true) calls
made before the app's scene is foregrounded: the audio policy
server denies the activation because the app is not yet considered
the foreground priority owner. didFinishLaunchingWithOptions runs
BEFORE the scene becomes .active, so synchronous activation there
hits this race on cold launch.

Symptom flow:
  1. App cold-launch -> AppDelegate.didFinishLaunching fires
  2. setActive(true) -> Error 561017449
  3. Audio session is left inactive
  4. cpal's later attempts to open RemoteIO see an inactive
     session and reject with StreamConfigNotSupported
  5. voice_join fails at ensure_audio_running
  6. user sees the audio failure manifested as missing mute /
     continuous / PTT buttons (now fixed in f1f81a3 to be
     lenient; this commit also unblocks the underlying audio).

Fix: split the AVAudioSession configuration into two phases:
  * setCategory at didFinishLaunching (always safe).
  * setActive(true) deferred to UIApplication.didBecomeActive    Notification, which fires after the cold-launch settle and
    on every resume-from-background. Repeated setActive while
    already-active is a no-op per docs.

This is the canonical iOS voice-app pattern (Discord, Zoom,
FaceTime, Flutter's  package all follow it). Documented
in commit body comments.

flutter build ios --release --no-codesign: 13.2 s, Runner.app
30.0 MB.
2026-05-16 17:53:49 +08:00
EdisonJwa f1f81a3d7e fix(core,bridge,ios): voice_join survives audio-engine failure; iOS log file
User report from iPhone: 'mute / continuous / PTT buttons missing'
with NO error popup. Root cause: voice_join's audio-engine startup
was failing silently, and the failure propagated out as a hard
error \u2014 which means SessionEvent::VoiceState(true) was never sent
to Dart even though the server-side channel move had already
succeeded. Dart's _inChannel stayed false; every control gated on
_inChannel disappeared while the channel tree continued to show
the user as joined.

This commit makes voice_join lenient on audio-engine failures so
the UI state matches the server-side reality, and also gives iOS
a writable log file so diagnostics from device builds are
recoverable for the first time.

core/chanora_core/src/lib.rs::voice_join
  * ensure_audio_running's error is now logged + emitted as
    SessionEvent::AudioStopped, but does NOT abort voice_join.
    The server move at step 1 already succeeded; failing the
    Dart-visible promise here would leave the UI in a phantom
    'in-channel visually but no controls' state. After this
    commit:
      - mic / headset / settings appear in the AppBar
      - PTT button appears at the bottom
      - status chip shows live audio state ('Mic on/off')
      - if audio actually failed (mic permission denied,
        no input device, CoreAudio rejecting stream config)
        the user can retry by switching modes / channels;
        BridgeEvent::AudioStopped wires _audioStarted=false
        in Dart so audio-stats poll is honest about the
        engine state.

crates/chanora_bridge/src/api.rs::log_file_path
  * iOS now writes the log to /home/milkice/Documents/chanora.log
    (Documents is the standard user-visible iOS sandbox dir).
  * Android remains None pending the bridge JNI init wiring
    a writable path (P1 follow-up).

apps/chanora_flutter/ios/Runner/Info.plist
  * Adds UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
    so the Documents directory shows up under 'On My iPhone \u2192
    Chanora' in the Files.app. The user can now copy chanora.log
    out for support without needing Xcode \u2192 Devices and
    Simulators \u2192 Download Container.

Workspace tests: 78/0/1 unchanged.
flutter build ios --release --no-codesign: 22.7 s clean
(Runner.app 29.9 MB).
2026-05-16 17:16:17 +08:00
EdisonJwa 7c0e5caa92 fix(ui,ios): first-tap on TextField now opens the keyboard
User report: 'every first time to tap to input box nothing
happened'. Symptom is iPhone-specific: the very first tap on any
of the Host / Nickname / Password text boxes in the connect form
fails to focus + open the keyboard. The second tap on the same
field works.

Two distinct causes, both addressed:

  apps/chanora_flutter/lib/main.dart
    The connect form lives inside a SingleChildScrollView. Flutter
    on iOS has a long-standing issue (flutter#19027) where the
    enclosing Scrollable's gesture-arena participant absorbs the
    first tap as a possible scroll-intent, leaving the TextField
    unfocused; the second tap reaches the field because the
    scrollable has already declined to handle a drag.

    Fix: _ConnectForm converted from StatelessWidget to
    StatefulWidget so it can own FocusNodes for the three text
    fields. Each TextField gains:
      * focusNode: <its own FocusNode>
      * onTap: () => focusNode.requestFocus()
        — forces focus on tap-down regardless of arena outcome
      * textInputAction: TextInputAction.next (host, nick) /
        TextInputAction.done (password) for return-key flow
      * autocorrect: false, enableSuggestions: false
        — these are server-host / nickname / password fields, the
        iOS auto-correct + suggestion bar is wrong for all three.

    Also set keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior
    .onDrag on the SingleChildScrollView so the keyboard hides
    when the user starts scrolling the bookmark list below.

  apps/chanora_flutter/ios/Runner/AppDelegate.swift
    AVAudioSession.sharedInstance().requestRecordPermission was
    fired synchronously from didFinishLaunchingWithOptions. The
    permission alert can race with iOS's text-input subsystem
    initialisation: if the alert appears before the keyboard
    layer finishes wiring up, subsequent text-field focus
    requests are dropped silently.

    Fix: DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) to
    defer the permission request until ~1 s after the app shell
    is on screen. Long enough for iOS's text-input layer to fully
    initialise; short enough that the user reads the prompt
    before tapping a field.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 26.8 s clean
(Runner.app 29.8 MB).
2026-05-16 15:50:27 +08:00
EdisonJwa d4c04b6a72 fix(ui,audio,ios,macos): eight P0 mobile fixes
User report from sideloaded iPhone build, in order of priority:

  #4 'Could not join channel: audio: audio backend:
     build_output_stream: The requested stream configuration is
     not supported by the device.'

     Cause: we forced cpal::BufferSize::Fixed(2048) on the output
     and input streams unconditionally on non-Linux. iOS CoreAudio
     RemoteIO units reject arbitrary buffer-size requests with that
     exact error. Windows WASAPI needs the pinning for shared-mode
     jitter, but macOS / iOS do not.
     Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere
     else use BufferSize::Default and let the platform HAL pick.
     crates/chanora_audio/src/engine.rs.

  #5 'Could not join channel: invariant violated:
     voice_in already taken'

     Cause: start_audio tore down the old engine BEFORE attempting
     to construct the new one, and consumed voice_in (an mpsc
     Receiver that can only be taken once) early. When the new
     engine failed mid-construction (e.g. because of #4 above) the
     session was left with: no audio engine, voice_in consumed,
     no way to retry without reconnect. The second voice_join
     attempt surfaced the invariant message.
     Fix: build the new engine BEFORE tearing down the old. Only
     swap state.audio if construction succeeded. crates/chanora_
     core/src/lib.rs::ChanoraSession::start_audio. Additionally
     added a put_voice_in helper to the protocol adapter (
     crates/chanora_protocol/src/adapter.rs) for a future
     broadcast-channel migration; the helper is unused on the
     immediate fix path but documents the intent.

  #3 'permission request would better on first open'

     Cause: AVAudioSession only triggers the mic-permission
     prompt the first time it tries to record. We never recorded
     until voice_join, so the prompt fired then.
     Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission
     in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:).
     Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in
     macos/Runner/AppDelegate.swift::applicationDidFinishLaunching.
     Both run non-blocking; user can deny without crashing app
     launch, and voice_join then surfaces a clearer downstream
     error when the engine fails to open the input device.

  #1 + #2 'one-column upper takes too much space; Push to Talk
           button at bottom would be better'

     Layout rework for narrow-mode (single column, mobile shape):
     - Flipped the stacking order in main.dart so Voice Bar moves
       to the BOTTOM of the body and the channel tree (Expanded)
       fills above. Wide-mode (Row, >= 840 dp) layout unchanged.
     - Inside the Voice Bar on touch-only hosts, moved the
       on-screen Push to Talk button to be the LAST element of
       the Voice Bar (was Row 3). Order now: pill + mutes, mode
       badge + settings, level meter, stats line, release-tail
       caption, PTT button. The button is closest to the user's
       thumb when the Voice Bar is pinned to the bottom of a
       narrow-layout screen.

  #6 'remove right top debug badge'

     debugShowCheckedModeBanner: false on the MaterialApp.
     Release builds never showed it anyway; this only affects
     local dev / debug builds.

  #7 'what does the refresh button use for? nothing happened'

     Removed. The snapshot updates via BridgeEvent::SnapshotChanged
     are pushed from the bridge — a manual rust.snapshot() call
     was redundant. Now only the Diagnostics + Disconnect actions
     remain in the AppBar trailing row when connected.

  #8 'Bind Key related function should not be added to a mobile
     platform'

     widgets/voice_settings.dart: bind-key OutlinedButton is now
     #cfg'd out when Platform.isIOS || Platform.isAndroid. The
     release-tail slider stays because it still applies to the
     on-screen PTT button. Capability badge in voice_bar.dart
     also hidden on mobile (it would always show L0Focused which
     is redundant with the visible on-screen button).

Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1
on Linux; flutter analyze clean (6 pre-existing Radio.groupValue
infos). flutter build ios --release --no-codesign: 28.8 s clean
(Runner.app 29.9 MB).
2026-05-16 15:41:08 +08:00
EdisonJwa f5d3810f5f feat(ios,macos): Apple privacy manifest (PrivacyInfo.xcprivacy)
Apple has enforced a `PrivacyInfo.xcprivacy` privacy manifest at App
Store submission since May 2024 for iOS / iPadOS / visionOS /
watchOS, and rolled the requirement out to macOS in late 2024.
Without the file, App Store Connect rejects archive uploads with
"missing required privacy manifest". This commit adds the manifest
for both iOS and macOS Runner targets.

apps/chanora_flutter/ios/Runner/PrivacyInfo.xcprivacy
apps/chanora_flutter/macos/Runner/PrivacyInfo.xcprivacy
  Identical content. Declarations:

  NSPrivacyCollectedDataTypes:
    NSPrivacyCollectedDataTypeAudioData
      Microphone audio transmitted to the user's chosen voice
      server while connected and unmuted. Not linked to user
      identity (no Apple ID / IDFA tied), not used for tracking.
      Purpose: AppFunctionality (communications).

  NSPrivacyTracking: false
  NSPrivacyTrackingDomains: []
    Chanora performs no cross-app / cross-website tracking.

  NSPrivacyAccessedAPITypes:
    FileTimestamp (C617.1)
      tokio + rusqlite file I/O for identity.tskey, chanora.db,
      audio_meta.json, chanora.log inside the app container.
    UserDefaults (CA92.1)
      Indirect via path_provider Flutter plugin querying for
      Application Support / Documents directories.
    SystemBootTime (35F9.1)
      tracing-subscriber timestamps log records relative to boot.
    DiskSpace (85F4.1)
      rusqlite checks before sqlite page writes.

  All four "required reason" API categories use Apple's published
  allow-list reason codes; no fingerprinting / analytics usage.

apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj
  Added PrivacyInfo.xcprivacy to the Runner group and to the
  Runner target's "Copy Bundle Resources" build phase via the
  xcodeproj Ruby gem (via a one-shot script). With this, the file
  is placed at Runner.app/PrivacyInfo.xcprivacy where Apple's
  validator looks for it — `find Runner.app -name
  PrivacyInfo.xcprivacy` shows our manifest at the bundle root
  alongside Flutter's and connectivity_plus's.

Verified on the M1 Mac (coder@100.118.130.73):
  flutter build ios --release --no-codesign                4.0 s
    -> Runner.app/PrivacyInfo.xcprivacy present
  flutter build ipa --release --no-codesign                28.4 s
    -> Runner.xcarchive built (171.4 MB)
    -> archive's Runner.app/PrivacyInfo.xcprivacy present
    -> archive's Runner.app/Frameworks/chanora_bridge.framework
       built fresh via the chanora_bridge.podspec prepare_command
       under xcodebuild's sandbox (no PATH / env weirdness).

P1 follow-ups noted by xcodebuild's validator (not blockers for
this commit but for App Store submission):
  * Real app icon (currently default placeholder)
  * Real launch image (currently default placeholder)
  * Paid Apple Developer Program account, registered App ID, and
    Distribution provisioning profile (Personal Team sideloads
    still work as today).
2026-05-16 16:03:39 +09:00
EdisonJwa f3320715ea feat(audio,ios): AVAudioSession PlayAndRecord+voiceChat in AppDelegate
iOS AVAudioSession must be configured BEFORE Flutter starts its
audio pipeline; the canonical place is application(_:didFinishLaunching\
WithOptions:) in AppDelegate.swift. This commit:

apps/chanora_flutter/ios/Runner/AppDelegate.swift:
  * import AVFoundation
  * In application(_:didFinishLaunchingWithOptions:), call
    AVAudioSession.sharedInstance().setCategory(.playAndRecord,
      mode: .voiceChat,
      options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
    followed by setActive(true). Failures are NSLogged but do not
    block app launch — cpal's CoreAudio backend will still come up
    against the default iOS routing.

  This shape:
   * routes the receiver/speaker like a phone call (.playAndRecord +
     .voiceChat),
   * engages on-device AEC / NS where supported,
   * defaults to speaker so users don't have to hold the phone to
     their ear,
   * permits Bluetooth headsets (AirPods et al. just work).

crates/chanora_audio/src/engine.rs:
  * Replace the iOS engine-start placeholder log line ('binding
    pending — Chanora iOS audio is documented-only for Beta') with
    an honest acknowledgment that the AVAudioSession configuration
    lives Swift-side. The Rust engine acknowledges the request, then
    cpal opens its CoreAudio streams against the session.

iOS-only Rust code is #[cfg(target_os = "ios")]-gated so this commit
is no-op on every other platform.

SRS-197: iOS/macOS audio routing contract. DEC-025: iOS officially
in scope for P0 (Focused PTT only — Apple's sandbox model has no
global PTT analogue).
2026-05-16 13:50:43 +08:00
EdisonJwa e3d7017dd9 feat(macos,ios): entitlements + permission strings + bundle-glue script
macOS:
  * Runner/DebugProfile.entitlements + Release.entitlements: add
    com.apple.security.network.client (outbound TS3 server connect)
    and com.apple.security.device.audio-input (microphone capture).
    Debug keeps com.apple.security.network.server + cs.allow-jit
    (Flutter hot-reload needs both); Release drops them.
  * Runner/Info.plist: add NSMicrophoneUsageDescription and
    NSInputMonitoringUsageDescription so the macOS system prompts
    show a sensible explanation when Chanora first needs mic or
    Input Monitoring access. Input Monitoring is required by
    CGEventTapCreate (SDD-085).
  * Runner.xcodeproj/project.pbxproj: switch Debug/Release/Profile
    code-signing from Automatic + Apple Development to Manual +
    "Sign to Run Locally" (CODE_SIGN_IDENTITY = -). This lets
    `flutter build macos --release` work over SSH where the login
    keychain is locked. The owner re-enables the personal team
    locally in Xcode for physical-device iOS testing later.

iOS:
  * Runner/Info.plist: add NSMicrophoneUsageDescription and the
    UIBackgroundModes = ['audio'] entry so voice traffic continues
    when the app is backgrounded (TS3 servers drop clients on idle
    audio streams).

tools/macos-postbuild.sh: new script. flutter build macos --release
emits build/macos/Build/Products/Release/chanora_flutter.app but
does NOT bundle libchanora_bridge.dylib. FRB on macOS dlopen()s the
bridge as chanora_bridge.framework/chanora_bridge, not a plain
dylib. This script:

  1. Wraps target/release/libchanora_bridge.dylib in a proper
     chanora_bridge.framework (Versions/A layout, Info.plist,
     Resources, symlinks).
  2. Rewrites LC_ID_DYLIB to
     @rpath/chanora_bridge.framework/chanora_bridge.
  3. Ad-hoc codesigns the framework and the .app bundle.
  4. Verifies with codesign --verify --deep --strict.

macOS analogue of buildit.cmd on Windows. Auto-integration into
Xcode build phases via cargokit / corrosion is a P1 carryover.

Verified end-to-end on the M1 Mac:
  cargo build --release -p chanora_bridge          11.76 s
  flutter build macos --release                    ok (59.2 MB)
  tools/macos-postbuild.sh Release                 ok
  chanora_flutter.app launch via SSH               bridge initialised,
                                                   identity + bookmark
                                                   store initialised
                                                   (~5 s smoke).
  ~/Library/Logs/app.chanora.chanora_flutter/chanora.log captures
  the boot sequence cleanly.

DEC-025 reference: macOS desktop is officially in scope.
2026-05-16 14:26:13 +09:00
EdisonJwa 4d57d189c9 feat(flutter,macos,ios): scaffold platform Xcode projects via flutter create
Ran `flutter create --platforms=macos,ios --project-name=chanora_flutter
--org=app.chanora .` on the M1 Mac to generate the standard Flutter
platform-specific scaffolding (Runner.xcodeproj, Podfile, AppDelegate,
entitlements, etc.) for both macOS and iOS.

The cross-platform Dart source (lib/) and Rust workspace (crates/,
core/) carry the actual application logic; these scaffolds are
required only so flutter build macos / ios can resolve their Xcode
projects. No application code added.

macOS smoke-launch from the M1 Mac verified the bridge dylib load
path: after manually wrapping libchanora_bridge.dylib into a proper
chanora_bridge.framework bundle (FRB on macOS expects a framework,
not a plain dylib) and codesigning ad-hoc, the runner starts cleanly
through 'bridge initialised', 'identity store initialised', 'bookmark
store initialised' just like the Linux runner.

Following commits will:
  * Wire the framework-bundling step into build glue (currently manual
    install_name_tool + codesign).
  * Replace the macOS PTT backend stub (crates/chanora_audio/src/
    ptt_backends/macos.rs) with live IOHIDCheckAccess +
    CGEventTapCreate so the descriptor advertises real L2/L3 capability
    on a permission-granted box (SDD-085).
  * Add the iOS AVAudioSession PlayAndRecord+voiceChat wiring.
  * Add docs/verification/macos-p0-acceptance.md and ios-p0-acceptance.md.
2026-05-16 14:07:33 +09:00