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