From 2e796b08a70b2d2a9b392302af853276d8e558a0 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 23:27:23 +0530 Subject: [PATCH] Atoll: add in-app Spotify login sheet for sp_dc cookie capture Wraps accounts.spotify.com login in a WKWebView so users can sign in without opening the browser inspector to copy the sp_dc cookie. The sheet uses a non-persistent data store, observes the cookie store, and hands the captured value to the existing SpotifyAuthManager.validate flow on a match. Spotify's anti-bot blocks the WebView path for some accounts, so this sits alongside (not replaces) the manual paste field, with an "Open in Browser" escape hatch in the sheet toolbar. Signed-off-by: Jis G Jacob --- DynamicIsland/Localizable.xcstrings | 58 ++-- .../Settings/SpotifyAuthSettingsSection.swift | 49 ++-- .../Settings/SpotifyLoginSheet.swift | 247 ++++++++++++++++++ 3 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 DynamicIsland/components/Settings/SpotifyLoginSheet.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9c4d703..f46d0b3 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,7 +2314,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3150,7 +3149,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3708,7 +3706,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5635,6 +5632,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6028,7 +6029,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6936,7 +6936,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -11679,6 +11678,10 @@ } } }, + "Connect Spotify" : { + "comment" : "A heading for a Spotify login sheet.", + "isCommentAutoGenerated" : true + }, "Connected" : { "extractionState" : "stale", "localizations" : { @@ -14542,7 +14545,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20319,6 +20321,10 @@ } } }, + "Get the cookie manually" : { + "comment" : "A section that provides instructions on how to get the Spotify cookie manually.", + "isCommentAutoGenerated" : true + }, "Get your free API key from Google AI Studio" : { "localizations" : { "fr" : { @@ -20853,7 +20859,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21598,10 +21603,6 @@ } } }, - "How to get it:" : { - "comment" : "A heading for the instructions on how to get the Spotify cookie.", - "isCommentAutoGenerated" : true - }, "https://example.com/animation.json" : { "comment" : "A placeholder text for a Lottie JSON URL input field.", "isCommentAutoGenerated" : true, @@ -29979,7 +29980,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30386,6 +30386,10 @@ } } }, + "Open in Browser" : { + "comment" : "A button that opens Spotify in the user's browser.", + "isCommentAutoGenerated" : true + }, "Open in Calendar" : { "comment" : "A button label that opens a calendar with the event details.", "isCommentAutoGenerated" : true, @@ -30732,7 +30736,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30837,10 +30840,6 @@ "comment" : "A button that copies the `sp_dc` cookie from the clipboard.", "isCommentAutoGenerated" : true }, - "Paste the `sp_dc` cookie from your logged-in Spotify web session." : { - "comment" : "A description of how to paste the Spotify cookie.", - "isCommentAutoGenerated" : true - }, "Pause" : { "localizations" : { "ru" : { @@ -33653,6 +33652,10 @@ } } }, + "Reset Session" : { + "comment" : "A button that clears the Spotify login session.", + "isCommentAutoGenerated" : true + }, "Reset Timer" : { "localizations" : { "ko" : { @@ -34289,7 +34292,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35811,7 +35813,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38247,7 +38248,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38272,7 +38272,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38389,6 +38388,14 @@ } } }, + "Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below." : { + "comment" : "A description of how to sign in to Spotify to get the `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, + "Sign in with Spotify" : { + "comment" : "A button that opens a sheet for signing in with Spotify.", + "isCommentAutoGenerated" : true + }, "Simple and minimal interface with just the essentials for everyday tasks." : { "localizations" : { "ko" : { @@ -38500,7 +38507,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39228,7 +39234,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39255,6 +39260,10 @@ "comment" : "A section header for the Spotify Canvas session settings.", "isCommentAutoGenerated" : true }, + "Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings." : { + "comment" : "A help message for the Spotify login sheet.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -39469,7 +39478,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42297,7 +42305,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45673,7 +45680,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift index 28e7b55..890e14a 100644 --- a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -23,6 +23,7 @@ import SwiftUI struct SpotifyAuthSettingsSection: View { @Default(.spotifySPDCCookie) private var spotifySPDCCookie @ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared + @State private var showingLoginSheet = false private var hasCookie: Bool { !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty @@ -31,10 +32,17 @@ struct SpotifyAuthSettingsSection: View { var body: some View { Section { VStack(alignment: .leading, spacing: 8) { - Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.") + Text("Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below.") .font(.caption) .foregroundStyle(.secondary) + Button { + showingLoginSheet = true + } label: { + Label("Sign in with Spotify", systemImage: "person.crop.circle.badge.checkmark") + } + .buttonStyle(.borderedProminent) + TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical) .textFieldStyle(.roundedBorder) .font(.caption.monospaced()) @@ -77,25 +85,25 @@ struct SpotifyAuthSettingsSection: View { .disabled(!hasCookie && !spotifyAuthManager.isAuthenticated) } - VStack(alignment: .leading, spacing: 6) { - Text("How to get it:") - .font(.caption) - .foregroundStyle(.secondary) + DisclosureGroup("Get the cookie manually") { + VStack(alignment: .leading, spacing: 6) { + Text("1. Open Spotify in a browser and log in") + .font(.caption) + Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") + .font(.caption) + Text("3. Copy the value of `sp_dc` and paste it here") + .font(.caption) - Text("1. Open Spotify in a browser and log in") + HStack(spacing: 12) { + Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) + Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) + } .font(.caption) - Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") - .font(.caption) - Text("3. Copy the value of `sp_dc` and paste it here") - .font(.caption) - - HStack(spacing: 12) { - Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) - Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) } - .font(.caption) + .padding(.top, 4) } - .padding(.top, 2) + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Spotify Canvas Session") } footer: { @@ -103,6 +111,15 @@ struct SpotifyAuthSettingsSection: View { .foregroundStyle(.secondary) .font(.caption) } + .sheet(isPresented: $showingLoginSheet) { + SpotifyLoginSheet { capturedValue in + let sanitized = SpotifyAuthManager.sanitizeCookie(capturedValue) + spotifySPDCCookie = sanitized + Task { + await spotifyAuthManager.validateSession() + } + } + } } private func pasteCookieFromClipboard() { diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift new file mode 100644 index 0000000..7c326f5 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -0,0 +1,247 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * 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 AppKit +import os +import SwiftUI +@preconcurrency import WebKit + +private enum SpotifyLoginConstants { + static let loginURL = URL(string: "https://accounts.spotify.com/en/login")! + static let externalLoginURL = URL(string: "https://open.spotify.com/")! + static let cookieInstructionsURL = URL( + string: "https://github.com/Paxsenix0/Spotify-Canvas-API#how-to-get-sp_dc-cookie" + )! + static let cookieDomainSuffix = "spotify.com" + static let cookieName = "sp_dc" + // Chrome UA — Spotify's anti-bot heuristics block "Safari" WebViews more + // aggressively than they block Chromium-shaped ones in practice. + static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" +} + +private let spotifyLoginLogger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SpotifyLogin") + +struct SpotifyLoginSheet: View { + @Environment(\.dismiss) private var dismiss + + let onCapture: (String) -> Void + + @State private var statusText: String = "Sign in to Spotify to finish setup." + @State private var didCapture = false + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Connect Spotify") + .font(.headline) + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + NSWorkspace.shared.open(SpotifyLoginConstants.externalLoginURL) + } label: { + Label("Open in Browser", systemImage: "safari") + } + .help("Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings.") + Button("Reset Session") { + NotificationCenter.default.post(name: .spotifyLoginSheetReset, object: nil) + statusText = "Session cleared. Sign in again to capture the cookie." + } + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(12) + Divider() + SpotifyLoginWebView { value in + guard !didCapture else { return } + didCapture = true + statusText = "Captured sp_dc cookie. Validating…" + onCapture(value) + dismiss() + } onStatus: { message in + statusText = message + } + } + .frame(minWidth: 520, minHeight: 640) + } +} + +extension Notification.Name { + static let spotifyLoginSheetReset = Notification.Name("SpotifyLoginSheetReset") +} + +struct SpotifyLoginWebView: NSViewRepresentable { + let onCapture: (String) -> Void + let onStatus: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCapture: onCapture, onStatus: onStatus) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + + let webView = FirstClickWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = SpotifyLoginConstants.userAgent + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + + context.coordinator.attach(webView: webView) + webView.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + DispatchQueue.main.async { [weak webView] in + webView?.window?.makeFirstResponder(webView) + } + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { + coordinator.detach() + } + + private final class FirstClickWebView: WKWebView { + // SwiftUI sheets present the WKWebView with the host window already key, but + // AppKit still treats the webview as inactive until something inside it gets + // explicit focus. Without this override the first mouseDown is consumed + // making the view first responder rather than dispatching the click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + } + + @MainActor + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKHTTPCookieStoreObserver { + private let onCapture: (String) -> Void + private let onStatus: (String) -> Void + private weak var webView: WKWebView? + private var resetObserver: NSObjectProtocol? + + init(onCapture: @escaping (String) -> Void, onStatus: @escaping (String) -> Void) { + self.onCapture = onCapture + self.onStatus = onStatus + super.init() + resetObserver = NotificationCenter.default.addObserver( + forName: .spotifyLoginSheetReset, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.resetSession() } + } + } + + deinit { + if let resetObserver { + NotificationCenter.default.removeObserver(resetObserver) + } + } + + func attach(webView: WKWebView) { + self.webView = webView + webView.configuration.websiteDataStore.httpCookieStore.add(self) + } + + func detach() { + webView?.configuration.websiteDataStore.httpCookieStore.remove(self) + webView = nil + } + + private func resetSession() { + guard let webView else { return } + let store = webView.configuration.websiteDataStore + let types = WKWebsiteDataStore.allWebsiteDataTypes() + store.removeData(ofTypes: types, modifiedSince: .distantPast) { [weak self] in + Task { @MainActor in + self?.webView?.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + } + } + } + + // MARK: - WKHTTPCookieStoreObserver + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { [weak self] cookies in + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let host = webView.url?.host ?? "" + spotifyLoginLogger.debug("didFinish host=\(host, privacy: .public)") + if host.contains("accounts.spotify.com") { + onStatus("Sign in to your Spotify account.") + } else if host.contains("open.spotify.com") { + onStatus("Open Spotify shouldn't load here — capture should fire before this. Falling back.") + } else if host.contains("www.spotify.com") || host.contains("spotify.com") { + onStatus("Logged in. Finishing capture…") + } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + let names = cookies + .filter { $0.domain.hasSuffix("spotify.com") } + .map { "\($0.name)@\($0.domain)" } + .joined(separator: ", ") + spotifyLoginLogger.debug("cookies after load: \(names, privacy: .public)") + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + // Allow pop-ups (e.g. SSO providers) to load inline in the same webview. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil, let url = navigationAction.request.url { + webView.load(URLRequest(url: url)) + } + return nil + } + + // MARK: - Capture + + private func handle(cookies: [HTTPCookie]) { + let match = cookies.first { cookie in + cookie.name == SpotifyLoginConstants.cookieName + && cookie.domain.hasSuffix(SpotifyLoginConstants.cookieDomainSuffix) + } + guard let match, !match.value.isEmpty else { return } + onCapture(match.value) + } + } +}