diff --git a/Gemfile.lock b/Gemfile.lock index 12a8057..ad494d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1258.0) - aws-sdk-core (3.251.0) + aws-partitions (1.1261.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -20,7 +20,7 @@ GEM aws-sdk-kms (1.129.0) aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.225.0) + aws-sdk-s3 (1.226.0) aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -72,7 +72,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.1) - fastlane (2.236.0) + fastlane (2.236.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) @@ -141,14 +141,14 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-storage_v1 (0.63.0) google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.8.0) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.2.2) base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.6.0) - google-cloud-storage (1.60.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -158,7 +158,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.17.0) + googleauth (1.17.1) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -172,7 +172,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.8) + json (2.19.9) jwt (3.2.0) base64 logger (1.7.0) @@ -183,7 +183,7 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nkf (0.2.0) + nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) @@ -247,10 +247,10 @@ CHECKSUMS artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1258.0) sha256=3fee017570594ecf5e7f4c845b115fef8000530e57bdaf2b927357e5bf08967f - aws-sdk-core (3.251.0) sha256=ef8186cb5509147e590310da58fab4c5b0901eba0e85a72955abdf772e425c87 + aws-partitions (1.1261.0) sha256=89eda89781c1ea4e1be2a2dcdc72318c62c6171a01cdaeaa0cbecf3cea6f9fdc + aws-sdk-core (3.252.0) sha256=09c042cbfc2acf2239441cc9b982ebab2a999bed2ef6bdc51849e7b3d6e48a1c aws-sdk-kms (1.129.0) sha256=363f548df321f4a4fcfd05523384e591060b400f8e65133ed7ef0793155a3343 - aws-sdk-s3 (1.225.0) sha256=734752cc77e369c645ff5285b70ff5af207b0dcd3388346de2f727cd4e943924 + aws-sdk-s3 (1.226.0) sha256=e599f431e006ec9b92c61ee0f14d3f658a1f6c8a1d623d2160be927ac958e2bf aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b @@ -281,7 +281,7 @@ CHECKSUMS faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.236.0) sha256=a496b8235ebce61c6311ca4cc1fd274b599e497f65d3035d7cb409d5591150d8 + fastlane (2.236.1) sha256=fb89e618e0f38636e487743622cf710ad722723f5d33a63f19e367f84d3770bc fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 google-apis-androidpublisher_v3 (0.102.0) sha256=562415c44bd7f81cc808abbea11845ede16cdc3696d95f95efcf50aaffb159cf @@ -289,17 +289,17 @@ CHECKSUMS google-apis-iamcredentials_v1 (0.27.0) sha256=9289f29968610754ef11d98b9ec627f0153f3e2616fef839aef096de529f6d1e google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78 google-apis-storage_v1 (0.63.0) sha256=7063a6c19c40f5ef96d5894df33c4f9ac915e3107e632b1870ba5247e3bb633f - google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1 - google-cloud-storage (1.60.0) sha256=b21b752d37945d678a4533be5ef4303f15d33a964d8bc709c7c41c3600f650db + google-cloud-storage (1.61.0) sha256=a77f10f4a603289948b09e81c3e23d762a18733fd69d70a4c1399663fbd96002 google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b - googleauth (1.17.0) sha256=dbddcaa3b78469fa9392c0e784ab6d8f3f760a1e5f6c6dbbbaa44a612c4198b0 + googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.19.8) sha256=6354310fd76ef69b87d5bd1f38b40d730613baf90b6803d2d0a48f618d32dfaa + json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 @@ -309,7 +309,7 @@ CHECKSUMS mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 - nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 diff --git a/Localizable.xcstrings b/Localizable.xcstrings index dc5be65..a6ad51d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -67,6 +67,10 @@ "comment" : "A description of how to connect to the PcVolumeControl server automatically.", "isCommentAutoGenerated" : true }, + "Connection" : { + "comment" : "The header for the \"Connection\" section in the settings view.", + "isCommentAutoGenerated" : true + }, "Connection Error" : { }, @@ -187,6 +191,9 @@ }, "Opens a picker to choose the active output device" : { + }, + "Opens app settings" : { + }, "Opens the Windows server setup guide" : { "comment" : "A hint that appears when hovering over the button to", @@ -228,6 +235,10 @@ }, "Reconnect" : { + }, + "Reconnect to last server on launch" : { + "comment" : "A toggle that lets the user enable or disable automatic reconnection to the last used server when the app launches.", + "isCommentAutoGenerated" : true }, "Remove from Chat Balance" : { @@ -250,7 +261,8 @@ }, "Select Master Device" : { - + "comment" : "The title of a sheet that lets a user select their master device.", + "isCommentAutoGenerated" : true }, "Server disconnected" : { @@ -319,6 +331,10 @@ }, "We're open source. Report bugs, ask questions, or submit pull requests on GitHub!" : { + }, + "When enabled, the app connects to your most recently used server automatically when it opens." : { + "comment" : "A description of the \"Reconnect to last server on launch\" toggle.", + "isCommentAutoGenerated" : true }, "When enabled, your manually arranged session order will be preserved when you restart the app." : { diff --git a/PcVolumeControl.xcodeproj/project.pbxproj b/PcVolumeControl.xcodeproj/project.pbxproj index 8973218..c8045f9 100644 --- a/PcVolumeControl.xcodeproj/project.pbxproj +++ b/PcVolumeControl.xcodeproj/project.pbxproj @@ -585,7 +585,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = moronbros.PcVolumeControl; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -622,7 +622,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.1; PRODUCT_BUNDLE_IDENTIFIER = moronbros.PcVolumeControl; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PcVolumeControl/Info.plist b/PcVolumeControl/Info.plist index e011921..999bc25 100644 --- a/PcVolumeControl/Info.plist +++ b/PcVolumeControl/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0 + 2.1 CFBundleSignature ???? CFBundleURLTypes diff --git a/PcVolumeControl/repos/MDNSDiscoveryService.swift b/PcVolumeControl/repos/MDNSDiscoveryService.swift index 1f12d7c..7114f52 100644 --- a/PcVolumeControl/repos/MDNSDiscoveryService.swift +++ b/PcVolumeControl/repos/MDNSDiscoveryService.swift @@ -8,6 +8,12 @@ class MDNSDiscoveryService: ObservableObject { private var browser: NWBrowser? private let browserQueue = DispatchQueue(label: "mdns.browser.queue") + // Maps a Bonjour service instance name (e.g. "MYPC-pcvolumecontrol-3000") to the + // server resolved for it. A withdrawal (.removed) only gives us the service name, + // while discoveredServers is keyed by resolved IP+port, so this map is the only + // reliable link from a goodbye packet back to the row that must be removed. + private var resolvedByServiceName: [String: DiscoveredServer] = [:] + func startDiscovery() { guard browser == nil else { return } @@ -45,6 +51,7 @@ class MDNSDiscoveryService: ObservableObject { DispatchQueue.main.async { self.isDiscovering = false self.discoveredServers.removeAll() + self.resolvedByServiceName.removeAll() } } @@ -56,7 +63,7 @@ class MDNSDiscoveryService: ObservableObject { resolveService(result: result) } case .removed(let result): - removeServer(result: result) + handleRemoval(result: result) case .changed(old: _, new: let newResult, flags: _): resolveService(result: newResult) default: @@ -84,9 +91,7 @@ class MDNSDiscoveryService: ObservableObject { let server = DiscoveredServer(address: cleanedAddress, port: port.rawValue, computerName: computerName) DispatchQueue.main.async { - // Remove any existing server with the same address and add the new one - self?.discoveredServers.removeAll { $0.address == cleanedAddress } - self?.discoveredServers.append(server) + self?.recordResolvedServer(server, forServiceName: serviceName) } } connection.cancel() @@ -100,16 +105,31 @@ class MDNSDiscoveryService: ObservableObject { connection.start(queue: browserQueue) } - private func removeServer(result: NWBrowser.Result) { + // Records a freshly resolved service and publishes it. Must run on the main queue. + // Keyed by serviceName so a later withdrawal can find the exact server to remove. + func recordResolvedServer(_ server: DiscoveredServer, forServiceName serviceName: String) { + resolvedByServiceName[serviceName] = server + // Replace any existing entry for the same address, then add the new one. + discoveredServers.removeAll { $0.address == server.address } + discoveredServers.append(server) + } + + private func handleRemoval(result: NWBrowser.Result) { guard case let .service(name: serviceName, type: _, domain: _, interface: _) = result.endpoint else { return } - - DispatchQueue.main.async { - self.discoveredServers.removeAll { $0.id.contains(serviceName) } + DispatchQueue.main.async { [weak self] in + self?.removeServer(forServiceName: serviceName) } } + // Removes a withdrawn service (Bonjour goodbye / NWBrowser .removed). Must run on + // the main queue. No-op when the service was never resolved into a server. + func removeServer(forServiceName serviceName: String) { + guard let server = resolvedByServiceName.removeValue(forKey: serviceName) else { return } + discoveredServers.removeAll { $0 == server } + } + private func cleanIPAddress(_ rawAddress: String) -> String { // Remove interface identifier from IP address (e.g., "192.168.1.100%en0" -> "192.168.1.100") if let percentIndex = rawAddress.firstIndex(of: "%") { diff --git a/PcVolumeControl/viewmodels/MainViewModel.swift b/PcVolumeControl/viewmodels/MainViewModel.swift index 9d0c192..45ce870 100644 --- a/PcVolumeControl/viewmodels/MainViewModel.swift +++ b/PcVolumeControl/viewmodels/MainViewModel.swift @@ -97,7 +97,7 @@ final class MainViewModel: ObservableObject { } } - func connectToServer(address: String, port: UInt16) { + func connectToServer(address: String, port: UInt16, silent: Bool = false) { // Resolve a blank port (0) to the default so UI state and saved recents reflect the real port. let resolvedPort = port == 0 ? Self.defaultPort : port self.port = resolvedPort @@ -127,7 +127,10 @@ final class MainViewModel: ObservableObject { } catch { isLoading = false connectionStatus = .connected - self.error = error.localizedDescription + // Auto-connect on launch fails silently (no alert); manual connects surface the error. + if !silent { + self.error = error.localizedDescription + } currentRepo = nil fullState = nil } @@ -449,7 +452,16 @@ final class MainViewModel: ObservableObject { port = server.port connectToServer(address: address, port: port) } - + + /// Cold-launch auto-connect to the most recently used server. Fails silently + /// (no error alert): on failure the user simply lands on the connect screen. + func autoConnectToLastServer() { + guard let server = recentServers.first else { return } + address = server.address + port = server.port + connectToServer(address: server.address, port: server.port, silent: true) + } + func saveSuccessfulConnection() { let newServer = DiscoveredServer(address: address, port: port, computerName: nil) diff --git a/PcVolumeControl/views/AutoReconnectGate.swift b/PcVolumeControl/views/AutoReconnectGate.swift new file mode 100644 index 0000000..e8dd050 --- /dev/null +++ b/PcVolumeControl/views/AutoReconnectGate.swift @@ -0,0 +1,21 @@ +// +// AutoReconnectGate.swift +// PcVolumeControl +// +// Decides whether the app should auto-connect to the last-used server on launch. +// + +import Foundation + +enum AutoReconnectGate { + // Fastlane snapshot launches with --screenshotMode and needs deterministic + // screens, so auto-connect never fires there. + static func shouldAutoConnect( + isEnabled: Bool, + hasRecentServer: Bool, + arguments: [String] = ProcessInfo.processInfo.arguments + ) -> Bool { + if arguments.contains("--screenshotMode") { return false } + return isEnabled && hasRecentServer + } +} diff --git a/PcVolumeControl/views/MainView.swift b/PcVolumeControl/views/MainView.swift index bf585ae..5faac18 100644 --- a/PcVolumeControl/views/MainView.swift +++ b/PcVolumeControl/views/MainView.swift @@ -20,6 +20,9 @@ struct MainView: View { @AppStorage(UserDefaultsKeys.hasSeenIntroTour) private var hasSeenIntroTour = false @State private var showIntroTour = false @State private var showSetupGuide = false + @State private var showSettings = false + @AppStorage(UserDefaultsKeys.autoReconnectOnLaunch) private var autoReconnectOnLaunch = false + @State private var hasAttemptedAutoConnect = false init() { let local = ServersLocalDataSource() @@ -110,6 +113,24 @@ struct MainView: View { .accessibilityElement(children: .combine) } .accessibilityHint("Opens the Windows server setup guide") + + // Quiet, secondary entry point to app settings. Placed at the + // bottom of the screen so it stays unobtrusive on the connection + // screen rather than competing with the logo header up top. + Button { + showSettings = true + } label: { + HStack(spacing: 6) { + Image(systemName: "gearshape") + Text("Settings") + } + .font(.footnote) + .foregroundStyle(.white.opacity(0.7)) + .padding(.bottom, 12) + .accessibilityElement(children: .combine) + } + .accessibilityLabel("Settings") + .accessibilityHint("Opens app settings") } .readableContentWidth() } @@ -134,6 +155,9 @@ struct MainView: View { } } } + .sheet(isPresented: $showSettings) { + SettingsView() + } .fullScreenCover(isPresented: $showIntroTour) { IntroTourView { hasSeenIntroTour = true @@ -186,6 +210,17 @@ struct MainView: View { #endif viewModel.loadRecentServers() mdnsService.startDiscovery() + + // Cold-launch auto-connect. The one-shot flag persists for the app + // process, so returning to this screen (e.g. after a manual + // disconnect) does NOT re-trigger a connection. + if !hasAttemptedAutoConnect, + AutoReconnectGate.shouldAutoConnect( + isEnabled: autoReconnectOnLaunch, + hasRecentServer: !viewModel.recentServers.isEmpty) { + hasAttemptedAutoConnect = true + viewModel.autoConnectToLastServer() + } } .onDisappear { mdnsService.stopDiscovery() diff --git a/PcVolumeControl/views/SettingsView.swift b/PcVolumeControl/views/SettingsView.swift index bc61ff2..1e99d37 100644 --- a/PcVolumeControl/views/SettingsView.swift +++ b/PcVolumeControl/views/SettingsView.swift @@ -1,3 +1,4 @@ +// periphery:ignore // // SettingsView.swift // PcVolumeControl @@ -11,16 +12,27 @@ struct UserDefaultsKeys { static let preserveSessionOrder = "preserveSessionOrder" static let hasSeenChatBalanceInfo = "hasSeenChatBalanceInfo" static let hasSeenIntroTour = "hasSeenIntroTour" + static let autoReconnectOnLaunch = "autoReconnectOnLaunch" } struct SettingsView: View { @Environment(\.dismiss) var dismiss @AppStorage(UserDefaultsKeys.preserveSessionOrder) private var preserveSessionOrder = true + @AppStorage(UserDefaultsKeys.autoReconnectOnLaunch) private var autoReconnectOnLaunch = false @State private var showIntroTour = false var body: some View { NavigationStack { Form { + Section(header: Text("Connection")) { + Toggle("Reconnect to last server on launch", isOn: $autoReconnectOnLaunch) + .tint(.sliderPink) + + Text("When enabled, the app connects to your most recently used server automatically when it opens.") + .font(.caption) + .foregroundColor(.gray) + } + Section(header: Text("Session Preferences")) { Toggle("Preserve Session Order", isOn: $preserveSessionOrder) .tint(.sliderPink) diff --git a/PcVolumeControl/views/TopCell.swift b/PcVolumeControl/views/TopCell.swift index 343fedf..d3ff83e 100644 --- a/PcVolumeControl/views/TopCell.swift +++ b/PcVolumeControl/views/TopCell.swift @@ -210,11 +210,11 @@ struct TopCell: View { } } } - + // Dialog state for editing master device alias @State private var isEditingAlias = false @State private var newAlias = "" - + private func showAliasEditingPrompt() { // Initialize with current alias if exists if let device = internalDefault { @@ -294,13 +294,13 @@ private struct DevicePickerSheet: View { name: "External Speakers", sessions: [] ) - + @Previewable @State var mockDevices = [ "speaker-12345": "External Speakers", "headphones-67890": "Headphones (Bluetooth)", "monitor-11111": "Monitor Audio (HDMI)" ] - + TopCell( internalDefault: $mockDevice, internalDevices: $mockDevices, @@ -324,13 +324,13 @@ private struct DevicePickerSheet: View { name: "Speakers (Realtek High Definition Audio)", sessions: [] ) - + @Previewable @State var mockDevices = [ "speaker-12345": "Speakers (Realtek High Definition Audio)", "headphones-67890": "Headphones (Bluetooth)", "monitor-11111": "Monitor Audio (HDMI)" ] - + TopCell( internalDefault: $mockDevice, internalDevices: $mockDevices, diff --git a/PcVolumeControlTests/AutoReconnectGateTests.swift b/PcVolumeControlTests/AutoReconnectGateTests.swift new file mode 100644 index 0000000..53092c8 --- /dev/null +++ b/PcVolumeControlTests/AutoReconnectGateTests.swift @@ -0,0 +1,37 @@ +// +// AutoReconnectGateTests.swift +// PcVolumeControlTests +// + +import XCTest +@testable import PcVolumeControl + +final class AutoReconnectGateTests: XCTestCase { + + func testAutoConnectsWhenEnabledWithRecentServer() { + XCTAssertTrue(AutoReconnectGate.shouldAutoConnect( + isEnabled: true, hasRecentServer: true, arguments: [])) + } + + func testDoesNotAutoConnectWhenDisabled() { + XCTAssertFalse(AutoReconnectGate.shouldAutoConnect( + isEnabled: false, hasRecentServer: true, arguments: [])) + } + + func testDoesNotAutoConnectWithoutRecentServer() { + XCTAssertFalse(AutoReconnectGate.shouldAutoConnect( + isEnabled: true, hasRecentServer: false, arguments: [])) + } + + func testScreenshotModeSuppressesAutoConnect() { + XCTAssertFalse(AutoReconnectGate.shouldAutoConnect( + isEnabled: true, hasRecentServer: true, arguments: ["--screenshotMode"])) + } + + func testScreenshotModeWithOtherArgumentsStillSuppresses() { + XCTAssertFalse(AutoReconnectGate.shouldAutoConnect( + isEnabled: true, + hasRecentServer: true, + arguments: ["--screenshotMode", "--mockConnected"])) + } +} diff --git a/PcVolumeControlTests/Info.plist b/PcVolumeControlTests/Info.plist index 2d98bea..b2c3ae1 100644 --- a/PcVolumeControlTests/Info.plist +++ b/PcVolumeControlTests/Info.plist @@ -15,8 +15,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.0 + 2.1 CFBundleVersion 1 - \ No newline at end of file + diff --git a/PcVolumeControlTests/MDNSDiscoveryServiceTests.swift b/PcVolumeControlTests/MDNSDiscoveryServiceTests.swift new file mode 100644 index 0000000..c8f0d5d --- /dev/null +++ b/PcVolumeControlTests/MDNSDiscoveryServiceTests.swift @@ -0,0 +1,43 @@ +// +// MDNSDiscoveryServiceTests.swift +// PcVolumeControlTests +// +// Created by Bill Booth on 6/19/26. +// + +import XCTest +@testable import PcVolumeControl + +@MainActor +final class MDNSDiscoveryServiceTests: XCTestCase { + + // A server that withdraws from mDNS (Bonjour goodbye / NWBrowser .removed) + // must be taken off the discovered list. Regression guard for the old bug + // where removal matched the service name against the IP+port id and so + // never removed anything, leaving the green "Auto-discovered" indicator up. + func testWithdrawalRemovesResolvedDiscoveredServer() { + let service = MDNSDiscoveryService() + let serviceName = "CHOSS-pcvolumecontrol-3000" + let server = DiscoveredServer(address: "192.168.0.42", port: 3000, computerName: "CHOSS") + + service.recordResolvedServer(server, forServiceName: serviceName) + XCTAssertEqual(service.discoveredServers.count, 1) + XCTAssertEqual(service.discoveredServers.first?.address, "192.168.0.42") + + service.removeServer(forServiceName: serviceName) + XCTAssertTrue(service.discoveredServers.isEmpty, + "A withdrawn service must be removed from the discovered list") + } + + // A withdrawal for a service we never resolved must not touch the list. + func testWithdrawalForUnknownServiceIsNoOp() { + let service = MDNSDiscoveryService() + let server = DiscoveredServer(address: "192.168.0.42", port: 3000, computerName: "CHOSS") + service.recordResolvedServer(server, forServiceName: "CHOSS-pcvolumecontrol-3000") + + service.removeServer(forServiceName: "OTHER-pcvolumecontrol-9999") + + XCTAssertEqual(service.discoveredServers.count, 1, + "Removing an unknown service must not affect existing discoveries") + } +} diff --git a/PcVolumeControlTests/MainViewModelSimpleTests.swift b/PcVolumeControlTests/MainViewModelSimpleTests.swift index 27a5d48..ec79835 100644 --- a/PcVolumeControlTests/MainViewModelSimpleTests.swift +++ b/PcVolumeControlTests/MainViewModelSimpleTests.swift @@ -251,6 +251,86 @@ final class MainViewModelSimpleTests: XCTestCase { XCTAssertEqual(sut.unifiedServerList.filter { !$0.isManual }.count, 0) } + func testWithdrawnDiscoveredServerRevertsToRecentConnectionRow() { + // Given a server that is both live-discovered over mDNS and a saved recent + // connection. The unified list collapses them into one green "Auto-discovered" + // row while the server is broadcasting. + let discovered = DiscoveredServer(address: "192.168.0.42", port: 3000, computerName: "CHOSS") + let recent = DiscoveredServer(address: "192.168.0.42", port: 3000, computerName: nil) + sut.discoveredServers = [discovered] + sut.recentServers = [recent] + + let rowsBefore = sut.unifiedServerList.filter { !$0.isManual } + XCTAssertEqual(rowsBefore.count, 1) + XCTAssertTrue(rowsBefore.first?.isDiscovered ?? false, + "While broadcasting, the row is the green auto-discovered one") + + // When the server withdraws from mDNS, discovery no longer reports it. MainView + // forwards that empty list to the view model via handleDiscoveredServers(_:). + sut.handleDiscoveredServers([]) + + // Then the row stays (it is a previous/recent connection) but loses the green: + // no auto-discovered indicator, shown as a plain "Recent connection". + let rowsAfter = sut.unifiedServerList.filter { !$0.isManual } + XCTAssertEqual(rowsAfter.count, 1, "A previously-connected server stays in the list") + XCTAssertFalse(rowsAfter.first?.isDiscovered ?? true, + "The green auto-discovered indicator is gone after withdrawal") + XCTAssertTrue(rowsAfter.first?.isRecent ?? false, + "The row is now a plain recent connection") + } + + // MARK: - Auto-Connect On Launch Tests + + func testConnectToServerSurfacesErrorOnFailureByDefault() async { + // The default (non-silent) path must still surface a failure as an alert-driving error. + sut.connectToServer(address: "192.0.2.1", port: 3000) + + // Wait for the internal connect Task to run and hit its catch block. + for _ in 0..<200 { + if sut.error != nil { break } + try? await Task.sleep(nanoseconds: 5_000_000) // 5ms + } + + XCTAssertNotNil(sut.error, "Default connect must surface the failure") + XCTAssertNil(sut.fullState) + XCTAssertFalse(sut.hasActiveConnection) + } + + func testAutoConnectToLastServerWithNoRecentsIsNoOp() { + XCTAssertTrue(sut.recentServers.isEmpty) + + sut.autoConnectToLastServer() + + // Nothing was selected and no attempt was started. + XCTAssertEqual(sut.address, "") + XCTAssertEqual(sut.port, 0) + XCTAssertFalse(sut.isLoading) + XCTAssertNil(sut.error) + XCTAssertFalse(sut.hasActiveConnection) + } + + func testAutoConnectToLastServerSelectsRecentAndFailsSilently() async { + // Given a saved recent server (mock TCP always throws -> connect fails). + sut.recentServers = [DiscoveredServer(address: "192.0.2.1", port: 3000, computerName: nil)] + + // When auto-connecting on launch. + sut.autoConnectToLastServer() + + // The chosen server is reflected immediately. + XCTAssertEqual(sut.address, "192.0.2.1") + XCTAssertEqual(sut.port, 3000) + + // Wait out the same failure window the non-silent test needs; error must stay nil. + for _ in 0..<200 { + try? await Task.sleep(nanoseconds: 5_000_000) // 5ms, ~1s total + if sut.error != nil { break } // only on a regression + } + + XCTAssertNil(sut.error, "Auto-connect on launch must fail silently (no alert)") + XCTAssertNil(sut.fullState) + XCTAssertFalse(sut.hasActiveConnection) + } + // MARK: - Integration Tests func testConnectDisconnectCycle() async { @@ -358,14 +438,14 @@ class MockLocalDataSource: ServersLocalDataSourceProtocol { func loadRecent() -> [DiscoveredServer] { return [] } - - func saveRecent(_ list: [DiscoveredServer]) { - // Mock implementation + + func saveRecent(_: [DiscoveredServer]) { } } class MockTCPDataSource: TCPConnectionDataSourceProtocol { - func openConnection(to server: DiscoveredServer) async throws -> Connection { + func openConnection(to _server: DiscoveredServer) async throws -> Connection { + // For testing, we'll throw an error to simulate connection failure throw NSError(domain: "MockConnectionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Mock connection failed"]) } diff --git a/PcVolumeControlWidget/PcVolumeControlWidget.swift b/PcVolumeControlWidget/PcVolumeControlWidget.swift index 1edcb72..4b87aaa 100644 --- a/PcVolumeControlWidget/PcVolumeControlWidget.swift +++ b/PcVolumeControlWidget/PcVolumeControlWidget.swift @@ -1,3 +1,4 @@ +// periphery:ignore // // PcVolumeControlWidget.swift // PcVolumeControlWidget