Skip to content

Commit 0260755

Browse files
🆙 FIXUP: Save Spotify access token to keychain 🔐
1 parent cf4f08c commit 0260755

File tree

2 files changed

+120
-1
lines changed

2 files changed

+120
-1
lines changed

Sources/Control/Keychain.swift

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// Keychain.swift
3+
// ControlKit
4+
//
5+
6+
import OSLog
7+
import Security
8+
9+
public extension Control {
10+
11+
/// Wrapper for device keychain aka ``Security/SecItem`` 🔐
12+
@propertyWrapper
13+
public struct Keychain<T: Codable> {
14+
15+
private let key: String
16+
private let defaultValue: T
17+
18+
public init(_ key: String, default: T) {
19+
self.key = key
20+
self.defaultValue = `default`
21+
}
22+
23+
public var wrappedValue: T {
24+
get {
25+
KeychainHelper.retrieveValue(for: key, as: T.self) ?? defaultValue
26+
}
27+
set {
28+
KeychainHelper.save(newValue, for: key)
29+
}
30+
}
31+
}
32+
}
33+
34+
extension Control {
35+
36+
public enum KeychainHelper {
37+
38+
/// Save a value to the keychain.
39+
public static func save<T: Codable>(_ value: T, for key: String) {
40+
do {
41+
let data = try encoder.encode(value)
42+
43+
// Delete any existing item before adding a new one
44+
deleteValue(for: key)
45+
46+
let attributes = [
47+
kSecClass: kSecClassGenericPassword,
48+
kSecAttrAccount: key,
49+
kSecValueData: data,
50+
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
51+
] as CFDictionary
52+
SecItemAdd(attributes, nil)
53+
} catch {
54+
log.error("Failed to save value: \(error.localizedDescription)")
55+
}
56+
}
57+
58+
/// Retrieve a value from the keychain.
59+
public static func retrieveValue<T: Codable>(for key: String, as type: T.Type) -> T? {
60+
let query = [
61+
kSecClass: kSecClassGenericPassword,
62+
kSecAttrAccount: key,
63+
kSecReturnData: true,
64+
kSecMatchLimit: kSecMatchLimitOne,
65+
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
66+
] as CFDictionary
67+
68+
var result: AnyObject?
69+
let status = SecItemCopyMatching(query, &result)
70+
71+
do {
72+
guard
73+
status == errSecSuccess,
74+
let data = result as? Data
75+
else {
76+
throw Error.secItemCopyMatching(status)
77+
}
78+
return try decoder.decode(T.self, from: data)
79+
} catch {
80+
log.error("Failed to retrieve value: \(error.localizedDescription)")
81+
return nil
82+
}
83+
}
84+
85+
/// Delete a value from the keychain.
86+
public static func deleteValue(for key: String) {
87+
let query = [
88+
kSecClass: kSecClassGenericPassword,
89+
kSecAttrAccount: key,
90+
kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked
91+
] as CFDictionary
92+
SecItemDelete(query)
93+
}
94+
}
95+
}
96+
97+
private extension Control.KeychainHelper {
98+
99+
static let decoder = JSONDecoder()
100+
static let encoder = JSONEncoder()
101+
static let log = Logger(
102+
subsystem: Control.subsystem,
103+
category: "Keychain"
104+
)
105+
106+
enum Error: LocalizedError {
107+
108+
case secItemCopyMatching(_ status: OSStatus)
109+
110+
var errorDescription: String? {
111+
switch self {
112+
113+
case .secItemCopyMatching(let status):
114+
"SecItemCopyMatching failed with status: \(status)"
115+
}
116+
}
117+
}
118+
}

Sources/Controllers/SpotifyController.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// ControlKit
44
//
55

6+
import Control
67
import OSLog
78
import SpotifyiOS
89
import SwiftUI
@@ -26,7 +27,7 @@ public final class SpotifyController: NSObject, ObservableObject {
2627
return remote
2728
}()
2829

29-
@AppStorage("SpotifyAccessToken")
30+
@Control.Keychain("SpotifyAccessToken", default: nil)
3031
private var accessToken: String?
3132

3233
override public init() {

0 commit comments

Comments
 (0)