diff --git a/ios/NWCWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/NWCWidget/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 6b0ff7003..000000000 --- a/ios/NWCWidget/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.700", - "red" : "1.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/NWCWidget/NWCWidgetAttributes.swift b/ios/NWCWidget/NWCWidgetAttributes.swift index 4fb774576..61e301124 100644 --- a/ios/NWCWidget/NWCWidgetAttributes.swift +++ b/ios/NWCWidget/NWCWidgetAttributes.swift @@ -1,19 +1,13 @@ import Foundation import ActivityKit -// Shared between NWCWidget extension and the main zeus app target. -// Both targets compile this file; ActivityKit matches activities by type name. - @available(iOS 16.1, *) struct NWCLiveActivityAttributes: ActivityAttributes { struct ContentState: Codable, Hashable { - /// Currently playing track name, or nil when audio is stopped. var currentTrackName: String? - /// Whether the user has muted the audio (session stays alive). var isMuted: Bool } - /// Fixed: when the NWC session started (drives the elapsed timer). var startedAt: Date } diff --git a/ios/NWCWidget/NWCWidgetBridge.swift b/ios/NWCWidget/NWCWidgetBridge.swift deleted file mode 100644 index d0f2d62ef..000000000 --- a/ios/NWCWidget/NWCWidgetBridge.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -// Compiled into BOTH the NWCWidget extension AND the main zeus app target. -// -// In the widget extension process the NoOp implementation is used. -// In the main app process NWCActivityManager.init() immediately swaps -// NWCBridge to the real NWCWidgetBridgeImpl, so Live Activity button taps -// (which run inside the host-app process via LiveActivityIntent) are routed -// back to the running audio session. - -protocol NWCWidgetBridgeProtocol { - func nextTrack() - func prevTrack() - func toggleMute() - func stopNWC() -} - -/// Global. NoOp by default; replaced by NWCWidgetBridgeImpl in the main app. -var NWCBridge: NWCWidgetBridgeProtocol = NWCWidgetBridgeNoOp() - -final class NWCWidgetBridgeNoOp: NWCWidgetBridgeProtocol { - func nextTrack() {} - func prevTrack() {} - func toggleMute() {} - func stopNWC() {} -} diff --git a/ios/NWCWidget/NWCWidgetIntent.swift b/ios/NWCWidget/NWCWidgetIntent.swift index 73d6753cb..c0bf9e391 100644 --- a/ios/NWCWidget/NWCWidgetIntent.swift +++ b/ios/NWCWidget/NWCWidgetIntent.swift @@ -1,9 +1,6 @@ import AppIntents -import WidgetKit -// Darwin notification names – must match NWCAudioKeepAlive.m exactly. -// CFNotificationCenter is the correct IPC mechanism for widget extension → host app -// because LiveActivityIntent.perform() runs in the extension process, not the main app. +// Must match NWCAudioKeepAlive.m private let kNWCNextTrack = "com.zeusln.zeus.nwc.nextTrack" private let kNWCPrevTrack = "com.zeusln.zeus.nwc.prevTrack" private let kNWCToggleMute = "com.zeusln.zeus.nwc.toggleMute" diff --git a/ios/NWCWidget/NWCWidgetLiveActivity.swift b/ios/NWCWidget/NWCWidgetLiveActivity.swift index 2f3a93017..ac8ee1344 100644 --- a/ios/NWCWidget/NWCWidgetLiveActivity.swift +++ b/ios/NWCWidget/NWCWidgetLiveActivity.swift @@ -3,10 +3,6 @@ import AppIntents import SwiftUI import WidgetKit -// ─── Zeus icon (widget asset catalog: zeusLogo @1x/@2x/@3x) ───────────────── -// Uses the same layout as Primal's dynamicIslandLogo — explicit frame + fit. -// Asset is built from zeus_icon.jpg (opaque), not the 1024 App Store icon. - private struct ZeusIcon: View { var size: CGFloat = 20 @@ -20,8 +16,6 @@ private struct ZeusIcon: View { } } -// ─── Circle button style ────────────────────────────────────────────────────── - private struct CircleButtonStyle: ButtonStyle { var size: CGFloat = 38 var tint: Color = Color.white.opacity(0.14) @@ -35,15 +29,11 @@ private struct CircleButtonStyle: ButtonStyle { } } -// ─── Expanded / lock-screen content ────────────────────────────────────────── - private struct NWCExpandedView: View { let context: ActivityViewContext var body: some View { VStack(alignment: .leading, spacing: 9) { - - // Row 1 – header HStack(spacing: 10) { ZeusIcon(size: 28) @@ -59,16 +49,13 @@ private struct NWCExpandedView: View { .monospacedDigit() } - // Row 2 – track name - let muted = context.state.isMuted if let track = context.state.currentTrackName { - Text(muted ? "Muted · \(track)" : "Now playing: \(track)") + Text(context.state.isMuted ? "Muted · \(track)" : "Now playing: \(track)") .font(.system(size: 12)) .foregroundStyle(.white.opacity(0.5)) .lineLimit(1) } - // Row 3 – controls (all buttons same size) HStack(spacing: 0) { Button(intent: NWCPrevTrackIntent()) { Image(systemName: "backward.fill") @@ -80,13 +67,12 @@ private struct NWCExpandedView: View { Spacer() Button(intent: NWCToggleMuteIntent()) { - Image(systemName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill") + Image(systemName: context.state.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(muted ? Color.orange : .white.opacity(0.85)) - .contentTransition(.symbolEffect(.replace)) + .foregroundStyle(context.state.isMuted ? Color.orange : .white.opacity(0.85)) } .buttonStyle(CircleButtonStyle( - tint: muted ? Color.orange.opacity(0.25) : Color.white.opacity(0.14) + tint: context.state.isMuted ? Color.orange.opacity(0.25) : Color.white.opacity(0.14) )) Spacer() @@ -118,12 +104,9 @@ private struct NWCExpandedView: View { } } -// ─── Widget ─────────────────────────────────────────────────────────────────── - struct NWCWidgetLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: NWCLiveActivityAttributes.self) { context in - // Lock-screen / notification-banner NWCExpandedView(context: context) .padding(.horizontal, 20) .padding(.vertical, 14) @@ -144,7 +127,6 @@ struct NWCWidgetLiveActivity: Widget { : "speaker.wave.2.fill") .font(.system(size: 12, weight: .medium)) .foregroundStyle(context.state.isMuted ? .orange : .white) - .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) .padding(.trailing, 2) diff --git a/ios/zeus.xcodeproj/project.pbxproj b/ios/zeus.xcodeproj/project.pbxproj index bdbd3db44..1453c89f5 100644 --- a/ios/zeus.xcodeproj/project.pbxproj +++ b/ios/zeus.xcodeproj/project.pbxproj @@ -227,9 +227,7 @@ NWCAUDIO0000000000000012 /* White Noise.m4a in Resources */ = {isa = PBXBuildFile; fileRef = NWCAUDIO0000000000000016 /* White Noise.m4a */; }; NWCAUDIO0000000000000013 /* Gentle Rain.m4a in Resources */ = {isa = PBXBuildFile; fileRef = NWCAUDIO0000000000000017 /* Gentle Rain.m4a */; }; NWCLA000000000000000001 /* NWCWidgetAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = NWCLA000000000000000007 /* NWCWidgetAttributes.swift */; }; - NWCLA000000000000000002 /* NWCWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = NWCLA000000000000000008 /* NWCWidgetBridge.swift */; }; NWCLA000000000000000003 /* NWCActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = NWCLA000000000000000009 /* NWCActivityManager.swift */; }; - NWCLA000000000000000004 /* NWCWidgetBridgeImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = NWCLA000000000000000010 /* NWCWidgetBridgeImpl.swift */; }; NWCLA000000000000000005 /* NWCWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = NWCLA000000000000000006 /* NWCWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -721,9 +719,7 @@ NWCAUDIO0000000000000017 /* Gentle Rain.m4a */ = {isa = PBXFileReference; lastKnownFileType = "audio.x-m4a"; name = "Gentle Rain.m4a"; path = "zeus/Gentle Rain.m4a"; sourceTree = ""; }; NWCLA000000000000000006 /* NWCWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NWCWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; NWCLA000000000000000007 /* NWCWidgetAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NWCWidgetAttributes.swift; path = NWCWidget/NWCWidgetAttributes.swift; sourceTree = ""; }; - NWCLA000000000000000008 /* NWCWidgetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NWCWidgetBridge.swift; path = NWCWidget/NWCWidgetBridge.swift; sourceTree = ""; }; NWCLA000000000000000009 /* NWCActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NWCActivityManager.swift; path = zeus/NWCActivityManager.swift; sourceTree = ""; }; - NWCLA000000000000000010 /* NWCWidgetBridgeImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NWCWidgetBridgeImpl.swift; path = zeus/NWCWidgetBridgeImpl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -916,9 +912,7 @@ NWC1A2B3C4D5E6F700000003 /* NWCAudioKeepAlive.h */, NWC1A2B3C4D5E6F700000002 /* NWCAudioKeepAlive.m */, NWCLA000000000000000009 /* NWCActivityManager.swift */, - NWCLA000000000000000010 /* NWCWidgetBridgeImpl.swift */, NWCLA000000000000000007 /* NWCWidgetAttributes.swift */, - NWCLA000000000000000008 /* NWCWidgetBridge.swift */, NWCAUDIO0000000000000015 /* Fireplace.m4a */, NWCAUDIO0000000000000016 /* White Noise.m4a */, NWCAUDIO0000000000000017 /* Gentle Rain.m4a */, @@ -2070,9 +2064,7 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, NWC1A2B3C4D5E6F700000001 /* NWCAudioKeepAlive.m in Sources */, NWCLA000000000000000001 /* NWCWidgetAttributes.swift in Sources */, - NWCLA000000000000000002 /* NWCWidgetBridge.swift in Sources */, NWCLA000000000000000003 /* NWCActivityManager.swift in Sources */, - NWCLA000000000000000004 /* NWCWidgetBridgeImpl.swift in Sources */, 745388442A54C2B6000927CE /* lightning.pb.swift in Sources */, 74B637822A5A350B00750202 /* LncModule.mm in Sources */, 74B637842A5A350B00750202 /* Callback.mm in Sources */, diff --git a/ios/zeus/AppDelegate.mm b/ios/zeus/AppDelegate.mm index 40346039a..0946c7af3 100644 --- a/ios/zeus/AppDelegate.mm +++ b/ios/zeus/AppDelegate.mm @@ -50,8 +50,6 @@ static void ClearKeychainIfNecessary() { [RNNotifications startMonitorNotifications]; ClearKeychainIfNecessary(); - // Initialize NWCActivityManager early so the widget bridge is set up before - // any Live Activity intent fires (intents run in the host-app process). if (@available(iOS 16.1, *)) { Class managerClass = NSClassFromString(@"NWCActivityManager"); if (managerClass && [managerClass respondsToSelector:@selector(shared)]) { diff --git a/ios/zeus/NWCActivityManager.swift b/ios/zeus/NWCActivityManager.swift index f58437220..48d60e5e8 100644 --- a/ios/zeus/NWCActivityManager.swift +++ b/ios/zeus/NWCActivityManager.swift @@ -1,28 +1,18 @@ import Foundation import ActivityKit -/// Manages the NWC Live Activity (Dynamic Island + lock-screen banner). @available(iOS 16.1, *) @objc final class NWCActivityManager: NSObject { @objc static let shared = NWCActivityManager() - @objc var onNextTrack: (() -> Void)? - @objc var onPrevTrack: (() -> Void)? - @objc var onToggleMute: (() -> Void)? - @objc var onStop: (() -> Void)? - private var activity: Activity? override private init() { super.init() - NWCBridge = NWCWidgetBridgeImpl(manager: self) - // Synchronous cleanup so a force-quit island is gone on next cold start. endAllActivitiesBlocking() } - // ─── ObjC entry points ──────────────────────────────────────────────────── - @objc func startActivity(trackName: String, isMuted: Bool) { DispatchQueue.main.async { [weak self] in self?.startActivityOnMain(trackName: trackName, isMuted: isMuted) @@ -35,21 +25,16 @@ import ActivityKit } } - /// Normal stop (async). Ends every NWC activity, not only the cached reference. @objc func stopActivity() { DispatchQueue.main.async { [weak self] in self?.stopActivityOnMain() } } - /// Ends all NWC Live Activities and blocks until ActivityKit completes. - /// Required on force-quit: async `stopActivity` often never runs before iOS kills the process. @objc func endAllActivitiesImmediately() { endAllActivitiesBlocking() } - // ─── Main-queue implementation ──────────────────────────────────────────── - private var staleDate: Date { Date().addingTimeInterval(300) } private func startActivityOnMain(trackName: String, isMuted: Bool) { @@ -60,7 +45,6 @@ import ActivityKit } if let existing = activity, Self.isLive(existing) { - NSLog("[NWCActivity] activity already live, updating instead") updateActivityOnMain(trackName: trackName, isMuted: isMuted) return } @@ -114,8 +98,6 @@ import ActivityKit } } - // ─── Blocking end (force-quit + cold-start cleanup) ─────────────────────── - private func endAllActivitiesBlocking() { let sem = DispatchSemaphore(value: 0) Task.detached(priority: .userInitiated) { @@ -141,12 +123,9 @@ import ActivityKit } else { await act.end(using: finalState, dismissalPolicy: .immediate) } - NSLog("[NWCActivity] ended activity id=%@", act.id) } } - // ─── Helpers ────────────────────────────────────────────────────────────── - private static func isLive(_ act: Activity) -> Bool { if act.activityState == .ended { return false } if #available(iOS 16.2, *) { diff --git a/ios/zeus/NWCAudioKeepAlive.m b/ios/zeus/NWCAudioKeepAlive.m index c21e582db..a6b79dff6 100644 --- a/ios/zeus/NWCAudioKeepAlive.m +++ b/ios/zeus/NWCAudioKeepAlive.m @@ -4,7 +4,6 @@ #import #import -// ─── Event name constants ──────────────────────────────────────────────────── static NSString *const kEventInterrupted = @"NWCAudioInterrupted"; static NSString *const kEventInterruptionEnded = @"NWCAudioInterruptionEnded"; static NSString *const kEventRouteChanged = @"NWCAudioRouteChanged"; @@ -12,18 +11,14 @@ static NSString *const kEventStatusUpdate = @"NWCAudioStatusUpdate"; static NSString *const kEventSuspended = @"NWCAudioSuspended"; static NSString *const kEventTrackChanged = @"NWCAudioTrackChanged"; -// How often (seconds) to emit a heartbeat status event while active static const NSTimeInterval kStatusIntervalSeconds = 30.0; -// Darwin notification names shared with NWCWidgetIntent.swift (extension IPC). -// LiveActivityIntent.perform() runs in the widget extension process; posting a -// Darwin notification is the only way to reach the background-audio main app. +// Must match NWCWidgetIntent.swift static NSString *const kDarwinNextTrack = @"com.zeusln.zeus.nwc.nextTrack"; static NSString *const kDarwinPrevTrack = @"com.zeusln.zeus.nwc.prevTrack"; static NSString *const kDarwinToggleMute = @"com.zeusln.zeus.nwc.toggleMute"; static NSString *const kDarwinStop = @"com.zeusln.zeus.nwc.stop"; -// Available ambient audio tracks bundled with the app static NSArray *kAvailableTracks(void) { return @[@"Fireplace", @"White Noise", @"Gentle Rain"]; } @@ -32,27 +27,21 @@ static NSArray *kAvailableTracks(void) { @property (nonatomic, strong) AVAudioPlayer *audioPlayer; -// Track management @property (nonatomic, copy) NSArray *trackNames; @property (nonatomic, assign) NSInteger currentTrackIndex; @property (nonatomic, assign) BOOL isMuted; -// Monitoring @property (nonatomic, strong) NSDate *sessionStartTime; @property (nonatomic, strong) NSDate *backgroundEnteredTime; @property (nonatomic, strong) NSTimer *statusTimer; @property (nonatomic, assign) BOOL isActive; @property (nonatomic, assign) BOOL hasListeners; -// Stats @property (nonatomic, assign) NSUInteger disconnectCount; @property (nonatomic, strong) NSString *lastDisconnectReason; @property (nonatomic, strong) NSString *iosVersion; @property (nonatomic, strong) NSString *deviceModel; -// Live Activity early-start support. -// Set YES (while app is in foreground) so that the UIApplicationWillResignActiveNotification -// handler can call Activity.request() before the app state becomes .background. @property (nonatomic, assign) BOOL nwcArmed; - (void)switchToTrackAtIndex:(NSInteger)index; @@ -63,8 +52,7 @@ static NSArray *kAvailableTracks(void) { @end -// Darwin notification C callback — must live after the private @interface above -// so the compiler can see properties and methods used inside the block. +// After private @interface (nwcDarwinCallback uses instance members). static void nwcDarwinCallback(CFNotificationCenterRef center, void *observer, CFNotificationName name, const void *object, CFDictionaryRef userInfo) { NWCAudioKeepAlive *self = (__bridge NWCAudioKeepAlive *)observer; @@ -105,7 +93,6 @@ static void nwcDarwinCallback(CFNotificationCenterRef center, void *observer, RCT_EXPORT_MODULE(); -// ─── RCTEventEmitter ───────────────────────────────────────────────────────── + (BOOL)requiresMainQueueSetup { return NO; @@ -130,7 +117,6 @@ RCT_EXPORT_MODULE(); self.hasListeners = NO; } -// ─── Lifecycle ─────────────────────────────────────────────────────────────── - (instancetype)init { if (self = [super init]) { @@ -159,12 +145,7 @@ RCT_EXPORT_MODULE(); [self unregisterDarwinObservers]; } -// ─── Public API ────────────────────────────────────────────────────────────── -/** - * Starts the AVAudioSession (.playback + mixWithOthers) and begins looping - * the currently selected ambient track. Returns a status dict on resolve. - */ RCT_EXPORT_METHOD(startAudioKeepAlive:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { if (self.isActive) { @@ -220,8 +201,6 @@ RCT_EXPORT_METHOD(startAudioKeepAlive:(RCTPromiseResolveBlock)resolve }]; }); - // 5. Wire up widget button callbacks and start the Live Activity - [self registerWidgetCallbacks]; if (@available(iOS 16.1, *)) { NSString *trackName = self.trackNames[self.currentTrackIndex]; [[NWCActivityManager shared] startActivityWithTrackName:trackName @@ -233,26 +212,17 @@ RCT_EXPORT_METHOD(startAudioKeepAlive:(RCTPromiseResolveBlock)resolve resolve([self currentStatusDict]); } -/** - * Stops playback and deactivates the AVAudioSession. - */ RCT_EXPORT_METHOD(stopAudioKeepAlive:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { [self stopInternal:@"manual_stop"]; resolve([self currentStatusDict]); } -/** - * Returns the current status without modifying state. - */ RCT_EXPORT_METHOD(getStatus:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { resolve([self currentStatusDict]); } -/** - * Returns the list of available ambient audio tracks. - */ RCT_EXPORT_METHOD(getAvailableTracks:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSMutableArray *result = [NSMutableArray array]; @@ -266,9 +236,6 @@ RCT_EXPORT_METHOD(getAvailableTracks:(RCTPromiseResolveBlock)resolve resolve(result); } -/** - * Selects a track by index and immediately switches to it if active. - */ RCT_EXPORT_METHOD(setTrack:(NSInteger)index resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -287,18 +254,12 @@ RCT_EXPORT_METHOD(setTrack:(NSInteger)index resolve([self currentStatusDict]); } -/** - * Advances to the next track (wraps around). - */ RCT_EXPORT_METHOD(nextTrack:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSInteger next = (self.currentTrackIndex + 1) % (NSInteger)self.trackNames.count; [self setTrack:next resolver:resolve rejecter:reject]; } -/** - * Goes back to the previous track (wraps around). - */ RCT_EXPORT_METHOD(previousTrack:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { NSInteger prev = (self.currentTrackIndex - 1 + (NSInteger)self.trackNames.count) @@ -306,9 +267,6 @@ RCT_EXPORT_METHOD(previousTrack:(RCTPromiseResolveBlock)resolve [self setTrack:prev resolver:resolve rejecter:reject]; } -/** - * Mutes or unmutes the audio track. The session stays alive; only volume is 0. - */ RCT_EXPORT_METHOD(setMuted:(BOOL)muted resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -316,31 +274,21 @@ RCT_EXPORT_METHOD(setMuted:(BOOL)muted resolve([self currentStatusDict]); } -/** - * Called from JS while the app is still in the FOREGROUND (when the NWC service - * becomes active and the AppState monitor is registered). Sets a flag so that - * handleWillResignActive: can call Activity.request() immediately – the only - * window where ActivityKit accepts new activities (UIApplication.applicationState - * must be .active, which it is during UIApplicationWillResignActiveNotification). - */ RCT_EXPORT_METHOD(armNWCAudio:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_main_queue(), ^{ self.nwcArmed = YES; NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - // Remove stale observers before re-registering to avoid duplicates. [nc removeObserver:self name:UIApplicationWillResignActiveNotification object:nil]; [nc removeObserver:self name:UIApplicationWillTerminateNotification object:nil]; [nc addObserver:self selector:@selector(handleWillResignActive:) name:UIApplicationWillResignActiveNotification object:nil]; - // Stop the Live Activity if the process is force-quit by the user. [nc addObserver:self selector:@selector(handleWillTerminate:) name:UIApplicationWillTerminateNotification object:nil]; - // Always unregister first to prevent duplicate callbacks if armNWCAudio // is called more than once during a session. [self unregisterDarwinObservers]; [self registerDarwinObservers]; @@ -349,9 +297,6 @@ RCT_EXPORT_METHOD(armNWCAudio:(RCTPromiseResolveBlock)resolve resolve(@(YES)); } -/** - * Called from JS when the NWC service stops or is torn down. - */ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { dispatch_async(dispatch_get_main_queue(), ^{ @@ -368,10 +313,7 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve resolve(@(YES)); } -// ─── Internal track / mute helpers ─────────────────────────────────────────── -/// Switches to the given track index, starts it if the session is active, -/// and updates the Live Activity state. - (void)switchToTrackAtIndex:(NSInteger)index { self.currentTrackIndex = index; @@ -387,7 +329,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } } -/// Applies a mute/unmute change and propagates to the Live Activity. - (void)applyMuted:(BOOL)muted { self.isMuted = muted; self.audioPlayer.volume = muted ? 0.0f : 1.0f; @@ -400,62 +341,7 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } } -// ─── Widget callback registration ──────────────────────────────────────────── -/// Registers callback blocks on NWCActivityManager so widget button taps -/// (next/prev/mute/stop) route back into this instance. -- (void)registerWidgetCallbacks { - if (@available(iOS 16.1, *)) { - __weak typeof(self) weakSelf = self; - - [NWCActivityManager shared].onNextTrack = ^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return; - NSInteger next = (strongSelf.currentTrackIndex + 1) - % (NSInteger)strongSelf.trackNames.count; - [strongSelf switchToTrackAtIndex:next]; - [strongSelf safeEmit:kEventTrackChanged body:@{ - @"trackIndex": @(next), - @"trackName": strongSelf.trackNames[next], - @"source": @"widget" - }]; - }; - - [NWCActivityManager shared].onPrevTrack = ^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return; - NSInteger count = (NSInteger)strongSelf.trackNames.count; - NSInteger prev = (strongSelf.currentTrackIndex - 1 + count) % count; - [strongSelf switchToTrackAtIndex:prev]; - [strongSelf safeEmit:kEventTrackChanged body:@{ - @"trackIndex": @(prev), - @"trackName": strongSelf.trackNames[prev], - @"source": @"widget" - }]; - }; - - [NWCActivityManager shared].onToggleMute = ^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return; - [strongSelf applyMuted:!strongSelf.isMuted]; - }; - - [NWCActivityManager shared].onStop = ^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return; - [strongSelf stopInternal:@"widget_stop"]; - // Emit so JS can react (close NWC connection, etc.) - [strongSelf safeEmit:kEventSuspended body:@{ - @"reason": @"widget_stop", - @"uptimeSeconds": @([strongSelf uptimeSeconds]) - }]; - }; - } -} - -// ─── Audio player ──────────────────────────────────────────────────────────── - -/// Resolves a bundled ambient track by name (tries common audio extensions). - (NSString *)pathForBundledTrackNamed:(NSString *)trackName { static NSArray *extensions; static dispatch_once_t once; @@ -527,7 +413,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve self.audioPlayer = nil; } -// ─── AVAudioPlayerDelegate ─────────────────────────────────────────────────── - (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error { NSLog(@"[NWCAudio] Decode error: %@", error.localizedDescription); @@ -547,7 +432,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } } -// ─── Internal stop ─────────────────────────────────────────────────────────── - (void)stopInternal:(NSString *)reason { if (!self.isActive) return; @@ -563,9 +447,7 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve [self teardownAudioPlayer]; [self unregisterNotifications]; - // unregisterNotifications uses removeObserver:self which wipes ALL observers - // registered by armNWCAudio. Re-register the two lifetime observers so the - // next background/terminate events are still handled correctly. + // removeObserver:self clears arm observers; re-register if still armed. if (self.nwcArmed) { NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self @@ -596,7 +478,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve NSLog(@"[NWCAudio] Stopped after %.0f s – reason: %@", uptime, reason); } -// ─── AVAudioSession notifications ──────────────────────────────────────────── - (void)registerNotifications { NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; @@ -628,12 +509,9 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } - (void)unregisterNotifications { - // Only removes NSNotificationCenter observers. - // Darwin observers (widget IPC) are managed separately by arm/disarm. [[NSNotificationCenter defaultCenter] removeObserver:self]; } -// ─── Darwin IPC helpers (widget extension → main app) ──────────────────────── - (void)registerDarwinObservers { CFNotificationCenterRef darwin = CFNotificationCenterGetDarwinNotifyCenter(); @@ -660,18 +538,7 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve NSLog(@"[NWCAudio] Darwin observers removed"); } -// ─── Notification handlers ─────────────────────────────────────────────────── -/** - * Fires while UIApplication.applicationState is still .active (the only state - * in which ActivityKit accepts Activity.request()). If the NWC service is armed - * and audio is not yet playing, we start the Live Activity right here so it is - * already visible the instant the app moves to background. - * - * When startAudioKeepAlive is later called (from the 'background' AppState - * event on the JS side), NWCActivityManager detects the existing activity and - * updates its state instead of requesting a new one. - */ - (void)handleWillResignActive:(NSNotification *)notification { if (!self.nwcArmed) return; if (@available(iOS 16.1, *)) { @@ -682,11 +549,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } } -/** - * Called when the process is about to be terminated (user force-quits via the - * app-switcher, or the OS terminates a suspended background app). - * Ends the Live Activity immediately so the Dynamic Island disappears. - */ - (void)handleWillTerminate:(NSNotification *)notification { NSLog(@"[NWCAudio] App terminating – ending Live Activity (blocking)"); if (@available(iOS 16.1, *)) { @@ -789,7 +651,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve [self emitStatusUpdate:NO reason:@"returned_to_foreground"]; } -// ─── Audio player resume ───────────────────────────────────────────────────── - (void)resumeAudioPlayer { if (self.audioPlayer.isPlaying) return; @@ -816,7 +677,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve } } -// ─── Event helpers ─────────────────────────────────────────────────────────── - (void)emitStatusUpdate:(BOOL)isSuspected reason:(NSString *)reason { if (!self.hasListeners) return; @@ -833,7 +693,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve [self sendEventWithName:eventName body:body]; } -// ─── Status dict ───────────────────────────────────────────────────────────── - (NSDictionary *)currentStatusDict { NSTimeInterval uptime = [self uptimeSeconds]; @@ -858,7 +717,6 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve }; } -// ─── Utilities ─────────────────────────────────────────────────────────────── - (NSTimeInterval)uptimeSeconds { return self.sessionStartTime diff --git a/ios/zeus/NWCWidgetBridgeImpl.swift b/ios/zeus/NWCWidgetBridgeImpl.swift deleted file mode 100644 index 517276452..000000000 --- a/ios/zeus/NWCWidgetBridgeImpl.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// Real bridge implementation compiled into the main zeus app target only. -/// Routes Live Activity button taps back to NWCActivityManager's callback blocks -/// (which NWCAudioKeepAlive.m registers at session start). -@available(iOS 16.1, *) -final class NWCWidgetBridgeImpl: NWCWidgetBridgeProtocol { - - private weak var manager: NWCActivityManager? - - init(manager: NWCActivityManager) { - self.manager = manager - } - - func nextTrack() { - DispatchQueue.main.async { [weak self] in - self?.manager?.onNextTrack?() - } - } - - func prevTrack() { - DispatchQueue.main.async { [weak self] in - self?.manager?.onPrevTrack?() - } - } - - func toggleMute() { - DispatchQueue.main.async { [weak self] in - self?.manager?.onToggleMute?() - } - } - - func stopNWC() { - DispatchQueue.main.async { [weak self] in - self?.manager?.onStop?() - } - } -} diff --git a/stores/NostrWalletConnectStore.ts b/stores/NostrWalletConnectStore.ts index 4c6b52c6e..bc4bff3c9 100644 --- a/stores/NostrWalletConnectStore.ts +++ b/stores/NostrWalletConnectStore.ts @@ -2706,13 +2706,6 @@ export default class NostrWalletConnectStore { console.warn('Android: Reconnection check error:', error); } } - // ─── iOS audio keep-alive (AVAudioSession + silent loop) ───────────────── - - /** - * iOS-only: registers AppState listener when has `At least one active connection` - * is on (same setting as Android’s foreground service). Starts silent audio - * in background and re-subscribes relays when returning to foreground. - */ private setupIOSAppStateMonitoring(): void { if (Platform.OS !== 'ios') return; // Remove any stale listener before registering a new one @@ -2722,10 +2715,6 @@ export default class NostrWalletConnectStore { if (AppState.currentState === 'background') { this.startIOSAudioKeepAlive(); } else { - // App is in the foreground: arm the native side so that the - // UIApplicationWillResignActiveNotification handler (which fires while - // UIApplication.applicationState is still .active) can call - // Activity.request() – the only window ActivityKit allows it. IOSAudioKeepAliveUtils.arm(); } @@ -2749,7 +2738,6 @@ export default class NostrWalletConnectStore { nextState === 'active' && previousState === 'background' ) { - // App returning to foreground — stop audio, re-subscribe relay console.log( '[NWCAudio] App returning to foreground – stopping keep-alive' ); @@ -2771,28 +2759,15 @@ export default class NostrWalletConnectStore { this.iosAudioAppStateListener.remove(); this.iosAudioAppStateListener = null; } - // Disarm the native Live Activity pre-start hook. IOSAudioKeepAliveUtils.disarm(); } - /** - * iOS: when the first NWC connection is created after a cold start that had - * none, `initializeService` bailed out early and never registered AppState / - * audio keep-alive. Call this after a connection is live so background works - * without restarting the app. - */ private ensureIOSNWCBackgroundMonitoring(): void { if (Platform.OS !== 'ios') return; if (this.activeConnections.length === 0) return; if (!this.isServiceReady()) return; this.setupIOSAppStateMonitoring(); } - /** - * Starts a silent AVAudioSession (.playback) backed by an AVAudioEngine - * loop. While active, iOS treats the app as a foreground-like audio - * process and avoids suspending it, allowing the Nostr WebSocket relay - * subscriptions to remain live in the background. - */ @action public async startIOSAudioKeepAlive(): Promise { if (Platform.OS !== 'ios') return false; @@ -2882,8 +2857,6 @@ export default class NostrWalletConnectStore { `[NWCAudio] Suspected suspension – reason: ${payload.reason}, ` + `uptime: ${payload.uptimeSeconds.toFixed(0)}s` ); - // Re-subscribe once we can – the next foreground transition - // will also trigger initializeService via AppState change. } ); diff --git a/utils/IOSAudioKeepAliveUtils.ts b/utils/IOSAudioKeepAliveUtils.ts index 7c143fb51..eb600a256 100644 --- a/utils/IOSAudioKeepAliveUtils.ts +++ b/utils/IOSAudioKeepAliveUtils.ts @@ -13,9 +13,7 @@ export interface AudioKeepAliveStatus { currentTrackIndex: number; currentTrackName: string; availableTracks: string[]; - /** Seconds the audio session has been alive */ uptimeSeconds: number; - /** Seconds spent in background since last backgrounding */ backgroundDuration: number; disconnectCount: number; lastDisconnectReason: string; @@ -68,9 +66,7 @@ interface NWCAudioKeepAliveModule { nextTrack(): Promise; previousTrack(): Promise; setMuted(muted: boolean): Promise; - /** Call while app is in foreground so the Live Activity can start before backgrounding. */ armNWCAudio(): Promise; - /** Call when NWC service is stopped/torn down. */ disarmNWCAudio(): Promise; addListener(eventType: string): void; removeListeners(count: number): void; @@ -205,12 +201,6 @@ class IOSAudioKeepAliveUtils { } } - /** - * Call while the app is still in the foreground (when NWC becomes active). - * Arms the native side so that UIApplicationWillResignActiveNotification - * can start the Live Activity before the app fully enters the background – - * the only window ActivityKit accepts Activity.request(). - */ async arm(): Promise { const mod = this.getModule(); if (!mod) return; @@ -221,7 +211,6 @@ class IOSAudioKeepAliveUtils { } } - /** Call when the NWC service is stopped or torn down. */ async disarm(): Promise { const mod = this.getModule(); if (!mod) return;