-
Notifications
You must be signed in to change notification settings - Fork 27
Expand file tree
/
Copy pathWebKitManager.swift
More file actions
420 lines (346 loc) Β· 16.1 KB
/
WebKitManager.swift
File metadata and controls
420 lines (346 loc) Β· 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
import Foundation
import os
import WebKit
// MARK: - CookieBackupManager
/// Manages backup storage of auth cookies in Application Support for resilience against WebKit data loss.
/// Uses file-based storage instead of Keychain to avoid code signing issues during development.
enum CookieBackupManager {
private static let logger = DiagnosticsLogger.webKit
/// Returns the URL for the cookie backup file in Application Support.
private static var backupFileURL: URL? {
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
logger.error("Could not find Application Support directory")
return nil
}
let appFolder = appSupport.appendingPathComponent("Kaset", isDirectory: true)
// Create directory if needed
do {
try FileManager.default.createDirectory(
at: appFolder,
withIntermediateDirectories: true
)
} catch {
logger.error("Failed to create Kaset folder: \(error.localizedDescription)")
return nil
}
return appFolder.appendingPathComponent("cookies.dat")
}
/// Saves YouTube auth cookies to file as a backup.
static func backupCookies(_ cookies: [HTTPCookie]) {
// Filter to only YouTube auth cookies
let authCookieNames = Set([
"SAPISID", "__Secure-3PAPISID", "__Secure-1PAPISID",
"SID", "HSID", "SSID", "APISID",
])
let authCookies = cookies.filter { authCookieNames.contains($0.name) }
guard !authCookies.isEmpty else { return }
guard let fileURL = backupFileURL else { return }
// Use NSKeyedArchiver since cookie properties contain Date objects
// that can't be serialized with JSONSerialization
let cookieData = authCookies.compactMap { cookie -> Data? in
guard let properties = cookie.properties else { return nil }
// Convert to [String: Any] for archiving
var stringProperties: [String: Any] = [:]
for (key, value) in properties {
stringProperties[key.rawValue] = value
}
return try? NSKeyedArchiver.archivedData(
withRootObject: stringProperties,
requiringSecureCoding: false
)
}
guard let data = try? NSKeyedArchiver.archivedData(
withRootObject: cookieData,
requiringSecureCoding: false
) else {
self.logger.error("Failed to serialize cookies for backup")
return
}
do {
try data.write(to: fileURL, options: .atomic)
self.logger.debug("Backed up \(authCookies.count) auth cookies to file")
} catch {
self.logger.error("Failed to backup cookies to file: \(error.localizedDescription)")
}
}
/// Restores YouTube auth cookies from file backup.
/// Returns the cookies if found, nil otherwise.
static func restoreCookies() -> [HTTPCookie]? {
guard let fileURL = backupFileURL else { return nil }
guard FileManager.default.fileExists(atPath: fileURL.path) else {
self.logger.info("No cookie backup found (first run or cleared)")
return nil
}
guard let data = try? Data(contentsOf: fileURL),
let cookieDataArray = try? NSKeyedUnarchiver.unarchivedObject(
ofClasses: [NSArray.self, NSData.self],
from: data
) as? [Data]
else {
self.logger.error("Failed to read cookie backup from file")
return nil
}
let cookies = cookieDataArray.compactMap { cookieData -> HTTPCookie? in
guard let stringProperties = try? NSKeyedUnarchiver.unarchivedObject(
ofClasses: [NSDictionary.self, NSString.self, NSDate.self, NSNumber.self],
from: cookieData
) as? [String: Any] else {
return nil
}
// Convert string keys back to HTTPCookiePropertyKey
var convertedProperties: [HTTPCookiePropertyKey: Any] = [:]
for (key, value) in stringProperties {
convertedProperties[HTTPCookiePropertyKey(key)] = value
}
return HTTPCookie(properties: convertedProperties)
}
if !cookies.isEmpty {
self.logger.info("Restored \(cookies.count) auth cookies from file backup")
}
return cookies.isEmpty ? nil : cookies
}
/// Clears the cookie backup file.
static func clearBackup() {
guard let fileURL = backupFileURL else { return }
do {
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
self.logger.info("Cleared cookie backup file")
} catch {
self.logger.error("Failed to clear cookie backup: \(error.localizedDescription)")
}
}
}
// MARK: - WebKitManager
/// Manages WebKit data store for persistent cookies and session management.
@MainActor
@Observable
final class WebKitManager: NSObject, WebKitManagerProtocol {
/// Shared singleton instance.
static let shared = WebKitManager()
/// The persistent website data store used across all WebViews.
let dataStore: WKWebsiteDataStore
/// Timestamp of the last cookie change (for observation).
private(set) var cookiesDidChange: Date = .distantPast
/// Task for debouncing cookie change handling.
private var cookieDebounceTask: Task<Void, Never>?
/// Minimum interval between cookie backup operations (in seconds).
private static let cookieDebounceInterval: Duration = .seconds(5)
/// The YouTube Music origin URL.
static let origin = "https://music.youtube.com"
/// Required cookie name for authentication.
static let authCookieName = "__Secure-3PAPISID"
/// Fallback cookie name (non-secure version).
static let fallbackAuthCookieName = "SAPISID"
/// Custom user agent to appear as Safari to avoid "browser not supported" errors.
static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
private let logger = DiagnosticsLogger.webKit
override private init() {
// Use the default persistent data store
// This is more reliable than custom identifiers as it:
// 1. Is the standard WebKit approach
// 2. Shares cookies with the system's standard location
// 3. Doesn't get reset when WebKit detects issues
self.dataStore = WKWebsiteDataStore.default()
super.init()
// Observe cookie changes
self.dataStore.httpCookieStore.add(self)
// Always restore auth cookies from backup on startup
// File backup is our source of truth since WebKit storage is unreliable
// during development (sandbox container changes with code signing)
Task {
await self.restoreAuthCookiesFromBackup()
}
self.logger.info("WebKitManager initialized with persistent data store")
}
/// Restores auth cookies from file backup to WebKit.
/// File backup is the source of truth - always restore on startup.
private func restoreAuthCookiesFromBackup() async {
// Wait a moment for WebKit to fully initialize
try? await Task.sleep(for: .milliseconds(100))
let existingCookies = await dataStore.httpCookieStore.allCookies()
self.logger.info("WebKit has \(existingCookies.count) cookies on startup")
// Always restore from backup if we have one
guard let backupCookies = CookieBackupManager.restoreCookies() else {
self.logger.info("No cookie backup found (first run or signed out)")
return
}
self.logger.info("Restoring \(backupCookies.count) auth cookies from backup")
// Set each cookie in WebKit
for cookie in backupCookies {
await self.dataStore.httpCookieStore.setCookie(cookie)
}
// Verify restore
let cookies = await dataStore.httpCookieStore.allCookies()
let hasAuth = cookies.contains { $0.name == "SAPISID" || $0.name == "__Secure-3PAPISID" }
if hasAuth {
self.logger.info("β Auth cookies restored from backup (\(cookies.count) total cookies)")
} else {
self.logger.error("β Failed to restore auth cookies - backup may be corrupted")
}
}
/// Creates a WebView configuration using the shared persistent data store.
func createWebViewConfiguration() -> WKWebViewConfiguration {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = self.dataStore
configuration.preferences.isElementFullscreenEnabled = true
configuration.mediaTypesRequiringUserActionForPlayback = []
// Enable AirPlay for streaming to Apple TV, HomePod, etc.
configuration.allowsAirPlayForMediaPlayback = true
return configuration
}
/// Retrieves all cookies from the HTTP cookie store.
func getAllCookies() async -> [HTTPCookie] {
await self.dataStore.httpCookieStore.allCookies()
}
/// Gets cookies for a specific domain.
func getCookies(for domain: String) async -> [HTTPCookie] {
let allCookies = await getAllCookies()
return allCookies.filter { cookie in
domain.hasSuffix(cookie.domain) || cookie.domain.hasSuffix(domain)
}
}
/// Builds a Cookie header string for the given domain.
func cookieHeader(for domain: String) async -> String? {
let cookies = await getCookies(for: domain)
guard !cookies.isEmpty else { return nil }
let headerFields = HTTPCookie.requestHeaderFields(with: cookies)
return headerFields["Cookie"]
}
/// Retrieves the SAPISID cookie value used for authentication.
/// Checks both secure and non-secure cookie variants.
func getSAPISID() async -> String? {
let cookies = await getCookies(for: "youtube.com")
let allCookies = await getAllCookies()
self.logger.debug("Checking for SAPISID - total cookies: \(allCookies.count), youtube.com cookies: \(cookies.count)")
// Try secure cookie first, then fallback to non-secure
let secureCookie = cookies.first { $0.name == Self.authCookieName }
let fallbackCookie = cookies.first { $0.name == Self.fallbackAuthCookieName }
if let cookie = secureCookie ?? fallbackCookie {
// Log cookie expiration for debugging session issues
if let expiresDate = cookie.expiresDate {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
let expiresStr = formatter.string(from: expiresDate)
let isExpired = expiresDate < Date()
self.logger.debug("Found \(cookie.name) cookie, expires: \(expiresStr), expired: \(isExpired)")
if isExpired {
self.logger.warning("Auth cookie has expired!")
return nil
}
} else if cookie.isSessionOnly {
self.logger.debug("Found \(cookie.name) cookie (session-only, no expiration)")
}
return cookie.value
}
let cookieNames = cookies.map(\.name).joined(separator: ", ")
self.logger.debug("No auth cookie found. Available cookies: \(cookieNames)")
return nil
}
/// Checks if the required authentication cookies exist.
func hasAuthCookies() async -> Bool {
let sapisid = await getSAPISID()
return sapisid != nil
}
/// Logs all authentication-related cookies for debugging.
/// Call this when troubleshooting login persistence issues.
func logAuthCookies() async {
let cookies = await getCookies(for: "youtube.com")
let authCookieNames = ["SAPISID", "__Secure-3PAPISID", "SID", "HSID", "SSID", "APISID", "__Secure-1PAPISID"]
self.logger.info("=== Auth Cookie Diagnostic ===")
self.logger.info("Total youtube.com cookies: \(cookies.count)")
for name in authCookieNames {
if let cookie = cookies.first(where: { $0.name == name }) {
let expiry: String
if let date = cookie.expiresDate {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
expiry = formatter.string(from: date)
} else if cookie.isSessionOnly {
expiry = "session-only"
} else {
expiry = "unknown"
}
self.logger.info("β \(name): expires \(expiry)")
} else {
self.logger.info("β \(name): not found")
}
}
self.logger.info("==============================")
}
/// Clears all website data (cookies, cache, etc.).
func clearAllData() async {
let allTypes = WKWebsiteDataStore.allWebsiteDataTypes()
let dateFrom = Date.distantPast
self.logger.info("Clearing all WebKit data")
await self.dataStore.removeData(ofTypes: allTypes, modifiedSince: dateFrom)
// Also clear the Keychain backup
CookieBackupManager.clearBackup()
self.logger.info("WebKit data cleared successfully")
}
/// Forces an immediate backup of all YouTube/Google cookies to Keychain.
/// Call this after successful login to ensure cookies are persisted.
func forceBackupCookies() async {
let cookies = await dataStore.httpCookieStore.allCookies()
self.logger.info("Force backup: found \(cookies.count) total cookies")
// Filter for YouTube/Google auth cookies
let authCookies = cookies.filter { cookie in
let domain = cookie.domain.lowercased()
return domain.hasSuffix("youtube.com") ||
domain.hasSuffix("google.com") ||
domain == ".youtube.com" ||
domain == ".google.com"
}
self.logger.info("Force backup: \(authCookies.count) YouTube/Google cookies to backup")
if !authCookies.isEmpty {
CookieBackupManager.backupCookies(authCookies)
}
}
}
// MARK: WKHTTPCookieStoreObserver
extension WebKitManager: WKHTTPCookieStoreObserver {
nonisolated func cookiesDidChange(in cookieStore: WKHTTPCookieStore) {
Task { @MainActor in
self.cookiesDidChange = Date()
// Debounce cookie backup to avoid excessive writes
// WebKit fires this callback for each individual cookie change,
// which can result in dozens of calls in rapid succession
self.cookieDebounceTask?.cancel()
self.cookieDebounceTask = Task {
do {
try await Task.sleep(for: Self.cookieDebounceInterval)
} catch is CancellationError {
// Task was cancelled (new cookie change came in), skip backup
return
} catch {
// Unexpected error during sleep - log and continue with backup
self.logger.warning("Unexpected error during cookie debounce: \(error.localizedDescription)")
}
// Perform debounced backup
await self.performCookieBackup(cookieStore: cookieStore)
}
}
}
/// Performs the actual cookie backup after debouncing.
private func performCookieBackup(cookieStore: WKHTTPCookieStore) async {
let cookies = await cookieStore.allCookies()
// Filter for YouTube/Google auth cookies
let authCookies = cookies.filter { cookie in
let domain = cookie.domain.lowercased()
// Match youtube.com, .youtube.com, google.com, .google.com
return domain.hasSuffix("youtube.com") ||
domain.hasSuffix("google.com") ||
domain == ".youtube.com" ||
domain == ".google.com"
}
if !authCookies.isEmpty {
CookieBackupManager.backupCookies(authCookies)
}
}
}