Files
yattee/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

256 lines
7.9 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 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
)
}
}