Files
yattee/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift
2026-02-08 18:33:56 +01:00

264 lines
8.2 KiB
Swift

//
// NetworkShareDiscoveryService.swift
// Yattee
//
// Discovers WebDAV and SMB shares on the local network using Bonjour/mDNS.
//
import Foundation
import Network
/// A network share discovered via Bonjour/mDNS.
struct DiscoveredShare: Identifiable, Hashable, Sendable {
let id = UUID()
let name: String // Service name (e.g., "Synology")
let host: String // Hostname (e.g., "synology.local")
let port: Int? // Port if non-standard
let path: String? // WebDAV path from TXT record
let type: ShareType
enum ShareType: String, CaseIterable, Sendable {
case webdav // _webdav._tcp (HTTP)
case webdavs // _webdavs._tcp (HTTPS)
case smb // _smb._tcp
var displayName: String {
switch self {
case .webdav: String(localized: "discovery.shareType.webdav")
case .webdavs: String(localized: "discovery.shareType.webdavs")
case .smb: String(localized: "discovery.shareType.smb")
}
}
var systemImage: String {
switch self {
case .webdav: "globe"
case .webdavs: "lock.shield"
case .smb: "folder.badge.gearshape"
}
}
var serviceType: String {
switch self {
case .webdav: "_webdav._tcp"
case .webdavs: "_webdavs._tcp"
case .smb: "_smb._tcp"
}
}
}
/// Constructs a URL for this share.
var url: URL? {
var components = URLComponents()
switch type {
case .webdav:
components.scheme = "http"
case .webdavs:
components.scheme = "https"
case .smb:
components.scheme = "smb"
}
components.host = host
if let port, port != defaultPort {
components.port = port
}
if let path, !path.isEmpty {
components.path = path.hasPrefix("/") ? path : "/\(path)"
}
return components.url
}
private var defaultPort: Int {
switch type {
case .webdav: 80
case .webdavs: 443
case .smb: 445
}
}
}
/// Service for discovering WebDAV and SMB shares on the local network.
@MainActor
@Observable
final class NetworkShareDiscoveryService {
// MARK: - Public State
/// Discovered shares on the local network.
private(set) var discoveredShares: [DiscoveredShare] = []
/// Whether the service is actively scanning.
private(set) var isScanning: Bool = false
// MARK: - Private State
private var browsers: [NWBrowser] = []
private var discoveryTask: Task<Void, Never>?
private let queue = DispatchQueue(label: "stream.yattee.networksharediscovery")
/// Duration to scan before automatically stopping (in seconds).
private let scanDuration: TimeInterval = 5.0
// MARK: - Discovery
/// Start discovering network shares. Automatically stops after 5 seconds.
func startDiscovery() {
guard !isScanning else {
LoggingService.shared.logMediaSourcesDebug("Already scanning, ignoring duplicate start")
return
}
LoggingService.shared.logMediaSources("Starting network share discovery")
// Clear previous results
discoveredShares = []
isScanning = true
// Start browsers for each service type
for shareType in DiscoveredShare.ShareType.allCases {
startBrowser(for: shareType)
}
// Auto-stop after scan duration
discoveryTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(scanDuration))
if isScanning {
LoggingService.shared.logMediaSources("Scan timeout reached, stopping discovery")
stopDiscovery()
}
}
}
/// Stop discovering network shares.
func stopDiscovery() {
guard isScanning else { return }
LoggingService.shared.logMediaSources("Stopping network share discovery, found \(self.discoveredShares.count) shares")
discoveryTask?.cancel()
discoveryTask = nil
for browser in browsers {
browser.cancel()
}
browsers.removeAll()
isScanning = false
}
// MARK: - Private Methods
private func startBrowser(for shareType: DiscoveredShare.ShareType) {
let parameters = NWParameters()
parameters.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: shareType.serviceType, domain: nil),
using: parameters
)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor [weak self] in
self?.handleBrowserStateChange(state, shareType: shareType)
}
}
browser.browseResultsChangedHandler = { [weak self] results, changes in
Task { @MainActor [weak self] in
self?.handleBrowseResultsChanged(results: results, changes: changes, shareType: shareType)
}
}
browser.start(queue: queue)
browsers.append(browser)
LoggingService.shared.logMediaSourcesDebug("Started browser for \(shareType.serviceType)")
}
private func handleBrowserStateChange(_ state: NWBrowser.State, shareType: DiscoveredShare.ShareType) {
switch state {
case .ready:
LoggingService.shared.logMediaSourcesDebug("Browser ready for \(shareType.serviceType)")
case .failed(let error):
LoggingService.shared.logMediaSourcesError("Browser failed for \(shareType.serviceType)", error: error)
case .cancelled:
LoggingService.shared.logMediaSourcesDebug("Browser cancelled for \(shareType.serviceType)")
case .waiting(let error):
LoggingService.shared.logMediaSourcesWarning("Browser waiting for \(shareType.serviceType)", details: error.localizedDescription)
default:
break
}
}
private func handleBrowseResultsChanged(
results: Set<NWBrowser.Result>,
changes: Set<NWBrowser.Result.Change>,
shareType: DiscoveredShare.ShareType
) {
for change in changes {
switch change {
case .added(let result):
if let share = parseShare(from: result, shareType: shareType) {
// Avoid duplicates
if !discoveredShares.contains(where: { $0.host == share.host && $0.type == share.type && $0.name == share.name }) {
discoveredShares.append(share)
LoggingService.shared.logMediaSources("Discovered \(shareType.rawValue) share: \(share.name) at \(share.host)")
}
}
case .removed(let result):
if case let .service(name, _, _, _) = result.endpoint {
discoveredShares.removeAll { $0.name == name && $0.type == shareType }
LoggingService.shared.logMediaSourcesDebug("Removed \(shareType.rawValue) share: \(name)")
}
case .changed, .identical:
break
@unknown default:
break
}
}
}
private func parseShare(from result: NWBrowser.Result, shareType: DiscoveredShare.ShareType) -> DiscoveredShare? {
guard case let .service(name, _, _, _) = result.endpoint else {
return nil
}
// Extract host from endpoint - use the service name with .local suffix
let host = "\(name).local"
// Parse TXT record for additional info
var path: String?
var port: Int?
if case let .bonjour(txtRecord) = result.metadata {
let dict = txtRecord.dictionary
// WebDAV servers often advertise the path in TXT record
if let txtPath = dict["path"] {
path = txtPath
}
// Some servers advertise port in TXT record
if let txtPort = dict["port"], let portNum = Int(txtPort) {
port = portNum
}
}
return DiscoveredShare(
name: name,
host: host,
port: port,
path: path,
type: shareType
)
}
}