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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ jobs:
test \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO

continue-on-error: true
- name: Build and upload to App Store
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
ASC_KEY_CONTENT: "${{ secrets.ASC_KEY_CONTENT }}"
run: bundle exec fastlane release

- name: Upload IPA artifact
Expand Down
52 changes: 49 additions & 3 deletions RemoteCam/DeviceScannerViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public class DeviceScannerViewController: UIViewController {
var isScanning: Bool = false
var hasLocalNetworkAccess: Bool = true
var hasScanningError: Bool = false

// Auto-reconnect state
var isAutoConnecting: Bool = false
var autoConnectingPeerName: String?

// Modern Swift UserDefaults property
var speedRunScanning: Bool {
Expand Down Expand Up @@ -135,6 +139,27 @@ public class DeviceScannerViewController: UIViewController {
navigationItem.rightBarButtonItem = helpButton
}

/// Shows or hides a Cancel nav bar button during auto-connect.
func updateCancelButton() {
if isAutoConnecting {
let cancelButton = UIBarButtonItem(
barButtonSystemItem: .cancel,
target: self,
action: #selector(cancelAutoConnect)
)
navigationItem.leftBarButtonItem = cancelButton
} else {
navigationItem.leftBarButtonItem = nil
}
}

@objc private func cancelAutoConnect() {
isAutoConnecting = false
autoConnectingPeerName = nil
updateCancelButton()
tableView.reloadData()
}

@objc private func showHelpModal() {
let helpView = RemoteShutterHelpView(onDismiss: { [weak self] in
self?.dismiss(animated: true)
Expand Down Expand Up @@ -191,18 +216,24 @@ public class DeviceScannerViewController: UIViewController {
connectedPeers.removeAll()
isScanning = true
hasScanningError = false // Clear error state when starting new scan
isAutoConnecting = false
autoConnectingPeerName = nil
DispatchQueue.main.async {
self.updateCancelButton()
self.tableView.reloadData()
}
scanner.stopBrowsingForPeers()
scanner.startBrowsingForPeers()
}

func stopScanning() {
splash.stopAnimating()
connectedPeers.removeAll()
isScanning = false
isAutoConnecting = false
autoConnectingPeerName = nil
DispatchQueue.main.async {
self.updateCancelButton()
self.tableView.reloadData()
}
scanner.stopBrowsingForPeers()
Expand Down Expand Up @@ -437,6 +468,9 @@ extension DeviceScannerViewController: UITableViewDataSource, UITableViewDelegat
if (connectedPeers.count == 0) {
return
}
isAutoConnecting = false
autoConnectingPeerName = nil
updateCancelButton()
let peer = connectedPeers[indexPath.row]
remoteCamSession ! ConnectToDevice(peer: peer, sender: nil)
}
Expand All @@ -448,6 +482,8 @@ extension DeviceScannerViewController: UITableViewDataSource, UITableViewDelegat
} else {
return NSLocalizedString("TAP THE GREEN BUTTON TO GET STARTED", comment: "")
}
} else if isAutoConnecting, let name = autoConnectingPeerName {
return String(format: NSLocalizedString("CONNECTING TO %@...", comment: ""), name)
} else {
return NSLocalizedString("SEARCHING FOR NEARBY DEVICES...", comment: "")
}
Expand All @@ -457,11 +493,21 @@ extension DeviceScannerViewController: UITableViewDataSource, UITableViewDelegat
extension DeviceScannerViewController: MCNearbyServiceBrowserDelegate {
public func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
connectedPeers.append(peerID)

// Enable speed run scanning for future visits - user has successfully found a peer
speedRunScanning = true


// Auto-connect to known devices
if FeatureFlags.ENABLE_AUTO_RECONNECT &&
!isAutoConnecting &&
KnownDevicesManager.shared.isKnown(displayName: peerID.displayName) {
isAutoConnecting = true
autoConnectingPeerName = peerID.displayName
remoteCamSession ! ConnectToDevice(peer: peerID, sender: nil)
}

DispatchQueue.main.async {
self.updateCancelButton()
self.tableView.reloadData()
}
}
Expand Down
7 changes: 7 additions & 0 deletions RemoteCam/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ struct FeatureFlags {
/// Set to true when Shorts mode implementation is complete
static let ENABLE_SHORTS_MODE = false

// MARK: - Connectivity Features

/// Enable auto-reconnect to previously paired devices.
/// When enabled, the app auto-accepts invitations from known devices
/// and auto-invites them when discovered during scanning.
static let ENABLE_AUTO_RECONNECT = true

// MARK: - Future Feature Flags
// Add new feature flags here as needed
// Example:
Expand Down
59 changes: 59 additions & 0 deletions RemoteCam/KnownDevicesManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// KnownDevicesManager.swift
// RemoteShutter
//
// Created by Dario Lencina on 2025.
// Copyright © 2025 Security Union. All rights reserved.
//

import Foundation

/// Manages a list of known (previously connected) device display names.
/// Stores up to 10 entries in MRU (most-recently-used) order via UserDefaults.
/// Uses display names because remote MCPeerID instances are different each session.
class KnownDevicesManager {

static let shared = KnownDevicesManager()

private let key = "knownDeviceDisplayNames"
private let maxDevices = 10

private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

/// Adds a device to the known list. If already present, moves it to the front (MRU).
func addDevice(displayName: String) {
var devices = allDevices()
devices.removeAll { $0 == displayName }
devices.insert(displayName, at: 0)
if devices.count > maxDevices {
devices = Array(devices.prefix(maxDevices))
}
defaults.set(devices, forKey: key)
}

/// Returns true if the display name is in the known devices list.
func isKnown(displayName: String) -> Bool {
return allDevices().contains(displayName)
}

/// Returns all known device display names in MRU order.
func allDevices() -> [String] {
return defaults.stringArray(forKey: key) ?? []
}

/// Removes a specific device from the known list.
func removeDevice(displayName: String) {
var devices = allDevices()
devices.removeAll { $0 == displayName }
defaults.set(devices, forKey: key)
}

/// Clears all known devices.
func clearAll() {
defaults.removeObject(forKey: key)
}
}
1 change: 1 addition & 0 deletions RemoteCam/RemoteCamScanning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ extension RemoteCamSession {
lobby.splash.startAnimating()
}
case let w as OnConnectToDevice:
KnownDevicesManager.shared.addDevice(displayName: w.peer.displayName)
^{
lobby.splash.stopAnimating()
}
Expand Down
64 changes: 57 additions & 7 deletions RemoteCam/RemoteCamSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class RemoteCamSession: ViewCtrlActor<DeviceScannerViewController>, MCSes

var session: MCSession!

var mcAdvertiserAssistant: MCAdvertiserAssistant!
var mcNearbyServiceAdvertiser: MCNearbyServiceAdvertiser!

// Progress tracking for video transfers
var progressCancellables = Set<AnyCancellable>()
Expand All @@ -39,8 +39,8 @@ public class RemoteCamSession: ViewCtrlActor<DeviceScannerViewController>, MCSes
}

override public func willStop() {
if let adv = self.mcAdvertiserAssistant {
adv.stop()
if let adv = self.mcNearbyServiceAdvertiser {
adv.stopAdvertisingPeer()
}
if let session = self.session {
session.disconnect()
Expand Down Expand Up @@ -71,9 +71,10 @@ public class RemoteCamSession: ViewCtrlActor<DeviceScannerViewController>, MCSes
CATransaction.setCompletionBlock {
self.session = MCSession(peer: lobby.peerID)
self.session.delegate = self
self.mcAdvertiserAssistant = MCAdvertiserAssistant(
serviceType: service, discoveryInfo: nil, session: self.session)
self.mcAdvertiserAssistant.start()
self.mcNearbyServiceAdvertiser = MCNearbyServiceAdvertiser(
peer: lobby.peerID, discoveryInfo: nil, serviceType: service)
self.mcNearbyServiceAdvertiser.delegate = self
self.mcNearbyServiceAdvertiser.startAdvertisingPeer()
}
lobby.navigationController?.popToViewController(lobby, animated: true)
lobby.startScanning()
Expand Down Expand Up @@ -373,8 +374,57 @@ public class RemoteCamSession: ViewCtrlActor<DeviceScannerViewController>, MCSes
if let monitorActor = getMonitorActor() {
monitorActor ! message
}

// For camera side, we'll handle this in the camera states where we have the ctrl reference
// This is cleaner than notifications and maintains the actor pattern
}
}

// MARK: - MCNearbyServiceAdvertiserDelegate

extension RemoteCamSession: MCNearbyServiceAdvertiserDelegate {

public func advertiser(
_ advertiser: MCNearbyServiceAdvertiser,
didReceiveInvitationFromPeer peerID: MCPeerID,
withContext context: Data?,
invitationHandler: @escaping (Bool, MCSession?) -> Void
) {
if FeatureFlags.ENABLE_AUTO_RECONNECT &&
KnownDevicesManager.shared.isKnown(displayName: peerID.displayName) {
invitationHandler(true, self.session)
return
}

^{
let alert = UIAlertController(
title: NSLocalizedString("Incoming Connection", comment: ""),
message: String(
format: NSLocalizedString("%@ wants to connect", comment: ""),
peerID.displayName
),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(
title: NSLocalizedString("Accept", comment: ""),
style: .default
) { _ in
invitationHandler(true, self.session)
})
alert.addAction(UIAlertAction(
title: NSLocalizedString("Decline", comment: ""),
style: .cancel
) { _ in
invitationHandler(false, nil)
})
alert.show(true)
}
}

public func advertiser(
_ advertiser: MCNearbyServiceAdvertiser,
didNotStartAdvertisingPeer error: Error
) {
print("Advertiser failed to start: \(error.localizedDescription)")
}
}
Loading