Skip to content

Commit 08c8d76

Browse files
committed
Add login logic
1 parent 8f36aa5 commit 08c8d76

File tree

2 files changed

+259
-119
lines changed

2 files changed

+259
-119
lines changed

Sources/LyricsServiceUI/Controller/SpotifyLoginController.swift

Lines changed: 0 additions & 119 deletions
This file was deleted.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import AppKit
2+
import WebKit
3+
import KeychainAccess
4+
5+
struct SpotifyAccessToken: Codable {
6+
let accessToken: String
7+
let accessTokenExpirationTimestampMs: TimeInterval
8+
let isAnonymous: Bool
9+
10+
static func accessToken(forCookie cookie: String) async throws -> Self {
11+
let url = URL(string: "https://open.spotify.com/get_access_token?reason=transport&productType=web_player")!
12+
var request = URLRequest(url: url)
13+
request.setValue("sp_dc=\(cookie)", forHTTPHeaderField: "Cookie")
14+
let accessTokenData = try await URLSession.shared.data(for: request).0
15+
return try JSONDecoder().decode(SpotifyAccessToken.self, from: accessTokenData)
16+
}
17+
}
18+
19+
@propertyWrapper
20+
public struct Keychain<T: Codable> {
21+
private let keychain: KeychainAccess.Keychain
22+
23+
public var wrappedValue: T {
24+
set {
25+
do {
26+
keychain[data: key] = try JSONEncoder().encode(newValue)
27+
_cacheWrappedValue = newValue
28+
} catch {
29+
print(error)
30+
}
31+
}
32+
mutating get {
33+
if let _cacheWrappedValue {
34+
return _cacheWrappedValue
35+
} else {
36+
if let data = keychain[data: key],
37+
let value = try? JSONDecoder().decode(T.self, from: data) {
38+
_cacheWrappedValue = value
39+
return value
40+
} else {
41+
return defaultValue
42+
}
43+
}
44+
}
45+
}
46+
47+
private var _cacheWrappedValue: T?
48+
49+
private let defaultValue: T
50+
51+
private let key: String
52+
53+
public init(key: String, service: String, defaultValue: T) {
54+
self.keychain = .init(service: service).synchronizable(true)
55+
self.key = key
56+
self.defaultValue = defaultValue
57+
}
58+
}
59+
60+
public final class SpotifyLoginManager: NSObject, @unchecked Sendable {
61+
public static let shared = SpotifyLoginManager()
62+
63+
/// 确保UI相关操作在主线程
64+
@MainActor
65+
private let loginWindowController = SpotifyLoginWindowController()
66+
67+
private static let keychainDomain = "com.JH.LyricsKit.SpotifyLoginManager"
68+
69+
/// 使用actor保护状态访问
70+
private actor SecureStorage {
71+
@Keychain(key: "cookie", service: SpotifyLoginManager.keychainDomain, defaultValue: nil)
72+
var cookie: String?
73+
74+
@Keychain(key: "accessToken", service: SpotifyLoginManager.keychainDomain, defaultValue: nil)
75+
var accessToken: SpotifyAccessToken?
76+
77+
func getCookie() -> String? {
78+
return cookie
79+
}
80+
81+
func setCookie(_ newCookie: String?) {
82+
cookie = newCookie
83+
}
84+
85+
func getAccessToken() -> SpotifyAccessToken? {
86+
return accessToken
87+
}
88+
89+
func setAccessToken(_ token: SpotifyAccessToken?) {
90+
accessToken = token
91+
}
92+
}
93+
94+
private let secureStorage = SecureStorage()
95+
96+
private var activeTask: Task<Void, Never>?
97+
98+
public var isLogin: Bool {
99+
get async {
100+
return await secureStorage.getCookie() != nil
101+
}
102+
}
103+
104+
public var isAccessible: Bool {
105+
get async {
106+
return await secureStorage.getAccessToken() != nil
107+
}
108+
}
109+
110+
public var accessTokenString: String? {
111+
get async {
112+
return await secureStorage.getAccessToken()?.accessToken
113+
}
114+
}
115+
116+
private override init() {
117+
super.init()
118+
119+
self.activeTask = Task {
120+
if let accessToken = await secureStorage.getAccessToken(),
121+
accessToken.accessTokenExpirationTimestampMs <= Date().timeIntervalSince1970 * 1000,
122+
await secureStorage.getCookie() != nil {
123+
try? await self.requestAccessToken()
124+
}
125+
}
126+
}
127+
128+
deinit {
129+
activeTask?.cancel()
130+
}
131+
132+
public func requestAccessToken() async throws {
133+
guard let cookie = await secureStorage.getCookie() else { return }
134+
let accessToken = try await SpotifyAccessToken.accessToken(forCookie: cookie)
135+
await secureStorage.setAccessToken(accessToken)
136+
}
137+
138+
public func login() async throws {
139+
await MainActor.run {
140+
loginWindowController.showWindow(nil)
141+
loginWindowController.loginViewController.gotoLogin()
142+
}
143+
144+
let cookie = try await withCheckedThrowingContinuation { continuation in
145+
Task { @MainActor in
146+
loginWindowController.loginViewController.didLogin = { cookie in
147+
continuation.resume(returning: cookie)
148+
}
149+
}
150+
}
151+
152+
await secureStorage.setCookie(cookie)
153+
try await requestAccessToken()
154+
}
155+
156+
public func logout() async {
157+
await secureStorage.setCookie(nil)
158+
await secureStorage.setAccessToken(nil)
159+
160+
await MainActor.run {
161+
loginWindowController.showWindow(nil)
162+
loginWindowController.loginViewController.gotoLogout()
163+
}
164+
}
165+
}
166+
167+
public final class SpotifyLoginWindowController: NSWindowController {
168+
public init() {
169+
super.init(window: nil)
170+
}
171+
172+
@available(*, unavailable)
173+
public required init?(coder: NSCoder) {
174+
fatalError("init(coder:) has not been implemented")
175+
}
176+
177+
public override func loadWindow() {
178+
let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable], backing: .buffered, defer: false)
179+
window.title = "Spotify Login"
180+
window.setContentSize(NSSize(width: 800, height: 600))
181+
window.center()
182+
self.window = window
183+
}
184+
185+
lazy var loginViewController = SpotifyLoginViewController()
186+
187+
public override var windowNibName: NSNib.Name? { "" }
188+
189+
public override func windowDidLoad() {
190+
contentViewController = loginViewController
191+
}
192+
}
193+
194+
public final class SpotifyLoginViewController: NSViewController {
195+
private let webView: WKWebView
196+
197+
private static let loginURL = URL(string: "https://accounts.spotify.com/en/login?continue=https%3A%2F%2Fopen.spotify.com%2F")!
198+
199+
private static let logoutURL = URL(string: "https://www.spotify.com/logout/")!
200+
201+
public var didLogin: ((String) -> Void)?
202+
203+
public init() {
204+
self.webView = WKWebView(frame: .zero, configuration: .init())
205+
super.init(nibName: nil, bundle: nil)
206+
}
207+
208+
@available(*, unavailable)
209+
public required init?(coder: NSCoder) {
210+
fatalError("init(coder:) has not been implemented")
211+
}
212+
213+
public override func loadView() {
214+
view = webView
215+
}
216+
217+
public override func viewDidLoad() {
218+
super.viewDidLoad()
219+
gotoLogin()
220+
}
221+
222+
public func gotoLogin() {
223+
webView.load(.init(url: Self.loginURL))
224+
}
225+
226+
public func gotoLogout() {
227+
webView.load(.init(url: Self.logoutURL))
228+
}
229+
}
230+
231+
extension SpotifyLoginViewController: WKNavigationDelegate {
232+
public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
233+
guard let url = webView.url else { return }
234+
if url.absoluteString.starts(with: "https://open.spotify.com") {
235+
Task.detached {
236+
if let cookie = await WKWebsiteDataStore.default().spotifyCookie() {
237+
await MainActor.run {
238+
self.didLogin?(cookie)
239+
}
240+
}
241+
}
242+
}
243+
}
244+
}
245+
246+
extension WKWebsiteDataStore {
247+
public func spotifyCookie() async -> String? {
248+
let cookies = await httpCookieStore.allCookies()
249+
if let temporaryCookie = cookies.first(where: { $0.name == "sp_dc" }) {
250+
return temporaryCookie.value
251+
}
252+
return nil
253+
}
254+
}
255+
256+
@available(macOS 14.0, *)
257+
#Preview {
258+
SpotifyLoginViewController()
259+
}

0 commit comments

Comments
 (0)