diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 5f71d962..4be42aaf 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -204,8 +204,9 @@ 50BE5D522850F4D700156FC6 /* HelperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B798B323B8664700551E62 /* HelperTest.swift */; }; 50BE5D532850F4E700156FC6 /* MusicPlayerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5095F98B23C8389E008B0805 /* MusicPlayerTest.swift */; }; 50BE5D542850F4E700156FC6 /* SubsonicVersionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */; }; + B5C7DE010000000000000001 /* CaptivePortalDetectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */; }; + B5C7DE010000000000000003 /* CaptivePortalSessionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */; }; 50BE5D552850F4E700156FC6 /* UtilitiesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5090963726496A9500DD9826 /* UtilitiesTest.swift */; }; - B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; 50BE5D562850F4E700156FC6 /* PlayQueueHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5011712D27453D1300B7C08D /* PlayQueueHandlerTest.swift */; }; 50BE5D572850F4F600156FC6 /* album_missing_artistId.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */; }; 50BE5D582850F4F600156FC6 /* artist_example_1.xml in Resources */ = {isa = PBXBuildFile; fileRef = 50AB92C526661F5800DCE45C /* artist_example_1.xml */; }; @@ -312,6 +313,9 @@ 50C9D644284FA9C7007F18D0 /* BackendProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500B2B302249607B00D14EF4 /* BackendProxy.swift */; }; 50C9D645284FA9C7007F18D0 /* BackendApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086F87A22397B8400546F5A /* BackendApi.swift */; }; 50C9D646284FA9C7007F18D0 /* LoginCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */; }; + 7146AB03077769531B516A68 /* CaptivePortalDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */; }; + A5F13F6A02387E878E94F964 /* CaptivePortalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE16295A61BFA531345994AE /* CaptivePortalSession.swift */; }; + 6036AB4E5C3D8AA06B7D3AC5 /* CaptivePortalAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */; }; 50C9D647284FAA02007F18D0 /* SsMusicFolderParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DEEDFF265D46CB0073FF20 /* SsMusicFolderParserDelegate.swift */; }; 50C9D648284FAA02007F18D0 /* SsPingParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5086F884223A1E4600546F5A /* SsPingParserDelegate.swift */; }; 50C9D649284FAA02007F18D0 /* SubsonicVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E964A925E8E20E00E3210F /* SubsonicVersion.swift */; }; @@ -530,10 +534,11 @@ 632F51262C83A8400032860D /* LyricsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632F51252C83A8400032860D /* LyricsVC.swift */; }; 6333C8E12C6FDB5200CCA50A /* SecondaryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */; }; 637A28B52C79409C0082FACC /* MiniPlayerSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */; }; - C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; 638920F02C8C8E9000932EE8 /* SettingsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638920EF2C8C8E9000932EE8 /* SettingsList.swift */; }; 63DCDB7A2C674B5D00522F68 /* SettingsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */; }; 641708592C13BF5100BA2619 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641708582C13BF5100BA2619 /* Haptics.swift */; }; + B5A5CE010000000000000001 /* ShareSongActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CE010000000000000002 /* ShareSongActionTest.swift */; }; + C0A1B0032EEE000000000003 /* MacWindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B0022EEE000000000002 /* MacWindowHelper.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -840,7 +845,6 @@ 509001C027182A1300A8056D /* CatalogParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserTest.swift; sourceTree = ""; }; 509001C227182A4700A8056D /* CatalogParserDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogParserDelegate.swift; sourceTree = ""; }; 5090963726496A9500DD9826 /* UtilitiesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesTest.swift; sourceTree = ""; }; - B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; 509357512E3BF47800CD4075 /* MiniPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerView.swift; sourceTree = ""; }; 509362EC28E041FF005C2AAC /* Amperfy v28.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v28.xcdatamodel"; sourceTree = ""; }; 50947E0227DA011F00C368D7 /* ScrobbleEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleEntry.swift; sourceTree = ""; }; @@ -956,6 +960,9 @@ 50BA92EC21CBF45D00E5901D /* AlbumParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumParserDelegate.swift; sourceTree = ""; }; 50BA92ED21CBF45D00E5901D /* ArtistParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArtistParserDelegate.swift; sourceTree = ""; }; 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginCredentials.swift; sourceTree = ""; }; + F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalDetector.swift; sourceTree = ""; }; + AE16295A61BFA531345994AE /* CaptivePortalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalSession.swift; sourceTree = ""; }; + 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalAuthenticator.swift; sourceTree = ""; }; 50BA92F721CC318D00E5901D /* PlayableTableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayableTableCell.swift; sourceTree = ""; }; 50BA92F921CC37F800E5901D /* SongsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongsVC.swift; sourceTree = ""; }; 50BA92FD21CED4AF00E5901D /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; @@ -1103,6 +1110,8 @@ 50E936DA2F100C7800C1C504 /* RatingAppEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingAppEnum.swift; sourceTree = ""; }; 50E964A925E8E20E00E3210F /* SubsonicVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsonicVersion.swift; sourceTree = ""; }; 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsonicVersionTest.swift; sourceTree = ""; }; + B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalDetectorTest.swift; sourceTree = ""; }; + B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptivePortalSessionTest.swift; sourceTree = ""; }; 50ED030C2E2788200092E6DC /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/CoreAudio.framework; sourceTree = DEVELOPER_DIR; }; 50ED2B9027B7AE8500331BF7 /* EventNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventNotificationHandler.swift; sourceTree = ""; }; 50ED2B9227BB932700331BF7 /* album_missing_artistId.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = album_missing_artistId.xml; sourceTree = ""; }; @@ -1148,12 +1157,13 @@ 632F51252C83A8400032860D /* LyricsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsVC.swift; sourceTree = ""; }; 6333C8E02C6FDB5200CCA50A /* SecondaryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryText.swift; sourceTree = ""; }; 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerSceneDelegate.swift; sourceTree = ""; }; - C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; 638920EF2C8C8E9000932EE8 /* SettingsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsList.swift; sourceTree = ""; }; 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSceneDelegate.swift; sourceTree = ""; }; 641708582C13BF5100BA2619 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 643A04712CD29AB20012DEA3 /* Amperfy v39.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v39.xcdatamodel"; sourceTree = ""; }; 64C433822E1391FA0082A165 /* Amperfy v46.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Amperfy v46.xcdatamodel"; sourceTree = ""; }; + B5A5CE010000000000000002 /* ShareSongActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSongActionTest.swift; sourceTree = ""; }; + C0A1B0022EEE000000000002 /* MacWindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindowHelper.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1463,6 +1473,7 @@ isa = PBXGroup; children = ( 508385E721C5965B00C4BB32 /* AppDelegate.swift */, + 857989FA4AA8B31088A135BF /* CaptivePortalAuthenticator.swift */, 5059A31B28A9ED680068E4D2 /* SceneDelegate.swift */, 63DCDB792C674B5D00522F68 /* SettingsSceneDelegate.swift */, 637A28B42C79409B0082FACC /* MiniPlayerSceneDelegate.swift */, @@ -1490,6 +1501,8 @@ children = ( 5086F87A22397B8400546F5A /* BackendApi.swift */, 500B2B302249607B00D14EF4 /* BackendProxy.swift */, + F61D4B85223AB23B952FE958 /* CaptivePortalDetector.swift */, + AE16295A61BFA531345994AE /* CaptivePortalSession.swift */, 50CDF201264BB1C000F83EF6 /* EventLogger.swift */, 5077F954222C58A10099CA91 /* GenericXmlParser.swift */, 50BA92EE21CBF45D00E5901D /* LoginCredentials.swift */, @@ -2142,6 +2155,8 @@ 50DB11A8266536190033BFFA /* Subsonic */, 50DB1180266501EC0033BFFA /* Ampache */, 50E964AE25E8E25E00E3210F /* SubsonicVersionTest.swift */, + B5C7DE010000000000000002 /* CaptivePortalDetectorTest.swift */, + B5C7DE010000000000000004 /* CaptivePortalSessionTest.swift */, ); path = API; sourceTree = ""; @@ -2551,6 +2566,7 @@ 50C171522D10777400C0C53A /* PlaylistAddPlaylistsVC.swift in Sources */, 500B7D4921EDD6250083ED6F /* ViewCreator.swift in Sources */, 508385EA21C5965B00C4BB32 /* LoginVC.swift in Sources */, + 6036AB4E5C3D8AA06B7D3AC5 /* CaptivePortalAuthenticator.swift in Sources */, 507BA5CB2F01CFA800B4EA8B /* OnlineOfflineModeAppEnum.swift in Sources */, 50B137022EFC85D700738475 /* AccountAppEntity.swift in Sources */, 50C9D7152850759B007F18D0 /* AnimatedGradientLayer.swift in Sources */, @@ -2815,6 +2831,8 @@ 50C9D6A5284FAA7F007F18D0 /* CoreDataMigrationStep.swift in Sources */, 50C9D6B9284FAA87007F18D0 /* Artist.swift in Sources */, 50C9D644284FA9C7007F18D0 /* BackendProxy.swift in Sources */, + 7146AB03077769531B516A68 /* CaptivePortalDetector.swift in Sources */, + A5F13F6A02387E878E94F964 /* CaptivePortalSession.swift in Sources */, 50C9D69A284FAA6C007F18D0 /* AbstractLibraryEntityMO+CoreDataClass.swift in Sources */, 50C9D678284FAA6C007F18D0 /* SongMO+CoreDataProperties.swift in Sources */, 50C9D687284FAA6C007F18D0 /* AlbumMO+CoreDataProperties.swift in Sources */, @@ -2850,6 +2868,8 @@ 50BE5D522850F4D700156FC6 /* HelperTest.swift in Sources */, 50BE5D6B2850F4FB00156FC6 /* SsGenreParserTest.swift in Sources */, 50BE5D542850F4E700156FC6 /* SubsonicVersionTest.swift in Sources */, + B5C7DE010000000000000001 /* CaptivePortalDetectorTest.swift in Sources */, + B5C7DE010000000000000003 /* CaptivePortalSessionTest.swift in Sources */, 50BE5D742850F4FB00156FC6 /* SsAlbumMultidiscExample1ParserTest.swift in Sources */, 50BE5D872850F50F00156FC6 /* AbstractAmpacheTest.swift in Sources */, 50BE5D4F2850F4C900156FC6 /* AlbumTest.swift in Sources */, diff --git a/Amperfy/AppDelegate.swift b/Amperfy/AppDelegate.swift index 527863bc..32027506 100644 --- a/Amperfy/AppDelegate.swift +++ b/Amperfy/AppDelegate.swift @@ -87,6 +87,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AmperKit.shared.localNotificationManager }() + private let captivePortalAuthenticator = CaptivePortalAuthenticator() + public func getMeta(_ accountInfo: AccountInfo) -> MetaManager { AmperKit.shared.getMeta(accountInfo) } @@ -263,6 +265,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { os_log("application launch", log: self.log, type: .info) } + CaptivePortalSession.shared.authHandler = captivePortalAuthenticator + storage.applyMultiAccountSettingsUpdateIfNeeded() libraryUpdater.performAccountCleanUpIfNeccessaryInBackground() diff --git a/Amperfy/CaptivePortalAuthenticator.swift b/Amperfy/CaptivePortalAuthenticator.swift new file mode 100644 index 00000000..eacd15ca --- /dev/null +++ b/Amperfy/CaptivePortalAuthenticator.swift @@ -0,0 +1,239 @@ +// +// CaptivePortalAuthenticator.swift +// Amperfy +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import AmperfyKit +import UIKit +import WebKit + +// MARK: - CaptivePortalAuthenticator + +final class CaptivePortalAuthenticator: NSObject, CaptivePortalAuthHandler, @unchecked Sendable { + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws { + if clearSession { + let dataStore = WKWebsiteDataStore.default() + let allTypes = WKWebsiteDataStore.allWebsiteDataTypes() + let records = await dataStore.dataRecords(ofTypes: allTypes) + await dataStore.removeData(ofTypes: allTypes, for: records) + } + + try await showAlertAndAuthenticate(serverURL: serverURL) + } + + @MainActor + private func showAlertAndAuthenticate(serverURL: URL) async throws { + guard let window = AppDelegate.mainSceneDelegate?.window, + let rootVC = window.rootViewController + else { + throw CaptivePortalError.authenticationFailed + } + + let topVC = Self.topViewController(from: rootVC) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(), Error>) in + let alert = UIAlertController( + title: "Network Authentication Required", + message: + "Your server requires network authentication. You'll be redirected to log in.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in + self.presentWebView(serverURL: serverURL, from: topVC, continuation: continuation) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + continuation.resume(throwing: CaptivePortalError.userCancelled) + }) + topVC.present(alert, animated: true) + } + } + + @MainActor + private func presentWebView( + serverURL: URL, + from presenter: UIViewController, + continuation: CheckedContinuation<(), Error> + ) { + let webVC = CaptivePortalWebViewController( + serverURL: serverURL, + continuation: continuation + ) + let navVC = UINavigationController(rootViewController: webVC) + navVC.modalPresentationStyle = .fullScreen + presenter.present(navVC, animated: true) + } + + @MainActor + private static func topViewController(from vc: UIViewController) -> UIViewController { + if let presented = vc.presentedViewController { + return topViewController(from: presented) + } + if let nav = vc as? UINavigationController, let visible = nav.visibleViewController { + return topViewController(from: visible) + } + if let tab = vc as? UITabBarController, let selected = tab.selectedViewController { + return topViewController(from: selected) + } + return vc + } +} + +// MARK: - CaptivePortalWebViewController + +final class CaptivePortalWebViewController: UIViewController, WKNavigationDelegate, + WKUIDelegate { + private let serverURL: URL + private let serverHost: String + private var continuation: CheckedContinuation<(), Error>? + private var webView: WKWebView! + private var didComplete = false + + init(serverURL: URL, continuation: CheckedContinuation<(), Error>) { + self.serverURL = serverURL + self.serverHost = serverURL.host?.lowercased() ?? "" + self.continuation = continuation + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sign In" + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped) + ) + + let config = WKWebViewConfiguration() + config.websiteDataStore = .default() + webView = WKWebView(frame: view.bounds, configuration: config) + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.navigationDelegate = self + webView.uiDelegate = self + view.addSubview(webView) + + webView.load(URLRequest(url: serverURL)) + } + + @objc + private func cancelTapped() { + finishWith(error: CaptivePortalError.userCancelled) + } + + private func finishWith(error: Error?) { + guard !didComplete else { return } + didComplete = true + dismiss(animated: true) { + if let error { + self.continuation?.resume(throwing: error) + self.continuation = nil + } else { + self.copyCookiesToSharedStorage() + } + } + } + + private func copyCookiesToSharedStorage() { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + HTTPCookieStorage.shared.setCookie(cookie) + } + self.continuation?.resume() + self.continuation = nil + } + } + + // MARK: - WKNavigationDelegate + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> () + ) { + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let currentHost = webView.url?.host?.lowercased() else { return } + if currentHost == serverHost { + finishWith(error: nil) + } + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } + finishWith(error: CaptivePortalError.authenticationFailed) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } + finishWith(error: CaptivePortalError.authenticationFailed) + } + + // MARK: - WKUIDelegate + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) + -> WKWebView? { + if navigationAction.targetFrame == nil || !navigationAction.targetFrame!.isMainFrame { + webView.load(navigationAction.request) + } + return nil + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> () + ) { + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }) + present(alert, animated: true) + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> () + ) { + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) }) + alert + .addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) }) + present(alert, animated: true) + } +} diff --git a/Amperfy/Screens/ViewController/LoginVC.swift b/Amperfy/Screens/ViewController/LoginVC.swift index 3cc774e0..bd2a8f8b 100644 --- a/Amperfy/Screens/ViewController/LoginVC.swift +++ b/Amperfy/Screens/ViewController/LoginVC.swift @@ -399,10 +399,25 @@ class LoginVC: UIViewController { Task { @MainActor in do { let meta = self.appDelegate.getMeta(accountInfo) - let authenticatedApiType = try await meta.backendApi.login( - apiType: selectedApiType, - credentials: credentials - ) + let authenticatedApiType: BackenApiType + do { + authenticatedApiType = try await meta.backendApi.login( + apiType: selectedApiType, + credentials: credentials + ) + } catch let error as CaptivePortalError where error == .captivePortalDetected { + guard let serverURL = URL(string: credentials.activeBackendServerUrl) else { + self.showErrorMsg(message: "Invalid server URL") + self.appDelegate.resetMeta(accountInfo) + return + } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + authenticatedApiType = try await meta.backendApi.login( + apiType: selectedApiType, + credentials: credentials + ) + } + credentials.backendApi = authenticatedApiType accountInfo = Account.createInfo(credentials: credentials) meta.backendApi.selectedApi = authenticatedApiType @@ -430,9 +445,13 @@ class LoginVC: UIViewController { mainScene .replaceMainRootViewController(vc: syncVC) } + } catch let error as CaptivePortalError where error == .userCancelled { + self.appDelegate.resetMeta(accountInfo) } catch { if error is AuthenticationError { self.showErrorMsg(message: error.localizedDescription) + } else if error is CaptivePortalError { + self.showErrorMsg(message: error.localizedDescription) } else { self.showErrorMsg(message: "Not able to login!") } diff --git a/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift b/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift index bb82ce4f..bc3207ff 100644 --- a/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift +++ b/Amperfy/SwiftUI/Settings/Account/AccountSettingsView.swift @@ -72,6 +72,9 @@ struct AccountSettingsView: View { } let meta = appDelegate.getMeta(accountInfo) + + CaptivePortalSession.shared.logout() + meta.stopManager() appDelegate.resetMeta(accountInfo) diff --git a/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift b/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift index f1be1578..7d9c734b 100644 --- a/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift +++ b/AmperfyKit/Api/Ampache/AmpacheXmlServerApi.swift @@ -741,11 +741,32 @@ final class AmpacheXmlServerApi: URLCleanser, Sendable { } private func request(url: URL) async throws -> APIDataResponse { - try await withUnsafeThrowingContinuation { continuation in - AF.request(url, method: .get).validate().responseData { response in + let (data, httpResponse) = try await performAFRequest(url: url) + + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: httpResponse, data: data + ) { + guard let serverURLString = credentials.wrappedValue?.activeBackendServerUrl, + let serverURL = URL(string: serverURLString) + else { throw CaptivePortalError.authenticationFailed } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + let (retryData, retryResponse) = try await performAFRequest(url: url) + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: retryResponse, data: retryData + ) { + throw CaptivePortalError.authenticationFailed + } + return try processAFResponse(url: url, data: retryData, httpResponse: retryResponse) + } + return try processAFResponse(url: url, data: data, httpResponse: httpResponse) + } + + private func performAFRequest(url: URL) async throws -> (Data, HTTPURLResponse?) { + try await withUnsafeThrowingContinuation { continuation in + AF.request(url, method: .get).responseData { response in if let data = response.data { - continuation.resume(returning: APIDataResponse(data: data, url: url)) + continuation.resume(returning: (data, response.response)) return } if let err = response.error { @@ -757,6 +778,22 @@ final class AmpacheXmlServerApi: URLCleanser, Sendable { } } + private func processAFResponse( + url: URL, data: Data, httpResponse: HTTPURLResponse? + ) throws + -> APIDataResponse { + if let statusCode = httpResponse?.statusCode, statusCode >= 400 { + throw ResponseError( + type: .api, + statusCode: statusCode, + message: "HTTP Error: \(statusCode)", + cleansedURL: cleanse(url: url), + data: data + ) + } + return APIDataResponse(data: data, url: url) + } + func requesetLibraryMetaData() async throws -> AuthentificationHandshake { try await reauthenticate() } diff --git a/AmperfyKit/Api/BackendProxy.swift b/AmperfyKit/Api/BackendProxy.swift index 2c7d10ed..20bda212 100644 --- a/AmperfyKit/Api/BackendProxy.swift +++ b/AmperfyKit/Api/BackendProxy.swift @@ -321,26 +321,43 @@ public final class BackendProxy: Sendable { let sessionConfig = URLSessionConfiguration.default let session = URLSession(configuration: sessionConfig) let request = URLRequest(url: activeBackendServerUrl) - let task = session.downloadTask(with: request) { tempLocalUrl, response, error in + let task = session.dataTask(with: request) { data, response, error in if let error = error { continuation .resume( throwing: AuthenticationError .downloadError(message: error.localizedDescription) ) - } else { - if let statusCode = (response as? HTTPURLResponse)?.statusCode { - if statusCode >= 400, - // ignore 401 Unauthorized (RFC 7235) status code - // -> Can occur if root website requires http basic authentication, - // but the REST API endpoints are reachable without http basic authentication - statusCode != 401 { - continuation - .resume(throwing: AuthenticationError.requestStatusError(message: "\(statusCode)")) - } else { - continuation.resume() - } + return + } + + let httpResponse = response as? HTTPURLResponse + + // For the root URL reachability check, only detect captive portal + // via domain redirect (the server redirected us to a different host). + // Content-Type checks are not appropriate here because the server's + // root page legitimately returns HTML. + if CaptivePortalDetector.isDomainRedirect( + requestURL: activeBackendServerUrl, + response: httpResponse + ) { + continuation.resume(throwing: CaptivePortalError.captivePortalDetected) + return + } + + if let statusCode = httpResponse?.statusCode { + if statusCode >= 400, + // ignore 401 Unauthorized (RFC 7235) status code + // -> Can occur if root website requires http basic authentication, + // but the REST API endpoints are reachable without http basic authentication + statusCode != 401 { + continuation + .resume(throwing: AuthenticationError.requestStatusError(message: "\(statusCode)")) + } else { + continuation.resume() } + } else { + continuation.resume() } } task.resume() diff --git a/AmperfyKit/Api/CaptivePortalDetector.swift b/AmperfyKit/Api/CaptivePortalDetector.swift new file mode 100644 index 00000000..16a0bbca --- /dev/null +++ b/AmperfyKit/Api/CaptivePortalDetector.swift @@ -0,0 +1,67 @@ +// +// CaptivePortalDetector.swift +// AmperfyKit +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import Foundation + +public enum CaptivePortalDetector { + public static func isCaptivePortalResponse( + requestURL: URL, + response: HTTPURLResponse?, + data: Data? + ) + -> Bool { + guard let response else { return false } + + if isDomainRedirect(requestURL: requestURL, response: response) { + return true + } + + if isHTMLContentType(response: response) { + return true + } + + if response.statusCode == 403, let data, isHTMLContent(data: data) { + return true + } + + return false + } + + public static func isHTMLContent(data: Data) -> Bool { + let prefix = String(data: data.prefix(500), encoding: .utf8)?.lowercased() ?? "" + return prefix.contains(" Bool { + guard let response else { return false } + guard let responseURL = response.url else { return false } + guard let requestHost = requestURL.host?.lowercased(), + let responseHost = responseURL.host?.lowercased() + else { return false } + return requestHost != responseHost + } + + private static func isHTMLContentType(response: HTTPURLResponse) -> Bool { + guard let contentType = response.value(forHTTPHeaderField: "Content-Type")?.lowercased() + else { return false } + return contentType.contains("text/html") + } +} diff --git a/AmperfyKit/Api/CaptivePortalSession.swift b/AmperfyKit/Api/CaptivePortalSession.swift new file mode 100644 index 00000000..4b46f16b --- /dev/null +++ b/AmperfyKit/Api/CaptivePortalSession.swift @@ -0,0 +1,118 @@ +// +// CaptivePortalSession.swift +// AmperfyKit +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import Foundation + +// MARK: - CaptivePortalAuthHandler + +public protocol CaptivePortalAuthHandler: AnyObject, Sendable { + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws +} + +// MARK: - CaptivePortalError + +public enum CaptivePortalError: LocalizedError, Equatable { + case captivePortalDetected + case noAuthHandler + case authenticationFailed + case userCancelled + + public var errorDescription: String? { + switch self { + case .captivePortalDetected: + return "Server is behind a network authentication portal." + case .noAuthHandler: + return "Captive portal auth handler not configured." + case .authenticationFailed: + return "Network authentication failed. Please try again." + case .userCancelled: + return "Authentication was cancelled." + } + } +} + +// MARK: - CaptivePortalSession + +public final class CaptivePortalSession: @unchecked Sendable { + public static let shared = CaptivePortalSession() + + private let lock = NSLock() + private var _authHandler: (any CaptivePortalAuthHandler)? + private var _needsSessionClear = false + private var activeAuthTask: Task<(), Error>? + + public var authHandler: (any CaptivePortalAuthHandler)? { + get { lock.withLock { _authHandler } } + set { lock.withLock { _authHandler = newValue } } + } + + private init() {} + + @MainActor + public func authenticate(serverURL: URL) async throws { + let existingTask: Task<(), Error>? = lock.withLock { activeAuthTask } + if let existingTask { + try await existingTask.value + return + } + + let clearSession = lock.withLock { + let val = _needsSessionClear + _needsSessionClear = false + return val + } + + let task = Task { @MainActor in + guard let handler = self.authHandler else { + throw CaptivePortalError.noAuthHandler + } + try await handler.performCaptivePortalAuth(serverURL: serverURL, clearSession: clearSession) + } + lock.withLock { activeAuthTask = task } + + do { + try await task.value + lock.withLock { activeAuthTask = nil } + } catch { + lock.withLock { activeAuthTask = nil } + throw error + } + } + + /// Call on explicit logout. Clears HTTPCookieStorage immediately and marks + /// WKWebView data for clearing on the next auth attempt. + public func logout() { + HTTPCookieStorage.shared.cookies?.forEach { cookie in + HTTPCookieStorage.shared.deleteCookie(cookie) + } + lock.withLock { _needsSessionClear = true } + } + + #if DEBUG + public func resetForTesting() { + lock.withLock { + _needsSessionClear = false + activeAuthTask = nil + } + } + #endif +} diff --git a/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift b/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift index 47fd6ea9..452d785f 100644 --- a/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift +++ b/AmperfyKit/Api/Subsonic/SubsonicServerApi.swift @@ -957,24 +957,34 @@ final class SubsonicServerApi: URLCleanser, Sendable { } private func request(url: URL) async throws -> APIDataResponse { + let (data, httpResponse) = try await performAFRequest(url: url) + + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: httpResponse, data: data + ) { + guard let serverURLString = credentials.wrappedValue?.activeBackendServerUrl, + let serverURL = URL(string: serverURLString) + else { throw CaptivePortalError.authenticationFailed } + try await CaptivePortalSession.shared.authenticate(serverURL: serverURL) + let (retryData, retryResponse) = try await performAFRequest(url: url) + if CaptivePortalDetector.isCaptivePortalResponse( + requestURL: url, response: retryResponse, data: retryData + ) { + throw CaptivePortalError.authenticationFailed + } + return try processAFResponse(url: url, data: retryData, httpResponse: retryResponse) + } + + return try processAFResponse(url: url, data: data, httpResponse: httpResponse) + } + + private func performAFRequest(url: URL) async throws -> (Data, HTTPURLResponse?) { try await withUnsafeThrowingContinuation { continuation in - let afRequest = AF.request(url, method: .get) - afRequest.validate().responseData { response in - if response.response?.statusCode == 404 { - let cleanedURL = self.cleanse(url: response.request?.url) - os_log("API 404: Not Found: %s", log: self.log, type: .info, cleanedURL.description) - let notFoundError = ResponseError( - type: .api, - statusCode: SubsonicError.requestedDataNotFound.rawValue, - message: "404: Not Found", - cleansedURL: cleanedURL, - data: response.data - ) - continuation.resume(throwing: notFoundError) - return - } + AF.request(url, method: .get).responseData { response in if let data = response.data { - continuation.resume(returning: APIDataResponse(data: data, url: url)) + continuation.resume( + returning: (data, response.response) + ) return } if let err = response.error { @@ -985,4 +995,31 @@ final class SubsonicServerApi: URLCleanser, Sendable { } } } + + private func processAFResponse( + url: URL, data: Data, httpResponse: HTTPURLResponse? + ) throws + -> APIDataResponse { + if httpResponse?.statusCode == 404 { + let cleanedURL = cleanse(url: url) + os_log("API 404: Not Found: %s", log: self.log, type: .info, cleanedURL.description) + throw ResponseError( + type: .api, + statusCode: SubsonicError.requestedDataNotFound.rawValue, + message: "404: Not Found", + cleansedURL: cleanedURL, + data: data + ) + } + if let statusCode = httpResponse?.statusCode, statusCode >= 400 { + throw ResponseError( + type: .api, + statusCode: statusCode, + message: "HTTP Error: \(statusCode)", + cleansedURL: cleanse(url: url), + data: data + ) + } + return APIDataResponse(data: data, url: url) + } } diff --git a/AmperfyKit/Download/PlayableDownloadDelegate.swift b/AmperfyKit/Download/PlayableDownloadDelegate.swift index aa791075..120d3099 100644 --- a/AmperfyKit/Download/PlayableDownloadDelegate.swift +++ b/AmperfyKit/Download/PlayableDownloadDelegate.swift @@ -84,6 +84,14 @@ final class PlayableDownloadDelegate: DownloadManagerDelegate { url: fileURL, maxFileSize: Self.maxFileSizeOfErrorResponse ) else { return nil } + if CaptivePortalDetector.isHTMLContent(data: data) { + return ResponseError( + type: .api, + message: "Network session expired. Please open the app to re-authenticate.", + cleansedURL: downloadURL?.asCleansedURL(cleanser: backendApi), + data: data + ) + } return backendApi.checkForErrorResponse(response: APIDataResponse( data: data, url: downloadURL diff --git a/AmperfyKit/Player/BackendAudioPlayer.swift b/AmperfyKit/Player/BackendAudioPlayer.swift index 7893bbca..b31751d4 100644 --- a/AmperfyKit/Player/BackendAudioPlayer.swift +++ b/AmperfyKit/Player/BackendAudioPlayer.swift @@ -639,14 +639,31 @@ class BackendAudioPlayer: NSObject { return } + let headers = Self.cookieHeaders(for: asset.url) + switch queueType { case .play: currentPreparedUrl = asset.url.absoluteString - player?.play(url: asset.url) + if headers.isEmpty { + player?.play(url: asset.url) + } else { + player?.play(url: asset.url, headers: headers) + } case .queue: nextPreloadedUrl = asset.url.absoluteString - player?.queue(url: asset.url) + if headers.isEmpty { + player?.queue(url: asset.url) + } else { + player?.queue(url: asset.url, headers: headers) + } + } + } + + private static func cookieHeaders(for url: URL) -> [String: String] { + guard let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty else { + return [:] } + return HTTPCookie.requestHeaderFields(with: cookies) } // MARK: - EQ Implementation diff --git a/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift b/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift new file mode 100644 index 00000000..b1980fdc --- /dev/null +++ b/AmperfyKitTests/Cases/API/CaptivePortalDetectorTest.swift @@ -0,0 +1,238 @@ +// +// CaptivePortalDetectorTest.swift +// AmperfyKitTests +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +@testable import AmperfyKit +import XCTest + +class CaptivePortalDetectorTest: XCTestCase { + let serverURL = URL(string: "https://music.example.com/api/ping")! + + override func setUp() {} + + override func tearDown() {} + + // MARK: - isCaptivePortalResponse + + func testNilResponse() { + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: nil, data: nil + ) + XCTAssertFalse(result) + } + + func testNormalJsonResponse() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "{\"status\":\"ok\"}".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func testNormalXmlResponse() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "application/xml"] + ) + let data = "".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func testHtmlContentTypeDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "text/html; charset=utf-8"] + ) + let data = "Login".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertTrue(result) + } + + func testHtmlContentTypeUppercaseDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, + httpVersion: nil, headerFields: ["Content-Type": "TEXT/HTML"] + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertTrue(result) + } + + func testDomainRedirectDetected() { + let redirectURL = URL(string: "https://auth.cloudflare.com/login")! + let response = HTTPURLResponse( + url: redirectURL, statusCode: 302, + httpVersion: nil, headerFields: nil + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertTrue(result) + } + + func test403WithHtmlBodyDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/octet-stream"] + ) + let data = "Access Denied".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertTrue(result) + } + + func test403WithNonHtmlBodyNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "{\"error\":\"forbidden\"}".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + func test403WithNilDataNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 403, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: nil + ) + XCTAssertFalse(result) + } + + func testNon403ErrorWithHtmlBodyNotDetected() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 500, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + ) + let data = "Server Error".data(using: .utf8) + let result = CaptivePortalDetector.isCaptivePortalResponse( + requestURL: serverURL, response: response, data: data + ) + XCTAssertFalse(result) + } + + // MARK: - isHTMLContent + + func testIsHTMLContentDoctype() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentDoctypeUppercase() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentHtmlTag() { + let data = "test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentWithLeadingWhitespace() { + let data = " \n test".data(using: .utf8)! + XCTAssertTrue(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentXml() { + let data = "".data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentJson() { + let data = "{\"status\":\"ok\",\"version\":\"1.16.1\"}".data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentEmptyData() { + let data = Data() + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + func testIsHTMLContentOnlyChecksPrefix() { + var content = String(repeating: "x", count: 600) + content += "" + let data = content.data(using: .utf8)! + XCTAssertFalse(CaptivePortalDetector.isHTMLContent(data: data)) + } + + // MARK: - isDomainRedirect + + func testDomainRedirectSameHost() { + let response = HTTPURLResponse( + url: serverURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } + + func testDomainRedirectDifferentHost() { + let redirectURL = URL(string: "https://idp.example.com/authorize")! + let response = HTTPURLResponse( + url: redirectURL, statusCode: 302, httpVersion: nil, headerFields: nil + ) + XCTAssertTrue( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } + + func testDomainRedirectNilResponse() { + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: nil) + ) + } + + func testDomainRedirectCaseInsensitive() { + let requestURL = URL(string: "https://Music.Example.COM/api")! + let responseURL = URL(string: "https://music.example.com/api")! + let response = HTTPURLResponse( + url: responseURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: requestURL, response: response) + ) + } + + func testDomainRedirectDifferentPath() { + let responseURL = URL(string: "https://music.example.com/different/path")! + let response = HTTPURLResponse( + url: responseURL, statusCode: 200, httpVersion: nil, headerFields: nil + ) + XCTAssertFalse( + CaptivePortalDetector.isDomainRedirect(requestURL: serverURL, response: response) + ) + } +} diff --git a/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift b/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift new file mode 100644 index 00000000..5f842384 --- /dev/null +++ b/AmperfyKitTests/Cases/API/CaptivePortalSessionTest.swift @@ -0,0 +1,192 @@ +// +// CaptivePortalSessionTest.swift +// AmperfyKitTests +// +// Created by Jerzy Królak on 07.05.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +@testable import AmperfyKit +import XCTest + +// MARK: - MOCK_CaptivePortalAuthHandler + +final class MOCK_CaptivePortalAuthHandler: CaptivePortalAuthHandler, @unchecked Sendable { + var authCallCount = 0 + var lastServerURL: URL? + var lastClearSession = false + var shouldThrow: CaptivePortalError? + + @MainActor + func performCaptivePortalAuth(serverURL: URL, clearSession: Bool) async throws { + authCallCount += 1 + lastServerURL = serverURL + lastClearSession = clearSession + if let error = shouldThrow { + throw error + } + } +} + +// MARK: - CaptivePortalSessionTest + +@MainActor +class CaptivePortalSessionTest: XCTestCase { + var session: CaptivePortalSession! + var mockHandler: MOCK_CaptivePortalAuthHandler! + + override func setUp() { + session = CaptivePortalSession.shared + session.resetForTesting() + mockHandler = MOCK_CaptivePortalAuthHandler() + session.authHandler = mockHandler + } + + override func tearDown() { + session.authHandler = nil + } + + // MARK: - authenticate + + func testAuthenticateCallsHandler() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertEqual(mockHandler.authCallCount, 1) + XCTAssertEqual(mockHandler.lastServerURL, url) + } + + func testAuthenticateNoHandlerThrows() async { + session.authHandler = nil + let url = URL(string: "https://music.example.com")! + do { + try await session.authenticate(serverURL: url) + XCTFail("Expected error") + } catch let error as CaptivePortalError { + XCTAssertEqual(error, .noAuthHandler) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testAuthenticateForwardsHandlerError() async { + mockHandler.shouldThrow = .authenticationFailed + let url = URL(string: "https://music.example.com")! + do { + try await session.authenticate(serverURL: url) + XCTFail("Expected error") + } catch let error as CaptivePortalError { + XCTAssertEqual(error, .authenticationFailed) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + // MARK: - logout + + func testLogoutClearsCookies() { + let cookie = HTTPCookie(properties: [ + .name: "CF_Authorization", + .value: "test-token", + .domain: "music.example.com", + .path: "/", + ])! + HTTPCookieStorage.shared.setCookie(cookie) + + let beforeCount = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" && $0.domain == "music.example.com" + }.count ?? 0 + XCTAssertEqual(beforeCount, 1) + + session.logout() + + let afterCount = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" && $0.domain == "music.example.com" + }.count ?? 0 + XCTAssertEqual(afterCount, 0) + } + + func testLogoutClearsAllCookies() { + let serverCookie = HTTPCookie(properties: [ + .name: "CF_Authorization", + .value: "test-token", + .domain: "music.example.com", + .path: "/", + ])! + let auth0Cookie = HTTPCookie(properties: [ + .name: "auth0_session", + .value: "session-value", + .domain: "login.auth0.com", + .path: "/", + ])! + HTTPCookieStorage.shared.setCookie(serverCookie) + HTTPCookieStorage.shared.setCookie(auth0Cookie) + + session.logout() + + let remaining = HTTPCookieStorage.shared.cookies?.filter { + $0.name == "CF_Authorization" || $0.name == "auth0_session" + } ?? [] + XCTAssertEqual(remaining.count, 0) + } + + // MARK: - clearSession flag + + func testNoClearSessionByDefault() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + func testClearSessionAfterLogout() async throws { + let url = URL(string: "https://music.example.com")! + session.logout() + try await session.authenticate(serverURL: url) + XCTAssertTrue(mockHandler.lastClearSession) + } + + func testClearSessionFlagConsumedAfterAuth() async throws { + let url = URL(string: "https://music.example.com")! + session.logout() + try await session.authenticate(serverURL: url) + XCTAssertTrue(mockHandler.lastClearSession) + + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + func testClearSessionFlagNotSetOnSessionExpiry() async throws { + let url = URL(string: "https://music.example.com")! + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + + try await session.authenticate(serverURL: url) + XCTAssertFalse(mockHandler.lastClearSession) + } + + // MARK: - CaptivePortalError + + func testErrorDescriptions() { + XCTAssertNotNil(CaptivePortalError.captivePortalDetected.errorDescription) + XCTAssertNotNil(CaptivePortalError.noAuthHandler.errorDescription) + XCTAssertNotNil(CaptivePortalError.authenticationFailed.errorDescription) + XCTAssertNotNil(CaptivePortalError.userCancelled.errorDescription) + } + + func testErrorEquality() { + XCTAssertEqual(CaptivePortalError.captivePortalDetected, .captivePortalDetected) + XCTAssertNotEqual(CaptivePortalError.captivePortalDetected, .userCancelled) + } +}