diff --git a/apps/chanora_flutter/ios/Runner/Info.plist b/apps/chanora_flutter/ios/Runner/Info.plist
index 2437290..885f9d5 100644
--- a/apps/chanora_flutter/ios/Runner/Info.plist
+++ b/apps/chanora_flutter/ios/Runner/Info.plist
@@ -7,7 +7,7 @@
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
- Chanora Flutter
+ Chanora
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
@@ -15,7 +15,7 @@
CFBundleInfoDictionaryVersion
6.0
CFBundleName
- chanora_flutter
+ Chanora
CFBundlePackageType
APPL
CFBundleShortVersionString
diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart
index 667f343..5243be7 100644
--- a/apps/chanora_flutter/lib/main.dart
+++ b/apps/chanora_flutter/lib/main.dart
@@ -27,6 +27,11 @@ import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
+bool get _isMacOS => !kIsWeb && Platform.isMacOS;
+
+/// Top padding for macOS to clear traffic-light buttons.
+const double _macOSTrafficLightPad = 56.0;
+
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
@@ -1007,280 +1012,249 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
+ final headerActions = [
+ if (_phase == _Phase.connected &&
+ _inChannel &&
+ MediaQuery.of(context).size.width < 840.0) ...[
+ IconButton(
+ tooltip: l10n.voiceHardMuteLabel,
+ icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
+ isSelected: _hardMute,
+ selectedIcon: const Icon(Icons.mic_off),
+ onPressed: _onToggleHardMute,
+ ),
+ IconButton(
+ tooltip: l10n.voiceOutputMuteLabel,
+ icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
+ isSelected: _outputMuted,
+ selectedIcon: const Icon(Icons.headset_off),
+ onPressed: _toggleOutputMute,
+ ),
+ ],
+ IconButton(
+ tooltip: l10n.aboutAction,
+ icon: const Icon(Icons.info_outline),
+ onPressed: () => _onShowAbout(context),
+ ),
+ if (_phase == _Phase.connected) ...[
+ IconButton(
+ tooltip: l10n.disconnectAction,
+ icon: const Icon(Icons.logout),
+ onPressed: _onDisconnect,
+ ),
+ ],
+ ];
+
+ final headerTitle = _AppBarTitle(
+ phase: _phase,
+ inChannel: _inChannel,
+ channelName: _currentVoiceChannelName(),
+ );
+
+ final bodyContent = LayoutBuilder(
+ builder: (ctx, bodyConstraints) {
+ const wideBreakpoint = 840.0;
+ final isWideSnapshot =
+ bodyConstraints.maxWidth >= wideBreakpoint &&
+ _phase == _Phase.connected &&
+ _snapshot != null;
+ final banner = Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.tertiaryContainer,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ l10n.homeNotProductionReadyBanner,
+ style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
+ ),
+ );
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
+ Text(statusText(), style: theme.textTheme.titleMedium),
+ if (_lostReason != null || _reconnectAttempt != null) ...[
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: 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 == _Phase.idle) ...[
+ Expanded(
+ 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 == _Phase.connecting) ...[
+ const Center(
+ child: Padding(
+ padding: EdgeInsets.all(32),
+ child: CircularProgressIndicator(),
+ ),
+ ),
+ ] else if (_phase == _Phase.connected && _snapshot != null) ...[
+ Expanded(
+ child: LayoutBuilder(
+ builder: (ctx, constraints) {
+ final voiceBar = VoiceBar(
+ inChannel: _inChannel,
+ transmitMode: _transmitMode,
+ hardMute: _hardMute,
+ outputMuted: _outputMuted,
+ releaseTailMs: _releaseTailMs,
+ channelName: _currentVoiceChannelName(),
+ audioStats: _audioStats,
+ pttLevel: _pttLevel,
+ pttBackendId: _pttBackendId,
+ pttBoundInputClass: _pttBoundInputClass,
+ pttBoundKeyLabel: _pttBoundKeyLabel,
+ onToggleMute: _onToggleHardMute,
+ onToggleOutputMute: _toggleOutputMute,
+ onConfigure: _onOpenVoiceSettings,
+ onPttHeldChanged: _onOnscreenPttHeldChanged,
+ );
+ final snapshotView = _SnapshotView(
+ snapshot: _snapshot!,
+ currentVoiceChannelId: _currentVoiceChannelId,
+ onJoinChannel: _onJoinChannel,
+ onLeaveVoice: _onLeaveVoice,
+ );
+ 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),
+ voiceBar,
+ ],
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(child: snapshotView),
+ ],
+ );
+ }
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(child: snapshotView),
+ const SizedBox(height: 8),
+ VoiceStatusChip(
+ transmitMode: _transmitMode,
+ releaseTailMs: _releaseTailMs,
+ pttBoundKeyLabel: _pttBoundKeyLabel,
+ audioStats: _audioStats,
+ isTouchOnly: _isTouchOnlyPttHost,
+ onTap: () => _onOpenVoiceDetailsSheet(),
+ ),
+ if (_inChannel &&
+ _transmitMode ==
+ rust.BridgeTransmitMode.ptt) ...[
+ const SizedBox(height: 8),
+ VoicePttButton(
+ active: _audioStats?.pttActive ?? false,
+ onHeldChanged: _onOnscreenPttHeldChanged,
+ ),
+ ],
+ ],
+ );
+ },
+ ),
+ ),
+ ],
+ ],
+ );
+ },
+ );
+
+ if (_isMacOS) {
+ return Scaffold(
+ body: Padding(
+ padding: const EdgeInsets.only(top: _macOSTrafficLightPad),
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ children: [
+ Expanded(child: headerTitle),
+ ...headerActions,
+ ],
+ ),
+ ),
+ Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)),
+ ],
+ ),
+ ),
+ );
+ }
+
return Scaffold(
appBar: AppBar(
- title: _AppBarTitle(
- phase: _phase,
- inChannel: _inChannel,
- channelName: _currentVoiceChannelName(),
- ),
- actions: [
- // Narrow-mode AppBar gains mic-mute + headset-mute icons
- // when the user is in a voice channel, so the on-screen
- // body can dedicate ~85% of its height to the channel tree
- // + the bottom-anchored PTT button. Wide mode keeps these
- // controls inside the VoiceBar widget (left column) so the
- // signed-off rc.8 wide-layout doesn't shift.
- //
- // The gear icon (Icons.tune) that used to live here was
- // **removed** in the rc.8 follow-up: it duplicated the
- // "Adjust mode & release tail" button now inside the modal
- // sheet, and the user reported the duplication. The modal
- // is reached by tapping the status chip above the PTT
- // button — there is now exactly one entry point.
- if (_phase == _Phase.connected &&
- _inChannel &&
- MediaQuery.of(context).size.width < 840.0) ...[
- IconButton(
- tooltip: l10n.voiceHardMuteLabel,
- icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
- isSelected: _hardMute,
- selectedIcon: const Icon(Icons.mic_off),
- onPressed: _onToggleHardMute,
- ),
- IconButton(
- tooltip: l10n.voiceOutputMuteLabel,
- icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
- isSelected: _outputMuted,
- selectedIcon: const Icon(Icons.headset_off),
- onPressed: _toggleOutputMute,
- ),
- ],
- IconButton(
- tooltip: l10n.aboutAction,
- icon: const Icon(Icons.info_outline),
- onPressed: () => _onShowAbout(context),
- ),
- if (_phase == _Phase.connected) ...[
- // The previous Refresh action (Icons.refresh + _onRefresh)
- // was removed in v1.0.0-rc.8 — the snapshot stream the
- // bridge pushes via BridgeEvent::SnapshotChanged keeps the
- // tree current automatically, and a manual refresh was a
- // no-op from the user's perspective.
- IconButton(
- tooltip: l10n.disconnectAction,
- icon: const Icon(Icons.logout),
- onPressed: _onDisconnect,
- ),
- ],
- ],
+ title: headerTitle,
+ actions: headerActions,
),
body: Padding(
padding: const EdgeInsets.all(16),
- child: LayoutBuilder(
- builder: (ctx, bodyConstraints) {
- // Shared breakpoint with the inner snapshot LayoutBuilder
- // below — when the UI is wide enough to split into two
- // columns AND we are showing the snapshot, the
- // not-production-ready banner is moved into the left
- // column above the Voice Bar so it doesn't span the
- // wider channel-tree area. In every other state (narrow,
- // or idle / connecting at any width) the banner stays
- // pinned to the top of the body.
- const wideBreakpoint = 840.0;
- final isWideSnapshot =
- bodyConstraints.maxWidth >= wideBreakpoint &&
- _phase == _Phase.connected &&
- _snapshot != null;
- final banner = Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: theme.colorScheme.tertiaryContainer,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(
- l10n.homeNotProductionReadyBanner,
- style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
- ),
- );
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
- Text(statusText(), style: theme.textTheme.titleMedium),
- if (_lostReason != null || _reconnectAttempt != null) ...[
- const SizedBox(height: 8),
- Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: 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 == _Phase.idle) ...[
- Expanded(
- child: SingleChildScrollView(
- // Dismiss keyboard when the user drags away from
- // a focused field — friendlier mobile UX than
- // forcing them to tap outside.
- 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 == _Phase.connecting) ...[
- const Center(
- child: Padding(
- padding: EdgeInsets.all(32),
- child: CircularProgressIndicator(),
- ),
- ),
- ] else if (_phase == _Phase.connected && _snapshot != null) ...[
- Expanded(
- child: LayoutBuilder(
- builder: (ctx, constraints) {
- final voiceBar = VoiceBar(
- inChannel: _inChannel,
- transmitMode: _transmitMode,
- hardMute: _hardMute,
- outputMuted: _outputMuted,
- releaseTailMs: _releaseTailMs,
- channelName: _currentVoiceChannelName(),
- audioStats: _audioStats,
- pttLevel: _pttLevel,
- pttBackendId: _pttBackendId,
- pttBoundInputClass: _pttBoundInputClass,
- pttBoundKeyLabel: _pttBoundKeyLabel,
- onToggleMute: _onToggleHardMute,
- onToggleOutputMute: _toggleOutputMute,
- onConfigure: _onOpenVoiceSettings,
- onPttHeldChanged: _onOnscreenPttHeldChanged,
- );
- final snapshotView = _SnapshotView(
- snapshot: _snapshot!,
- currentVoiceChannelId: _currentVoiceChannelId,
- onJoinChannel: _onJoinChannel,
- onLeaveVoice: _onLeaveVoice,
- );
- // Responsive: at <840 dp use a stacked layout
- // (Voice Bar on top, channel tree below). At
- // ≥840 dp use a side-by-side layout with the
- // Voice Bar pinned to 320 dp on the left and
- // the channel tree expanding on the right.
- // 840 dp matches Material's tablet / desktop
- // breakpoint.
- const wideBreakpoint = 840.0;
- 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),
- voiceBar,
- ],
- ),
- ),
- const SizedBox(width: 12),
- Expanded(child: snapshotView),
- ],
- );
- }
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // In narrow / one-column layout the layout
- // is:
- //
- // [ channel tree (Expanded) ]
- // [ status chip (2-line live readout) ]
- // [ PTT button (mobile-only,
- // PTT-mode-only) ]
- //
- // The chip is a tap target that opens
- // `showVoiceDetailsSheet` with the mic
- // level meter + TX/RX counts + capability
- // badge. Mode + bind key + release-tail
- // live in `VoiceSettingsDialog`
- // (gear icon in AppBar).
- //
- // Wide mode (Row branch above) is
- // unchanged from rc.8.
- Expanded(child: snapshotView),
- const SizedBox(height: 8),
- VoiceStatusChip(
- transmitMode: _transmitMode,
- releaseTailMs: _releaseTailMs,
- pttBoundKeyLabel: _pttBoundKeyLabel,
- audioStats: _audioStats,
- isTouchOnly: _isTouchOnlyPttHost,
- onTap: () => _onOpenVoiceDetailsSheet(),
- ),
- // PTT button only when PTT mode is active
- // AND the user is in a voice channel. In
- // Continuous mode there is nothing to
- // hold; the chip alone surfaces the
- // "Mic on / off" state.
- if (_inChannel &&
- _transmitMode ==
- rust.BridgeTransmitMode.ptt) ...[
- const SizedBox(height: 8),
- VoicePttButton(
- active: _audioStats?.pttActive ?? false,
- onHeldChanged: _onOnscreenPttHeldChanged,
- ),
- ],
- ],
- );
- },
- ),
- ),
- ],
- ],
- );
- },
- ),
+ child: bodyContent,
),
);
}
diff --git a/apps/chanora_flutter/macos/Runner/Info.plist b/apps/chanora_flutter/macos/Runner/Info.plist
index ae18827..2f2d7f5 100644
--- a/apps/chanora_flutter/macos/Runner/Info.plist
+++ b/apps/chanora_flutter/macos/Runner/Info.plist
@@ -13,7 +13,9 @@
CFBundleInfoDictionaryVersion
6.0
CFBundleName
- $(PRODUCT_NAME)
+ Chanora
+ CFBundleDisplayName
+ Chanora
CFBundlePackageType
APPL
CFBundleShortVersionString
diff --git a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift
index 3cc05eb..c21442f 100644
--- a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift
+++ b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift
@@ -8,8 +8,13 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
+ self.titlebarAppearsTransparent = true
+ self.titleVisibility = .hidden
+ self.styleMask.insert(.fullSizeContentView)
+ self.isMovableByWindowBackground = true
+
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()
}
-}
+}
\ No newline at end of file
diff --git a/tools/build-linux.sh b/tools/build-linux.sh
new file mode 100755
index 0000000..25b92e5
--- /dev/null
+++ b/tools/build-linux.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+# Chanora Linux build helper.
+#
+# Run this on a Linux host (Ubuntu 22.04+ / Debian bookworm+ / Fedora 38+) that
+# has already completed the prerequisites in §4 of docs/release/linux-build.md:
+# - Rust stable with x86_64-unknown-linux-gnu target
+# - Flutter 3.41.9+ stable on PATH
+# - CMake 3.13+, pkg-config, GTK3 dev headers, clang
+# - System libopus dev package (libopus-dev / opus-devel)
+# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings)
+#
+# Usage (from the repo root ~/chanora):
+# ./tools/build-linux.sh
+# ./tools/build-linux.sh --no-rust # skip Rust rebuild (cached)
+# ./tools/build-linux.sh --regenerate-bindings # rerun FRB codegen first
+# ./tools/build-linux.sh --version v0.2.0-beta.1
+#
+# Produces:
+# target/x86_64-unknown-linux-gnu/release/libchanora_bridge.so
+# apps/chanora_flutter/build/linux/x64/release/bundle/chanora_flutter
+# chanora--linux-x64.tar.gz (release bundle)
+
+set -euo pipefail
+
+VERSION="v0.2.0-beta.1"
+SKIP_RUST=0
+REGEN=0
+TARGET="x86_64-unknown-linux-gnu"
+
+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' '========================================================================'; }
+
+bar
+echo "Chanora Linux build"
+echo "Repo root : $REPO_ROOT"
+echo "Version : $VERSION"
+echo "Target : $TARGET"
+echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
+bar
+
+# ---------- 1. Verify toolchain ----------
+echo "[1/6] Verify toolchain"
+for cmd in cargo rustc flutter cmake pkg-config clang gcc; do
+ if ! command -v "$cmd" >/dev/null; then
+ echo " ERROR: '$cmd' not on PATH. See docs/release/linux-build.md." >&2
+ exit 1
+ fi
+ printf " %-12s : %s\n" "$cmd" "$($cmd --version 2>&1 | head -1)"
+done
+
+# Verify GTK3 dev headers
+if ! pkg-config --exists gtk+-3.0 2>/dev/null; then
+ echo " ERROR: gtk+-3.0 dev headers not found. Install libgtk-3-dev (Debian/Ubuntu) or gtk3-devel (Fedora)." >&2
+ exit 1
+fi
+
+# Verify libopus dev headers (needed by audiopus_sys to link)
+if ! pkg-config --exists opus 2>/dev/null; then
+ echo " ERROR: opus dev headers not found. Install libopus-dev (Debian/Ubuntu) or opus-devel (Fedora)." >&2
+ exit 1
+fi
+
+# Verify Rust target
+if ! rustup target list --installed | grep -q "^$TARGET$"; then
+ echo " Adding rustup target $TARGET..."
+ rustup target add "$TARGET"
+fi
+
+# ---------- 2. Add Linux platform if missing ----------
+FLUTTER_APP="$REPO_ROOT/apps/chanora_flutter"
+if [[ ! -d "$FLUTTER_APP/linux" ]]; then
+ echo "[2/6] Add Linux platform to apps/chanora_flutter"
+ (cd "$FLUTTER_APP" && flutter create --platforms=linux .)
+else
+ echo "[2/6] Linux platform already present in apps/chanora_flutter"
+fi
+
+# ---------- 3. (Optional) regenerate FRB bindings ----------
+if [[ $REGEN -eq 1 ]]; then
+ echo "[3/6] 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 "[3/6] Skipping FRB codegen (use --regenerate-bindings if api.rs changed)"
+fi
+
+# ---------- 4. Build the Rust cdylib ----------
+BRIDGE_SO="$REPO_ROOT/target/$TARGET/release/libchanora_bridge.so"
+
+if [[ $SKIP_RUST -eq 1 ]]; then
+ echo "[4/6] Skipping Rust build (cached)"
+ if [[ ! -f "$BRIDGE_SO" ]]; then
+ echo " ERROR: --no-rust but $BRIDGE_SO not found. Run without --no-rust first." >&2
+ exit 1
+ fi
+else
+ echo "[4/6] cargo build --release -p chanora_bridge --target $TARGET"
+ export CMAKE_POLICY_VERSION_MINIMUM=3.5
+ export LIBOPUS_STATIC=0
+ cargo build --release --target "$TARGET" -p chanora_bridge
+fi
+
+echo " Bridge .so: $BRIDGE_SO"
+if [[ ! -f "$BRIDGE_SO" ]]; then
+ echo " ERROR: $BRIDGE_SO not found after build." >&2
+ exit 1
+fi
+
+# ---------- 5. flutter build linux --release ----------
+echo "[5/6] flutter build linux --release"
+cd "$FLUTTER_APP"
+flutter pub get
+flutter build linux --release
+
+BUNDLE_DIR="$FLUTTER_APP/build/linux/x64/release/bundle"
+EXE="$BUNDLE_DIR/chanora_flutter"
+if [[ ! -f "$EXE" ]]; then
+ echo " ERROR: $EXE not found after flutter build." >&2
+ exit 1
+fi
+
+# Copy the bridge .so into the bundle's lib/ directory so dart:ffi finds it.
+mkdir -p "$BUNDLE_DIR/lib"
+cp "$BRIDGE_SO" "$BUNDLE_DIR/lib/"
+echo " Bundle dir : $BUNDLE_DIR"
+echo " EXE : $EXE"
+echo " Bridge .so : $BUNDLE_DIR/lib/libchanora_bridge.so"
+
+# ---------- 6. Package the bundle ----------
+echo "[6/6] Package release tarball"
+cd "$REPO_ROOT"
+TAR_NAME="chanora-$VERSION-linux-x64.tar.gz"
+TAR_PATH="$REPO_ROOT/$TAR_NAME"
+rm -f "$TAR_PATH"
+
+# Bundle everything in the bundle directory
+tar czf "$TAR_PATH" -C "$FLUTTER_APP/build/linux/x64/release/bundle" .
+echo " Tarball : $TAR_PATH"
+echo " Size : $(du -h "$TAR_PATH" | cut -f1)"
+
+bar
+echo "Linux build complete."
+bar
+echo "Artefacts:"
+echo " Rust lib:"
+echo " target/$TARGET/release/libchanora_bridge.so"
+echo " Bundle dir:"
+echo " $BUNDLE_DIR/"
+echo " Tarball:"
+echo " $TAR_PATH"
\ No newline at end of file