99import UIKit
1010import Theater
1111import MultipeerConnectivity
12+ import Network
13+ import dnssd
1214
1315let goToRolePickerController = " goToRolePickerController "
1416let service : String = " RemoteCam "
1517let userDefaultsPeerId = " peerID "
18+ let userDefaultsSpeedRunScanning = " speedrunscanning "
1619let remoteShutterUrl = " https://apps.apple.com/us/app/remote-shutter/id633274861 "
1720
1821func generateQRCode( _ string: String ) -> UIImage ? {
@@ -47,10 +50,32 @@ public class DeviceScannerViewController: UIViewController {
4750
4851 var connectedPeers : [ MCPeerID ] = [ ]
4952
53+ // Add state tracking
54+ var isScanning : Bool = false
55+ var hasLocalNetworkAccess : Bool = true
56+ var hasScanningError : Bool = false
57+
58+ // Modern Swift UserDefaults property
59+ var speedRunScanning : Bool {
60+ get {
61+ UserDefaults . standard. bool ( forKey: userDefaultsSpeedRunScanning)
62+ }
63+ set {
64+ UserDefaults . standard. set ( newValue, forKey: userDefaultsSpeedRunScanning)
65+ // Update UI when flag changes
66+ DispatchQueue . main. async {
67+ self . tableView. reloadData ( )
68+ }
69+ }
70+ }
71+
5072 lazy var splash = {
5173 CoolActivityIndicator ( currentController: self )
5274 } ( )
5375
76+ // Add network browser for checking local network access
77+ var networkBrowser : NWBrowser ?
78+
5479 lazy var scanner : MCNearbyServiceBrowser = {
5580 if let data = UserDefaults . standard. data ( forKey: userDefaultsPeerId) ,
5681 let id = try ? NSKeyedUnarchiver . unarchiveTopLevelObjectWithData ( data) as? MCPeerID {
@@ -88,7 +113,18 @@ public class DeviceScannerViewController: UIViewController {
88113 public override func viewDidAppear( _ animated: Bool ) {
89114 super. viewDidAppear ( animated)
90115 self . remoteCamSession ! Disconnect ( )
91- self . remoteCamSession ! UICmd. StartScanning ( sender: nil )
116+
117+ // Reload table data in case user returned from system permission dialog
118+ DispatchQueue . main. async {
119+ self . tableView. reloadData ( )
120+ }
121+
122+ // Check if user has successfully found peers before (speed run mode)
123+ if speedRunScanning {
124+ // Auto-start scanning for experienced users
125+ checkLocalNetworkAccessAndStartScanning ( )
126+ }
127+ // First-time users will see the button and educational flow
92128 }
93129
94130 public override func prepare( for segue: UIStoryboardSegue , sender: Any ? ) {
@@ -106,18 +142,167 @@ public class DeviceScannerViewController: UIViewController {
106142 func startScanning( ) {
107143 splash. stopAnimating ( )
108144 connectedPeers. removeAll ( )
109- tableView. reloadData ( )
145+ isScanning = true
146+ hasScanningError = false // Clear error state when starting new scan
147+ DispatchQueue . main. async {
148+ self . tableView. reloadData ( )
149+ }
110150 scanner. stopBrowsingForPeers ( )
111151 scanner. startBrowsingForPeers ( )
112152 }
113153
114154 func stopScanning( ) {
115155 splash. stopAnimating ( )
116156 connectedPeers. removeAll ( )
117- tableView. reloadData ( )
157+ isScanning = false
158+ DispatchQueue . main. async {
159+ self . tableView. reloadData ( )
160+ }
118161 scanner. stopBrowsingForPeers ( )
119162 }
120163
164+ @IBAction func startScanningDevices( ) {
165+ showScanningPermissionAlert ( )
166+ }
167+
168+ func showScanningPermissionAlert( ) {
169+ let alert = UIAlertController (
170+ title: NSLocalizedString ( " Scan for Nearby Devices " , comment: " " ) ,
171+ message: NSLocalizedString ( " Remote Shutter needs to scan for other devices on your local network to establish a connection. This allows you to use one device as a camera and another as a remote control. " , comment: " " ) ,
172+ preferredStyle: . alert
173+ )
174+
175+ alert. addAction ( UIAlertAction (
176+ title: NSLocalizedString ( " Not Now " , comment: " " ) ,
177+ style: . cancel,
178+ handler: { _ in
179+ // Reload table when "Not Now" is tapped
180+ DispatchQueue . main. async {
181+ self . tableView. reloadData ( )
182+ }
183+ }
184+ ) )
185+
186+ alert. addAction ( UIAlertAction (
187+ title: NSLocalizedString ( " Start Scanning " , comment: " " ) ,
188+ style: . default,
189+ handler: { _ in
190+ self . checkLocalNetworkAccessAndStartScanning ( )
191+ // Reload table when "Start Scanning" is tapped
192+ DispatchQueue . main. async {
193+ self . tableView. reloadData ( )
194+ }
195+ }
196+ ) )
197+
198+ present ( alert, animated: true ) {
199+ // Reload table after scanning permission alert is presented
200+ DispatchQueue . main. async {
201+ self . tableView. reloadData ( )
202+ }
203+ }
204+ }
205+
206+ func checkLocalNetworkAccessAndStartScanning( ) {
207+ // Use Bonjour to check local network access
208+ let parameters = NWParameters ( )
209+ parameters. includePeerToPeer = true
210+
211+ let browserDescriptor = NWBrowser . Descriptor. bonjourWithTXTRecord (
212+ type: " _remotecam._tcp " ,
213+ domain: " local. "
214+ )
215+
216+ networkBrowser = NWBrowser ( for: browserDescriptor, using: parameters)
217+
218+ networkBrowser? . stateUpdateHandler = { [ weak self] state in
219+ DispatchQueue . main. async {
220+ switch state {
221+ case . waiting( let error) :
222+ print ( " Network browser waiting with error: \( error) " )
223+ if case . dns( let dnsError) = error {
224+ let dnsCode = Int ( dnsError)
225+ print ( " DNS error code: \( dnsCode) " )
226+ if dnsCode == Int ( kDNSServiceErr_PolicyDenied) {
227+ // No local network access - reset speed run mode and set error state
228+ self ? . speedRunScanning = false
229+ self ? . hasLocalNetworkAccess = false
230+ self ? . hasScanningError = true
231+ self ? . showLocalNetworkAccessDeniedAlert ( )
232+ return
233+ }
234+ }
235+ // Other waiting states - continue with scanning, clear error state
236+ self ? . hasLocalNetworkAccess = true
237+ self ? . hasScanningError = false
238+ self ? . startActualScanning ( )
239+ case . ready:
240+ print ( " Network browser ready " )
241+ // Network access is available - clear error state
242+ self ? . hasLocalNetworkAccess = true
243+ self ? . hasScanningError = false
244+ self ? . startActualScanning ( )
245+ case . failed( let error) :
246+ print ( " Network browser failed: \( error) " )
247+ // Permission denied - likely local network access denied
248+ self ? . speedRunScanning = false
249+ self ? . hasLocalNetworkAccess = false
250+ self ? . hasScanningError = true
251+ self ? . showLocalNetworkAccessDeniedAlert ( )
252+ default :
253+ print ( " Network browser state: \( state) " )
254+ break
255+ }
256+ }
257+ }
258+
259+ networkBrowser? . start ( queue: . main)
260+
261+ // Stop the browser after a short check
262+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 2.0 ) {
263+ self . networkBrowser? . cancel ( )
264+ }
265+ }
266+
267+ func startActualScanning( ) {
268+ self . remoteCamSession ! UICmd. StartScanning ( sender: nil )
269+ startScanning ( )
270+ }
271+
272+ func showLocalNetworkAccessDeniedAlert( ) {
273+ let alert = UIAlertController (
274+ title: NSLocalizedString ( " Local Network Access Required " , comment: " " ) ,
275+ message: NSLocalizedString ( " Remote Shutter needs access to your local network to find other devices. Please grant access in Settings. " , comment: " " ) ,
276+ preferredStyle: . alert
277+ )
278+
279+ alert. addAction ( UIAlertAction (
280+ title: NSLocalizedString ( " Cancel " , comment: " " ) ,
281+ style: . cancel,
282+ handler: { _ in
283+ // Reload table when "Cancel" is tapped
284+ DispatchQueue . main. async {
285+ self . tableView. reloadData ( )
286+ }
287+ }
288+ ) )
289+
290+ alert. addAction ( UIAlertAction (
291+ title: NSLocalizedString ( " Open Settings " , comment: " " ) ,
292+ style: . default,
293+ handler: { _ in
294+ self . goToAppSettings ( )
295+ }
296+ ) )
297+
298+ present ( alert, animated: true ) {
299+ // Reload table after local network access alert is presented
300+ DispatchQueue . main. async {
301+ self . tableView. reloadData ( )
302+ }
303+ }
304+ }
305+
121306 @IBAction func goToRolePicker( ) {
122307 self . performSegue ( withIdentifier: goToRolePickerController, sender: self )
123308 }
@@ -141,6 +326,7 @@ public class DeviceScannerViewController: UIViewController {
141326
142327 deinit {
143328 print ( " deinit DeviceScanners " )
329+ networkBrowser? . cancel ( )
144330 frameSender ! Actor. Harakiri ( sender: nil )
145331 remoteCamSession ! Actor. Harakiri ( sender: nil )
146332 }
@@ -159,27 +345,59 @@ extension DeviceScannerViewController: UITableViewDataSource, UITableViewDelegat
159345 } else {
160346 let cell = tableView. dequeueReusableCell ( withIdentifier: " instructions " ) as! DeviceScannerPlaceholder
161347 cell. qrCode. image = qrCodeImage
162- cell. shareButton. styleButton (
163- backgroundColor: UIColor . systemGray,
164- borderColor: UIColor . clear,
165- textColor: UIColor . white
166- )
167- cell. shareButton. setNeedsDisplay ( )
348+
349+ // If no local network access, hide scanning buttons and only show settings
350+ if !hasLocalNetworkAccess {
351+ cell. shareButton. isHidden = true
352+ cell. goToSettings. isHidden = false
353+ } else {
354+ // Configure start scanning button when network access is available
355+ cell. shareButton. isHidden = false
356+ if !isScanning {
357+ cell. shareButton. setTitle ( NSLocalizedString ( " Start Scanning Devices " , comment: " " ) , for: . normal)
358+ cell. shareButton. removeTarget ( nil , action: nil , for: . allEvents)
359+ cell. shareButton. addTarget ( self , action: #selector( startScanningDevices) , for: . touchUpInside)
360+ cell. shareButton. styleButton (
361+ backgroundColor: UIColor . systemGreen,
362+ borderColor: UIColor . clear,
363+ textColor: UIColor . white
364+ )
365+ } else {
366+ cell. shareButton. setTitle ( NSLocalizedString ( " Stop Scanning " , comment: " " ) , for: . normal)
367+ cell. shareButton. removeTarget ( nil , action: nil , for: . allEvents)
368+ cell. shareButton. addTarget ( self , action: #selector( stopScanningDevices) , for: . touchUpInside)
369+ cell. shareButton. styleButton (
370+ backgroundColor: UIColor . systemRed,
371+ borderColor: UIColor . clear,
372+ textColor: UIColor . white
373+ )
374+ }
375+ cell. shareButton. setNeedsDisplay ( )
376+
377+ // Configure settings button - only show when there's a scanning error
378+ if #available( iOS 14 . 0 , * ) {
379+ cell. goToSettings. isHidden = !hasScanningError
380+ } else {
381+ cell. goToSettings. isHidden = true
382+ }
383+ }
384+
385+ // Always configure settings button styling when visible
168386 cell. goToSettings. styleButton (
169- backgroundColor: UIColor . systemGreen ,
387+ backgroundColor: UIColor . systemGray ,
170388 borderColor: UIColor . clear,
171389 textColor: UIColor . white
172390 )
173391 cell. goToSettings. setNeedsDisplay ( )
174- if #available( iOS 14 . 0 , * ) {
175- cell. goToSettings. isHidden = false
176- } else {
177- cell. goToSettings. isHidden = true
178- }
179392 return cell
180393 }
181394 }
182395
396+ @objc func stopScanningDevices( ) {
397+ stopScanning ( )
398+ self . remoteCamSession ! Disconnect ( )
399+ }
400+
183401 public func tableView( _ tableView: UITableView , didSelectRowAt indexPath: IndexPath ) {
184402 tableView. deselectRow ( at: indexPath, animated: true )
185403 if ( connectedPeers. count == 0 ) {
@@ -190,19 +408,67 @@ extension DeviceScannerViewController: UITableViewDataSource, UITableViewDelegat
190408 }
191409
192410 public func tableView( _ tableView: UITableView , titleForHeaderInSection section: Int ) -> String ? {
193- return " SEARCHING FOR NEARBY DEVICES... "
411+ if !isScanning {
412+ if hasScanningError {
413+ return NSLocalizedString ( " SCANNING ERROR - CHECK NETWORK SETTINGS " , comment: " " )
414+ } else {
415+ return NSLocalizedString ( " TAP THE GREEN BUTTON TO GET STARTED " , comment: " " )
416+ }
417+ } else {
418+ return NSLocalizedString ( " SEARCHING FOR NEARBY DEVICES... " , comment: " " )
419+ }
194420 }
195421}
196422
197423extension DeviceScannerViewController : MCNearbyServiceBrowserDelegate {
198424 public func browser( _ browser: MCNearbyServiceBrowser , foundPeer peerID: MCPeerID , withDiscoveryInfo info: [ String : String ] ? ) {
199425 connectedPeers. append ( peerID)
200- tableView. reloadData ( )
426+
427+ // Enable speed run scanning for future visits - user has successfully found a peer
428+ speedRunScanning = true
429+
430+ DispatchQueue . main. async {
431+ self . tableView. reloadData ( )
432+ }
201433 }
202434
203435 public func browser( _ browser: MCNearbyServiceBrowser , lostPeer peerID: MCPeerID ) {
204436 connectedPeers = connectedPeers. filter { ( peer) -> Bool in peer != peerID }
205- tableView. reloadData ( )
437+ DispatchQueue . main. async {
438+ self . tableView. reloadData ( )
439+ }
440+ }
441+
442+ public func browser( _ browser: MCNearbyServiceBrowser , didNotStartBrowsingForPeers error: Error ) {
443+ print ( " Browser failed to start browsing: \( error. localizedDescription) " )
444+
445+ // Reset speed run mode and set error state when scanning fails
446+ speedRunScanning = false
447+ hasScanningError = true
448+
449+ DispatchQueue . main. async {
450+ self . stopScanning ( )
451+
452+ // Show error alert to user
453+ let alert = UIAlertController (
454+ title: NSLocalizedString ( " Scanning Error " , comment: " " ) ,
455+ message: NSLocalizedString ( " Unable to scan for nearby devices. Please check your network settings and try again. " , comment: " " ) ,
456+ preferredStyle: . alert
457+ )
458+
459+ alert. addAction ( UIAlertAction (
460+ title: NSLocalizedString ( " OK " , comment: " " ) ,
461+ style: . default,
462+ handler: nil
463+ ) )
464+
465+ self . present ( alert, animated: true ) {
466+ // Reload table after scanning error alert is presented
467+ DispatchQueue . main. async {
468+ self . tableView. reloadData ( )
469+ }
470+ }
471+ }
206472 }
207473}
208474
0 commit comments