Surface clearer error when adding a Piped frontend URL

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 `<title>Piped</title>`; if matched, return a new
`pipedFrontendDetected` error that tells the user to enter the
Piped API URL instead.
This commit is contained in:
Arkadiusz Fal
2026-05-06 21:14:50 +02:00
parent e3f4d764cc
commit 411fcba037
2 changed files with 40 additions and 0 deletions

View File

@@ -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" : { "sources.error.couldNotDetect" : {
"comment" : "Error when source type could not be detected", "comment" : "Error when source type could not be detected",
"localizations" : { "localizations" : {

View File

@@ -20,6 +20,10 @@ enum DetectionError: Error, Sendable {
/// Detection was retried with HTTP Basic Auth credentials but the server still /// Detection was retried with HTTP Basic Auth credentials but the server still
/// returned 401 the credentials are invalid. /// returned 401 the credentials are invalid.
case basicAuthInvalid 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 { var localizedDescription: String {
switch self { switch self {
@@ -37,6 +41,8 @@ enum DetectionError: Error, Sendable {
return String(localized: "sources.error.basicAuthRequired") return String(localized: "sources.error.basicAuthRequired")
case .basicAuthInvalid: case .basicAuthInvalid:
return String(localized: "sources.error.basicAuthInvalid") return String(localized: "sources.error.basicAuthInvalid")
case .pipedFrontendDetected:
return String(localized: "sources.error.pipedFrontendDetected")
} }
} }
} }
@@ -153,9 +159,32 @@ actor InstanceDetector {
if sawUnauthorized { if sawUnauthorized {
return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid) return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid)
} }
if await looksLikePipedFrontend(url: url, extraHeaders: extraHeaders) {
return .failure(.pipedFrontendDetected)
}
return .failure(.unknownType) 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 // MARK: - Detection Methods
/// Detects if the instance is a Yattee Server with detailed error reporting. /// Detects if the instance is a Yattee Server with detailed error reporting.