Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import ComposableArchitecture
import Foundation
import SupacodeSettingsShared

private nonisolated let customNotificationSoundLogger = SupaLogger("Settings")

extension SettingsFeature {
/// Custom-sound file lifecycle kept separate from the general settings switch.
static var customNotificationSoundReducer: some Reducer<State, Action> {
@Dependency(AnalyticsClient.self) var analyticsClient
@Dependency(CustomNotificationSoundClient.self) var customNotificationSoundClient
@Dependency(NotificationSoundClient.self) var notificationSoundClient

return Reduce { state, action in
switch action {
case .customNotificationSoundSelected(let url):
guard !state.isManagingCustomNotificationSound else { return .none }
state.isManagingCustomNotificationSound = true
return .run { send in
do {
let sound = try await customNotificationSoundClient.importSound(url)
await send(.customNotificationSoundImported(.success(sound)))
} catch {
await send(.customNotificationSoundImported(.failure(error)))
}
}

case .customNotificationSoundImportFailed(let message):
state.alert = AlertState {
TextState("Unable to Import Sound")
} actions: {
ButtonState(role: .cancel, action: .dismiss) {
TextState("OK")
}
} message: {
TextState(message)
}
return .none

case .customNotificationSoundImported(.success(let customSound)):
state.isManagingCustomNotificationSound = false
let replacedSound = state.customNotificationSound
state.customNotificationSound = customSound
state.notificationSound = .custom
let configuration = state.notificationSoundConfiguration
var effects: [Effect<Action>] = [
Self.persist(state.globalSettings, analyticsClient: analyticsClient),
.run { _ in await notificationSoundClient.play(configuration) },
]
if let replacedSound, replacedSound != customSound {
effects.append(
.run { _ in
do {
try await customNotificationSoundClient.removeSound(replacedSound)
} catch {
customNotificationSoundLogger.warning(
"Could not remove the previous custom notification sound: \(error.localizedDescription)"
)
}
}
)
}
return .merge(effects)

case .customNotificationSoundImported(.failure(let error)):
state.isManagingCustomNotificationSound = false
return .send(.customNotificationSoundImportFailed(error.localizedDescription))

case .removeCustomNotificationSoundTapped:
guard !state.isManagingCustomNotificationSound else { return .none }
guard let customSound = state.customNotificationSound else { return .none }
state.alert = AlertState {
TextState("Remove \"\(customSound.displayName)\"?")
} actions: {
ButtonState(role: .destructive, action: .confirmRemoveCustomNotificationSound) {
TextState("Remove")
}
ButtonState(role: .cancel, action: .dismiss) {
TextState("Cancel")
}
} message: {
TextState("Supacode's managed copy of this sound will be deleted.")
}
return .none

case .alert(.presented(.confirmRemoveCustomNotificationSound)):
state.alert = nil
guard let customSound = state.customNotificationSound else { return .none }
state.isManagingCustomNotificationSound = true
var settingsAfterRemoval = state.globalSettings
settingsAfterRemoval.customNotificationSound = nil
if settingsAfterRemoval.notificationSound == .custom {
settingsAfterRemoval.notificationSound = GlobalSettings.default.notificationSound
}
return .merge(
Self.persist(settingsAfterRemoval, analyticsClient: analyticsClient),
.run { send in
do {
try await customNotificationSoundClient.removeSound(customSound)
await send(.customNotificationSoundRemoved(.success(())))
} catch {
await send(.customNotificationSoundRemoved(.failure(error)))
}
}
)

case .customNotificationSoundRemoved(.success):
state.isManagingCustomNotificationSound = false
state.customNotificationSound = nil
if state.notificationSound == .custom {
state.notificationSound = GlobalSettings.default.notificationSound
}
return Self.persist(state.globalSettings, analyticsClient: analyticsClient)

case .customNotificationSoundRemoved(.failure(let error)):
state.isManagingCustomNotificationSound = false
state.alert = AlertState {
TextState("Unable to Remove Sound")
} actions: {
ButtonState(role: .cancel, action: .dismiss) {
TextState("OK")
}
} message: {
TextState(error.localizedDescription)
}
return Self.persist(state.globalSettings, analyticsClient: analyticsClient)

default:
return .none
}
}
}
}
53 changes: 44 additions & 9 deletions SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public struct SettingsFeature {
public var updatesAutomaticallyDownloadUpdates: Bool
public var inAppNotificationsEnabled: Bool
public var notificationSound: NotificationSound
public var customNotificationSound: CustomNotificationSound?
public var systemNotificationsEnabled: Bool
public var muteNotificationsForActiveSurface: Bool
public var moveNotifiedWorktreeToTop: Bool
Expand Down Expand Up @@ -83,6 +84,7 @@ public struct SettingsFeature {
public var remoteSessionPersistenceEnabled: Bool
public var appVisibility: AppVisibility
public var terminalHibernationEnabled: Bool
public var isManagingCustomNotificationSound = false
public var chromeTextSize: ChromeTextSize
public var automaticRepositoryRefreshEnabled: Bool
public var cliInstallState = CLIInstallState.checking
Expand Down Expand Up @@ -128,6 +130,13 @@ public struct SettingsFeature {
SkillAgent.allCasesByDisplayName.filter { (agentIntegrationStates[$0] ?? .checking).isInstallSheetCandidate }
}

public var notificationSoundConfiguration: NotificationSoundConfiguration {
NotificationSoundConfiguration(
sound: notificationSound,
customSound: customNotificationSound
)
}

public init(settings: GlobalSettings = .default) {
@Dependency(\.openActionAvailability) var openActionAvailability
installedOpenActions = openActionAvailability.installedActions()
Expand All @@ -138,6 +147,7 @@ public struct SettingsFeature {
updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates
inAppNotificationsEnabled = settings.inAppNotificationsEnabled
notificationSound = settings.notificationSound
customNotificationSound = settings.customNotificationSound
systemNotificationsEnabled = settings.systemNotificationsEnabled
muteNotificationsForActiveSurface = settings.muteNotificationsForActiveSurface
moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop
Expand Down Expand Up @@ -181,6 +191,7 @@ public struct SettingsFeature {
updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates,
inAppNotificationsEnabled: inAppNotificationsEnabled,
notificationSound: notificationSound,
customNotificationSound: customNotificationSound,
systemNotificationsEnabled: systemNotificationsEnabled,
muteNotificationsForActiveSurface: muteNotificationsForActiveSurface,
moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop,
Expand Down Expand Up @@ -224,6 +235,11 @@ public struct SettingsFeature {
case repositoriesChanged([SettingsRepositorySummary])
case setSelection(SettingsSection?)
case setSystemNotificationsEnabled(Bool)
case customNotificationSoundSelected(URL)
case customNotificationSoundImportFailed(String)
case customNotificationSoundImported(Result<CustomNotificationSound, Error>)
case removeCustomNotificationSoundTapped
case customNotificationSoundRemoved(Result<Void, Error>)
case setAppVisibility(AppVisibility)
case setAutomatedActionPolicy(AutomatedActionPolicy)
case showNotificationPermissionAlert(errorMessage: String?)
Expand Down Expand Up @@ -262,6 +278,7 @@ public struct SettingsFeature {
public enum Alert: Equatable {
case dismiss
case openSystemNotificationSettings
case confirmRemoveCustomNotificationSound
case confirmAutoDeleteDaysChange(AutoDeletePeriod)
case confirmRemoveGlobalScript(ScriptDefinition.ID)
}
Expand All @@ -283,6 +300,7 @@ public struct SettingsFeature {

public var body: some Reducer<State, Action> {
BindingReducer()
Self.customNotificationSoundReducer
Reduce { state, action in
switch action {
case .task:
Expand Down Expand Up @@ -331,7 +349,7 @@ public struct SettingsFeature {
} else {
var updatedSettings = settings
updatedSettings.defaultWorktreeBaseDirectoryPath = normalizedWorktreeBaseDirPath
normalizedSettings = persistGlobalSettings(updatedSettings)
normalizedSettings = Self.persistGlobalSettings(updatedSettings)
}
state.appearanceMode = normalizedSettings.appearanceMode
state.defaultEditorID = normalizedSettings.defaultEditorID
Expand All @@ -340,6 +358,7 @@ public struct SettingsFeature {
state.updatesAutomaticallyDownloadUpdates = normalizedSettings.updatesAutomaticallyDownloadUpdates
state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled
state.notificationSound = normalizedSettings.notificationSound
state.customNotificationSound = normalizedSettings.customNotificationSound
state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled
state.muteNotificationsForActiveSurface = normalizedSettings.muteNotificationsForActiveSurface
state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop
Expand Down Expand Up @@ -376,15 +395,17 @@ public struct SettingsFeature {
return .send(.delegate(.settingsChanged(normalizedSettings)))

case .binding(\.notificationSound):
let sound = state.notificationSound
// Preview the chosen sound, but only on the in-app path: with system
// notifications on, the banner plays the macOS default instead. `.never`
// has nothing to audition.
let shouldPreview = !state.systemNotificationsEnabled && sound != .never
let configuration = state.notificationSoundConfiguration
let shouldPreview =
state.notificationSound != .never
&& (!state.systemNotificationsEnabled
|| state.notificationSound.usesSelectedSoundForSystemNotifications)
state.syncGlobalDefaults(from: state.globalSettings)
return .merge(
persist(state),
shouldPreview ? .run { _ in await notificationSoundClient.play(sound) } : .none
shouldPreview
? .run { _ in await notificationSoundClient.play(configuration) }
: .none
)

case .binding:
Expand Down Expand Up @@ -786,6 +807,13 @@ public struct SettingsFeature {
case .repositorySettings:
return .none

case .customNotificationSoundSelected,
.customNotificationSoundImportFailed,
.customNotificationSoundImported,
.removeCustomNotificationSoundTapped,
.customNotificationSoundRemoved:
return .none

case .delegate:
return .none
}
Expand All @@ -796,15 +824,22 @@ public struct SettingsFeature {
}

private func persist(_ state: State) -> Effect<Action> {
let settings = persistGlobalSettings(state.globalSettings)
Self.persist(state.globalSettings, analyticsClient: analyticsClient)
}

static func persist(
_ settings: GlobalSettings,
analyticsClient: AnalyticsClient
) -> Effect<Action> {
let settings = persistGlobalSettings(settings)
if settings.analyticsEnabled {
analyticsClient.capture("settings_changed", nil)
}
return .send(.delegate(.settingsChanged(settings)))
}

@discardableResult
private func persistGlobalSettings(_ settings: GlobalSettings) -> GlobalSettings {
private static func persistGlobalSettings(_ settings: GlobalSettings) -> GlobalSettings {
@Shared(.settingsFile) var settingsFile
$settingsFile.withLock {
$0.global = settings
Expand Down
56 changes: 52 additions & 4 deletions SupacodeSettingsFeature/Views/NotificationsSettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import ComposableArchitecture
import Foundation
import SupacodeSettingsShared
import SwiftUI
import UniformTypeIdentifiers

public struct NotificationsSettingsView: View {
private static let supportedSoundTypes: [UTType] = [
.aiff,
.wav,
UTType(filenameExtension: "caf"),
].compactMap { $0 }

@Bindable var store: StoreOf<SettingsFeature>
@State private var isChoosingCustomSound = false

public init(store: StoreOf<SettingsFeature>) {
self.store = store
Expand All @@ -25,19 +34,45 @@ public struct NotificationsSettingsView: View {
}
Divider()
Text(NotificationSound.supacodeClassic.displayName).tag(NotificationSound.supacodeClassic)
if let customSound = store.customNotificationSound {
Divider()
Text(customSound.displayName).tag(NotificationSound.custom)
}
} label: {
Text("Play notification sound")
Text(
"Ignored when system notifications are enabled, as they play sounds"
+ " according to your settings."
"For system notifications, macOS sounds use the default banner sound. "
+ "Custom and Supacode Classic apply directly when System Settings allows sounds."
)
}
.disabled(store.systemNotificationsEnabled)
VStack(alignment: .leading) {
HStack {
Button(
store.isManagingCustomNotificationSound ? "Working..." : "Choose Custom Sound..."
) {
isChoosingCustomSound = true
}
.disabled(store.isManagingCustomNotificationSound)
.help("Import an AIFF, WAV, or CAF notification sound")
if store.customNotificationSound != nil {
Button("Remove Custom Sound", role: .destructive) {
store.send(.removeCustomNotificationSoundTapped)
}
.disabled(store.isManagingCustomNotificationSound)
.help("Delete Supacode's managed copy of the custom notification sound")
}
}
Text("AIFF, WAV, or CAF under 30 seconds (Linear PCM, IMA4, µLaw, or aLaw).")
.font(.footnote)
.foregroundStyle(.secondary)
}
Toggle(
isOn: $store.muteNotificationsForActiveSurface
) {
Text("Mute notifications for active surface")
Text("Skip the notification and sound when the terminal that sent it is focused and visible.")
Text(
"Skip the notification and sound when the terminal that sent it is focused and visible."
)
}
.disabled(!store.hasActiveNotificationChannel)
}
Expand Down Expand Up @@ -72,6 +107,19 @@ public struct NotificationsSettingsView: View {
.padding(.leading, -8)
.padding(.trailing, -6)
.navigationTitle("Notifications")
.fileImporter(
isPresented: $isChoosingCustomSound,
allowedContentTypes: Self.supportedSoundTypes
) { result in
switch result {
case .success(let url):
store.send(.customNotificationSoundSelected(url))
case .failure(let error) where (error as? CocoaError)?.code == .userCancelled:
break
case .failure(let error):
store.send(.customNotificationSoundImportFailed(error.localizedDescription))
}
}
}
}

Expand Down
Loading
Loading