Files
yattee/Yattee/Services/API/InstanceDetector.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

449 lines
18 KiB
Swift

//
// InstanceDetector.swift
// Yattee
//
// Automatically detects backend instance type by probing API endpoints.
//
import Foundation
/// Errors that can occur during instance detection.
enum DetectionError: Error, Sendable {
case sslCertificateError
case networkError(String)
case unknownType
case invalidURL
case timeout
/// The instance is fronted by an HTTP Basic Auth challenge (401). The user must
/// supply credentials before detection can identify the backend type.
case basicAuthRequired
/// Detection was retried with HTTP Basic Auth credentials but the server still
/// returned 401 — the credentials are invalid.
case basicAuthInvalid
/// The URL points to the Piped web frontend SPA, not its API backend. The
/// frontend serves the same `index.html` for every path, so JSON probes can
/// never succeed — the user must supply the API URL instead.
case pipedFrontendDetected
var localizedDescription: String {
switch self {
case .sslCertificateError:
return String(localized: "sources.error.sslCertificate")
case .networkError(let message):
return message
case .unknownType:
return String(localized: "sources.error.couldNotDetect")
case .invalidURL:
return String(localized: "sources.validation.invalidURL")
case .timeout:
return String(localized: "sources.error.timeout")
case .basicAuthRequired:
return String(localized: "sources.error.basicAuthRequired")
case .basicAuthInvalid:
return String(localized: "sources.error.basicAuthInvalid")
case .pipedFrontendDetected:
return String(localized: "sources.error.pipedFrontendDetected")
}
}
}
/// Result of instance detection including type and authentication requirements.
struct InstanceDetectionResult: Sendable {
let type: InstanceType
init(type: InstanceType, requiresAuth: Bool = false) {
self.type = type
}
}
/// Detects the type of a backend instance by probing known API endpoints.
actor InstanceDetector {
private let httpClient: HTTPClient
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
/// Detects the instance type with detailed error reporting.
/// - Parameters:
/// - url: The base URL of the instance.
/// - basicAuthHeader: Optional HTTP Basic Auth header value (e.g., "Basic dXNlcjpwYXNz")
/// to inject into every probe. Used to retry detection after the user provides
/// credentials for an instance fronted by a reverse proxy.
/// - Returns: Result containing either the detection result or a detailed error.
func detectWithResult(
url: URL,
basicAuthHeader: String? = nil
) async -> Result<InstanceDetectionResult, DetectionError> {
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
// A 401 from a single probe is *not* enough to conclude that credentials are
// invalid. Some probe paths (e.g. Yattee Server's `/info`) trigger an HTTP
// redirect on Invidious, and iOS URLSession strips the Authorization header
// when following redirects, so the redirected request 401s even when the
// credentials are valid. We instead consider credentials bad only if EVERY
// probe failed with 401 and none matched.
var sawUnauthorized = false
// Try each detection method in order of specificity.
// Check Yattee Server first as it's most specific.
do {
if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) {
return .success(result)
}
} catch let error as DetectionError {
return .failure(error)
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isPeerTube(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .peertube))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isInvidious(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .invidious))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isPiped(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .piped))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Fall through
}
// No probe matched. If at least one probe returned 401, the instance is
// (or appears to be) behind HTTP Basic Auth. Distinguish "needs creds" from
// "creds rejected" by whether the caller already supplied a header.
if sawUnauthorized {
return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid)
}
if await looksLikePipedFrontend(url: url, extraHeaders: extraHeaders) {
return .failure(.pipedFrontendDetected)
}
return .failure(.unknownType)
}
/// Detects the Piped Vue SPA by fetching `/` and looking for the
/// `<title>Piped</title>` fingerprint. The frontend serves the same
/// `index.html` for every path with HTTP 200, so a body-content check is
/// the cheapest reliable signal.
private func looksLikePipedFrontend(url: URL, extraHeaders: [String: String]? = nil) async -> Bool {
let endpoint = GenericEndpoint.get("/")
do {
let data = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
guard let body = String(data: data, encoding: .utf8) else { return false }
let lower = body.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let looksLikeHTML = lower.hasPrefix("<!doctype html") || lower.hasPrefix("<html")
let hasPipedTitle = lower.contains("<title>piped</title>")
return looksLikeHTML && hasPipedTitle
} catch {
return false
}
}
// MARK: - Detection Methods
/// Detects if the instance is a Yattee Server with detailed error reporting.
/// Throws `APIError.unauthorized` if the probe receives a 401, so the caller can prompt for credentials.
private func detectYatteeServerWithError(
url: URL,
extraHeaders: [String: String]? = nil
) async throws -> InstanceDetectionResult? {
let endpoint = GenericEndpoint.get("/info")
do {
// First, get raw data to debug the response
let rawData = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
if let rawString = String(data: rawData, encoding: .utf8) {
LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api)
}
let response = try JSONDecoder().decode(InstanceDetectorModels.YatteeServerInfo.self, from: rawData)
LoggingService.shared.debug("[InstanceDetector] Parsed YatteeServerInfo: name=\(response.name ?? "nil")", category: .api)
// Yattee Server returns name containing "yattee"
if response.name?.lowercased().contains("yattee") == true {
// Auth is always required for Yattee Server
let result = InstanceDetectionResult(
type: .yatteeServer,
requiresAuth: true
)
LoggingService.shared.debug("[InstanceDetector] Returning result: type=yatteeServer, requiresAuth=true", category: .api)
return result
}
return nil
} catch APIError.unauthorized {
LoggingService.shared.debug("[InstanceDetector] /info returned 401 — basic auth required", category: .api)
throw APIError.unauthorized
} catch let urlError as URLError {
LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription)
// Check for SSL certificate errors
if urlError.code == .serverCertificateUntrusted ||
urlError.code == .serverCertificateHasBadDate ||
urlError.code == .serverCertificateHasUnknownRoot ||
urlError.code == .serverCertificateNotYetValid ||
urlError.code == .clientCertificateRejected {
throw DetectionError.sslCertificateError
}
if urlError.code == .timedOut {
throw DetectionError.timeout
}
throw DetectionError.networkError(urlError.localizedDescription)
} catch {
LoggingService.shared.error("[InstanceDetector] detectYatteeServer error", category: .api, details: error.localizedDescription)
return nil
}
}
/// Checks if the instance is PeerTube by calling /api/v1/config.
/// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isPeerTube(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
let endpoint = GenericEndpoint.get("/api/v1/config")
do {
let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// PeerTube config has specific fields
return response.instance != nil || response.serverVersion != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
/// Checks if the instance is Invidious by calling /api/v1/stats.
/// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isInvidious(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
let endpoint = GenericEndpoint.get("/api/v1/stats")
do {
let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// Invidious stats has software.name = "invidious"
return response.software?.name?.lowercased() == "invidious"
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
/// Checks if the instance is Piped by probing Piped-specific endpoints.
/// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isPiped(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
// Piped has a /healthcheck endpoint that returns "OK"
let healthEndpoint = GenericEndpoint.get("/healthcheck")
do {
let data = try await httpClient.fetchData(healthEndpoint, baseURL: url, customHeaders: extraHeaders)
if let text = String(data: data, encoding: .utf8), text.contains("OK") {
return true
}
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
// Continue to next check
}
// Also try /config endpoint which Piped uses
let configEndpoint = GenericEndpoint.get("/config")
do {
let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url, customHeaders: extraHeaders)
// Piped config has specific fields
return response.donationUrl != nil || response.statusPageUrl != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
}
// MARK: - Detection Response Models
/// Namespace for instance detection response models.
/// Using an enum as a namespace to avoid MainActor isolation issues.
enum InstanceDetectorModels {
struct YatteeServerInfo: Sendable {
let name: String?
let version: String?
let description: String?
}
/// Full server info response from /info endpoint for display in UI.
struct YatteeServerFullInfo: Sendable {
let name: String?
let version: String?
let dependencies: Dependencies?
let config: Config?
let sites: [Site]?
struct Dependencies: Sendable {
let ytDlp: String?
let ffmpeg: String?
}
struct Config: Sendable {
let invidiousInstance: String?
let allowAllSitesForExtraction: Bool?
}
struct Site: Sendable {
let name: String
let extractorPattern: String?
}
}
struct PeerTubeConfig: Sendable {
let instance: PeerTubeInstanceInfo?
let serverVersion: String?
struct PeerTubeInstanceInfo: Sendable {
let name: String?
let shortDescription: String?
}
}
struct InvidiousStats: Sendable {
let software: InvidiousSoftware?
struct InvidiousSoftware: Sendable {
let name: String?
let version: String?
}
}
struct PipedConfig: Sendable {
let donationUrl: String?
let statusPageUrl: String?
let s3Enabled: Bool?
}
}
// MARK: - Decodable Conformance (nonisolated)
extension InstanceDetectorModels.YatteeServerInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version, description
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
description = try container.decodeIfPresent(String.self, forKey: .description)
}
}
extension InstanceDetectorModels.YatteeServerFullInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version, dependencies, config, sites
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
dependencies = try container.decodeIfPresent(Dependencies.self, forKey: .dependencies)
config = try container.decodeIfPresent(Config.self, forKey: .config)
sites = try container.decodeIfPresent([Site].self, forKey: .sites)
}
}
extension InstanceDetectorModels.YatteeServerFullInfo.Dependencies: Decodable {
private enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
ytDlp = try container.decodeIfPresent(String.self, forKey: .ytDlp)
ffmpeg = try container.decodeIfPresent(String.self, forKey: .ffmpeg)
}
}
// Config and Site use automatic Decodable synthesis since HTTPClient uses .convertFromSnakeCase
extension InstanceDetectorModels.YatteeServerFullInfo.Config: Decodable {}
extension InstanceDetectorModels.YatteeServerFullInfo.Site: Decodable {}
extension InstanceDetectorModels.PeerTubeConfig: Decodable {
private enum CodingKeys: String, CodingKey {
case instance, serverVersion
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
instance = try container.decodeIfPresent(PeerTubeInstanceInfo.self, forKey: .instance)
serverVersion = try container.decodeIfPresent(String.self, forKey: .serverVersion)
}
}
extension InstanceDetectorModels.PeerTubeConfig.PeerTubeInstanceInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, shortDescription
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
}
}
extension InstanceDetectorModels.InvidiousStats: Decodable {
private enum CodingKeys: String, CodingKey {
case software
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
software = try container.decodeIfPresent(InvidiousSoftware.self, forKey: .software)
}
}
extension InstanceDetectorModels.InvidiousStats.InvidiousSoftware: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
}
}
extension InstanceDetectorModels.PipedConfig: Decodable {
private enum CodingKeys: String, CodingKey {
case donationUrl, statusPageUrl, s3Enabled
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
donationUrl = try container.decodeIfPresent(String.self, forKey: .donationUrl)
statusPageUrl = try container.decodeIfPresent(String.self, forKey: .statusPageUrl)
s3Enabled = try container.decodeIfPresent(Bool.self, forKey: .s3Enabled)
}
}