From 411fcba037a2e726fdcb9d57775167534fbed927 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 6 May 2026 21:14:50 +0200 Subject: [PATCH] Surface clearer error when adding a Piped frontend URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointing AddRemoteServer at a Piped Vue SPA (e.g. the frontend host rather than the API host) used to fail with a generic "could not detect instance type" — every JSON probe got the same index.html back. On the failure path, fetch `/` once more and look for `Piped`; if matched, return a new `pipedFrontendDetected` error that tells the user to enter the Piped API URL instead. --- Yattee/Localizable.xcstrings | 11 ++++++++ Yattee/Services/API/InstanceDetector.swift | 29 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 5e63730a..42546299 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -15094,6 +15094,17 @@ } } }, + "sources.error.pipedFrontendDetected" : { + "comment" : "Error when the URL points to the Piped web frontend (SPA) instead of its API backend. The frontend serves the same index.html for every path so detection cannot succeed.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This URL is the Piped web frontend, not its API. Please enter the Piped API URL instead (for example, the frontend may be at piped.example.com while the API is at piped-api.example.com or pipedapi.example.com — check with the instance operator)." + } + } + } + }, "sources.error.couldNotDetect" : { "comment" : "Error when source type could not be detected", "localizations" : { diff --git a/Yattee/Services/API/InstanceDetector.swift b/Yattee/Services/API/InstanceDetector.swift index c418bf80..fde71832 100644 --- a/Yattee/Services/API/InstanceDetector.swift +++ b/Yattee/Services/API/InstanceDetector.swift @@ -20,6 +20,10 @@ enum DetectionError: Error, Sendable { /// 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 { @@ -37,6 +41,8 @@ enum DetectionError: Error, Sendable { return String(localized: "sources.error.basicAuthRequired") case .basicAuthInvalid: return String(localized: "sources.error.basicAuthInvalid") + case .pipedFrontendDetected: + return String(localized: "sources.error.pipedFrontendDetected") } } } @@ -153,9 +159,32 @@ actor InstanceDetector { 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 + /// `Piped` 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("piped") + return looksLikeHTML && hasPipedTitle + } catch { + return false + } + } + // MARK: - Detection Methods /// Detects if the instance is a Yattee Server with detailed error reporting.