diff --git a/scarf/Scarf iOS/Skills/SkillsListView.swift b/scarf/Scarf iOS/Skills/SkillsListView.swift index 650ed96..015f0f7 100644 --- a/scarf/Scarf iOS/Skills/SkillsListView.swift +++ b/scarf/Scarf iOS/Skills/SkillsListView.swift @@ -89,6 +89,25 @@ private struct SkillDetailView: View { .textSelection(.enabled) } + if skill.name.lowercased() == "spotify" { + Section("Authentication") { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("Spotify needs OAuth") + .font(.callout.weight(.medium)) + Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "music.note") + .foregroundStyle(.green) + } + .padding(.vertical, 4) + } + } + if !skill.files.isEmpty { Section("Files") { ForEach(skill.files, id: \.self) { file in diff --git a/scarf/scarf/Core/Services/SpotifyAuthFlow.swift b/scarf/scarf/Core/Services/SpotifyAuthFlow.swift new file mode 100644 index 0000000..38d2380 --- /dev/null +++ b/scarf/scarf/Core/Services/SpotifyAuthFlow.swift @@ -0,0 +1,212 @@ +import Foundation +import AppKit +import os +import ScarfCore + +/// Drives `hermes auth spotify` for the Spotify skill (Hermes v2026.4.23). +/// +/// Spotify uses OAuth 2.0 authorization-code flow with a local-callback +/// listener: Hermes prints a `https://accounts.spotify.com/authorize?...` +/// URL, the user approves in their browser, Spotify redirects back to a +/// local server Hermes spun up, Hermes catches the code, exchanges it for +/// a token, and writes the result to `~/.hermes/auth.json`. +/// +/// The flow: +/// +/// 1. Spawn hermes via `context.makeTransport().makeProcess(...)`. +/// 2. Stream stdout/stderr; regex-detect the auth URL on whatever line +/// Hermes prints it (we're permissive — match any `accounts.spotify.com/authorize` +/// so log-format changes between minor versions don't break us). +/// 3. Auto-open the URL in the default browser; transition to +/// `.waitingForApproval` so the sheet can show a manual fallback. +/// 4. On subprocess exit 0, poll `~/.hermes/auth.json` for +/// `providers.spotify.access_token`. The exit code alone isn't +/// proof — auth could fail mid-callback and exit 0 anyway. +/// 5. Surface clear errors for cancellation / missing binary / token +/// not landing. +/// +/// Mirrors `NousAuthFlow` in shape so future "auth provider X" sheets +/// can lift the pattern without re-deriving the lifecycle handling. +@Observable +@MainActor +final class SpotifyAuthFlow { + enum State: Equatable { + case idle + case starting + case waitingForApproval(authorizeURL: URL) + case verifying + case success + case failure(reason: String) + } + + private(set) var state: State = .idle + /// Accumulated stdout/stderr — surfaced in the failure UI for bug reports. + private(set) var output: String = "" + + let context: ServerContext + private let logger = Logger(subsystem: "com.scarf", category: "SpotifyAuthFlow") + + private var process: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var pollTask: Task? + + init(context: ServerContext = .local) { + self.context = context + } + + // MARK: - Lifecycle + + /// Start the sign-in flow. Cancels any in-flight subprocess first. + func start() { + cancel() + output = "" + state = .starting + + let proc = context.makeTransport().makeProcess( + executable: context.paths.hermesBinary, + args: ["auth", "spotify"] + ) + if !context.isRemote { + var env = HermesFileService.enrichedEnvironment() + // Force unbuffered Python stdout so the auth URL flushes + // immediately. Same reasoning as NousAuthFlow. + env["PYTHONUNBUFFERED"] = "1" + proc.environment = env + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + self.process = proc + self.stdoutPipe = stdoutPipe + self.stderrPipe = stderrPipe + + do { + try proc.run() + } catch { + state = .failure(reason: "Couldn't start `hermes auth spotify`: \(error.localizedDescription)") + return + } + + // Stream both pipes into `output`; URL detection on every chunk. + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + Task { @MainActor [weak self] in + self?.absorb(text) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + Task { @MainActor [weak self] in + self?.absorb(text) + } + } + + proc.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in + self?.handleTermination(exitCode: terminated.terminationStatus) + } + } + } + + /// Cancel the in-flight subprocess, if any. Idempotent. + func cancel() { + pollTask?.cancel() + pollTask = nil + if let p = process, p.isRunning { + p.terminate() + } + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + try? stdoutPipe?.fileHandleForReading.close() + try? stderrPipe?.fileHandleForReading.close() + try? stdoutPipe?.fileHandleForWriting.close() + try? stderrPipe?.fileHandleForWriting.close() + process = nil + stdoutPipe = nil + stderrPipe = nil + } + + // MARK: - Output handling + + private func absorb(_ text: String) { + output += text + // Detect the OAuth authorize URL on first sight. + if case .starting = state, let url = Self.detectAuthorizeURL(in: output) { + state = .waitingForApproval(authorizeURL: url) + NSWorkspace.shared.open(url) + } + } + + /// Match any `accounts.spotify.com/authorize` URL in the buffer. + /// Permissive on purpose — log-format changes between Hermes minors + /// shouldn't break us. Returns nil if no match found yet. + nonisolated static func detectAuthorizeURL(in text: String) -> URL? { + let pattern = #"https://accounts\.spotify\.com/authorize\?[^\s)\"']+"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + let urlRange = Range(match.range, in: text) + else { return nil } + return URL(string: String(text[urlRange])) + } + + // MARK: - Termination + + private func handleTermination(exitCode: Int32) { + guard exitCode == 0 else { + // Cancelled by us, or hermes returned non-zero. + if state == .starting || state == .verifying { + state = .failure(reason: "Spotify auth exited with status \(exitCode). Last log:\n\(Self.tail(output, lines: 6))") + } + return + } + // Verify the token actually landed in auth.json. + state = .verifying + pollTask = Task { @MainActor [weak self] in + guard let self else { return } + let path = context.paths.authJSON + let transport = context.makeTransport() + // Three quick polls — auth.json is written synchronously by + // hermes before exit, so this almost always lands on the + // first read; the retries cover NFS / SFTP write barriers. + for _ in 0..<3 { + if Task.isCancelled { return } + if Self.authJSONHasSpotifyToken(path: path, transport: transport) { + state = .success + return + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + state = .failure(reason: "Hermes exited cleanly but no Spotify token landed in \(path).") + } + } + + /// Return true when `auth.json` contains a non-empty + /// `providers.spotify.access_token`. False on read failure, parse + /// failure, or absent token — caller treats as "not signed in". + nonisolated static func authJSONHasSpotifyToken( + path: String, + transport: any ServerTransport + ) -> Bool { + guard let data = try? transport.readFile(path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let providers = json["providers"] as? [String: Any], + let spotify = providers["spotify"] as? [String: Any], + let token = spotify["access_token"] as? String, + !token.isEmpty + else { return false } + return true + } + + /// Last `lines` lines of a string buffer, used in failure messages. + nonisolated static func tail(_ s: String, lines: Int) -> String { + let parts = s.split(separator: "\n", omittingEmptySubsequences: false) + let tail = parts.suffix(lines) + return tail.joined(separator: "\n") + } +} diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index b29e065..f2a26c9 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -3,6 +3,8 @@ import ScarfCore struct SkillsView: View { @State private var viewModel: SkillsViewModel + @State private var showSpotifySignIn: Bool = false + @Environment(\.serverContext) private var serverContext @State private var currentTab: Tab = .installed init(context: ServerContext) { @@ -134,6 +136,14 @@ struct SkillsView: View { .background(.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) } + // v2.5 Spotify auth affordance — only when this skill + // is the spotify one. We don't probe auth.json here + // (transport read is async); the button always shows + // and the sheet itself handles the "already signed in?" + // case (token present → succeeds immediately on retry). + if skill.name.lowercased() == "spotify" { + spotifyAuthRow + } Divider() if !skill.files.isEmpty { VStack(alignment: .leading, spacing: 4) { @@ -182,12 +192,44 @@ struct SkillsView: View { .sheet(isPresented: $viewModel.isEditing) { skillEditorSheet } + .sheet(isPresented: $showSpotifySignIn) { + SpotifySignInSheet(onSignedIn: { + // No state to refresh in this view yet — chat picks + // up the new token on next session start. Keep the + // hook so a future "auth status" indicator can rebind. + }) + } } else { ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list")) .frame(maxWidth: .infinity, maxHeight: .infinity) } } + /// Renders the v2.5 Spotify auth row when the user has the + /// `spotify` skill selected. Tapping opens `SpotifySignInSheet` + /// which drives `hermes auth spotify` end-to-end in-app. + private var spotifyAuthRow: some View { + HStack(spacing: 10) { + Image(systemName: "music.note") + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 2) { + Text("Sign in to Spotify") + .font(.callout.weight(.medium)) + Text("Authorise Hermes to control playback, search, and library actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Sign In") { showSpotifySignIn = true } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.green.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + private var skillEditorSheet: some View { VStack(spacing: 0) { HStack { diff --git a/scarf/scarf/Features/Skills/Views/SpotifySignInSheet.swift b/scarf/scarf/Features/Skills/Views/SpotifySignInSheet.swift new file mode 100644 index 0000000..7b4c86b --- /dev/null +++ b/scarf/scarf/Features/Skills/Views/SpotifySignInSheet.swift @@ -0,0 +1,195 @@ +import SwiftUI +import AppKit + +/// In-app sign-in sheet for the Spotify skill (Hermes v2026.4.23+). +/// Hosts a `SpotifyAuthFlow` and renders one of five sub-views keyed +/// on `flow.state`. Reached from the Skills sidebar (when the spotify +/// skill is selected and not yet authenticated) and from any future +/// "Auxiliary providers" surface. +/// +/// UX contract with the caller: +/// - Sheet presented via `.sheet(isPresented:)`. +/// - Parent owns the binding. +/// - `onSignedIn` fires on `.success` so callers can refresh whatever +/// view was showing the "not authed" affordance. +/// +/// Mirrors `NousSignInSheet` (v2.3) in shape — same lifecycle, same +/// patience model, same auto-dismiss-on-success. +struct SpotifySignInSheet: View { + @Environment(\.serverContext) private var serverContext + @Environment(\.dismiss) private var dismiss + + var onSignedIn: () -> Void = {} + + @State private var flow: SpotifyAuthFlow? + @State private var successDismissTask: Task? + + var body: some View { + VStack(spacing: 16) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(20) + .frame(minWidth: 440, idealWidth: 440, minHeight: 320) + .onAppear { + if flow == nil { + let f = SpotifyAuthFlow(context: serverContext) + flow = f + f.start() + } + } + .onDisappear { + successDismissTask?.cancel() + flow?.cancel() + } + .onChange(of: flowState) { _, newValue in + if case .success = newValue { + onSignedIn() + successDismissTask?.cancel() + successDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_200_000_000) + if !Task.isCancelled { dismiss() } + } + } + } + } + + // Captures `flow.state` so `.onChange(of:)` works (Equatable) without + // forcing the whole flow into the change closure (it isn't Equatable). + private var flowState: SpotifyAuthFlow.State { + flow?.state ?? .idle + } + + // MARK: - Subviews + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "music.note") + .foregroundStyle(.green) + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("Sign in to Spotify") + .font(.headline) + Text("Authorise Hermes to control your Spotify account.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Cancel") { + flow?.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + + @ViewBuilder + private var content: some View { + switch flow?.state ?? .idle { + case .idle, .starting: + startingView + case .waitingForApproval(let url): + waitingView(url: url) + case .verifying: + verifyingView + case .success: + successView + case .failure(let reason): + failureView(reason: reason) + } + } + + private var startingView: some View { + VStack(spacing: 12) { + ProgressView() + Text("Starting `hermes auth spotify`…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func waitingView(url: URL) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Waiting for browser approval…") + .font(.callout) + } + Text("Scarf opened the authorisation URL in your default browser. Sign in with your Spotify account and approve the requested permissions to complete sign-in.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Text(url.absoluteString) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + .controlSize(.small) + Button("Open") { + NSWorkspace.shared.open(url) + } + .controlSize(.small) + } + .padding(8) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var verifyingView: some View { + VStack(spacing: 10) { + ProgressView() + Text("Verifying token…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var successView: some View { + VStack(spacing: 10) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 36)) + .foregroundStyle(.green) + Text("Spotify connected") + .font(.headline) + Text("You can now use the spotify skill from chat.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func failureView(reason: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Sign-in failed") + .font(.headline) + Spacer() + } + Text(reason) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button("Try again") { + flow?.start() + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +}