-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
288 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
55 changes: 55 additions & 0 deletions
55
Sources/AppState/Application/Types/Application+SecureState.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import Security | ||
import Foundation | ||
|
||
extension Application { | ||
public var keychain: Dependency<Keychain> { | ||
dependency(Keychain()) | ||
} | ||
|
||
public struct SecureState { | ||
@AppDependency(\.keychain) private var keychain: Keychain | ||
|
||
/// The initial value of the state. | ||
private var initial: () -> String? | ||
|
||
/// The current state value. | ||
public var value: String? { | ||
get { | ||
guard | ||
let storedValue = keychain.get(scope.key, as: String.self) | ||
else { return initial() } | ||
|
||
return storedValue | ||
} | ||
set { | ||
guard let newValue else { | ||
return keychain.remove(scope.key) | ||
} | ||
|
||
keychain.set(value: newValue, forKey: scope.key) | ||
} | ||
} | ||
|
||
/// The scope in which this state exists. | ||
let scope: Scope | ||
|
||
/** | ||
Creates a new state within a given scope initialized with the provided value. | ||
|
||
- Parameters: | ||
- value: The initial value of the state | ||
- scope: The scope in which the state exists | ||
*/ | ||
init( | ||
initial: @escaping @autoclosure () -> String?, | ||
scope: Scope | ||
) { | ||
self.initial = initial | ||
self.scope = scope | ||
} | ||
|
||
public mutating func reset() { | ||
value = initial() | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import Foundation | ||
import Combine | ||
import SwiftUI | ||
|
||
@propertyWrapper public struct SecureState: DynamicProperty { | ||
/// Holds the singleton instance of `Application`. | ||
@ObservedObject private var app: Application = Application.shared | ||
|
||
/// Path for accessing `SecureState` from Application. | ||
private let keyPath: KeyPath<Application, Application.SecureState> | ||
|
||
private let fileID: StaticString | ||
private let function: StaticString | ||
private let line: Int | ||
private let column: Int | ||
|
||
/// Represents the current value of the `SecureState`. | ||
public var wrappedValue: String? { | ||
get { | ||
Application.secureState( | ||
keyPath, | ||
fileID, | ||
function, | ||
line, | ||
column | ||
).value | ||
} | ||
nonmutating set { | ||
let debugMessage: String | ||
|
||
#if DEBUG | ||
debugMessage = "🔑 Setting SecureState \(String(describing: keyPath)) = \(String(describing: newValue))" | ||
#else | ||
debugMessage = "🔑 Setting SecureState \(String(describing: keyPath))" | ||
#endif | ||
|
||
Application.log( | ||
debug: debugMessage, | ||
fileID: fileID, | ||
function: function, | ||
line: line, | ||
column: column | ||
) | ||
|
||
var state = app.value(keyPath: keyPath) | ||
state.value = newValue | ||
} | ||
} | ||
|
||
/// A binding to the `State`'s value, which can be used with SwiftUI views. | ||
public var projectedValue: Binding<String?> { | ||
Binding( | ||
get: { wrappedValue }, | ||
set: { wrappedValue = $0 } | ||
) | ||
} | ||
|
||
/** | ||
Initializes the AppState with a `keyPath` for accessing `SecureState` in Application. | ||
|
||
- Parameter keyPath: The `KeyPath` for accessing `SecureState` in Application. | ||
*/ | ||
public init( | ||
_ keyPath: KeyPath<Application, Application.SecureState>, | ||
_ fileID: StaticString = #fileID, | ||
_ function: StaticString = #function, | ||
_ line: Int = #line, | ||
_ column: Int = #column | ||
) { | ||
self.keyPath = keyPath | ||
self.fileID = fileID | ||
self.function = function | ||
self.line = line | ||
self.column = column | ||
} | ||
|
||
/// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes | ||
public static subscript<OuterSelf: ObservableObject>( | ||
_enclosingInstance observed: OuterSelf, | ||
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, String?>, | ||
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self> | ||
) -> String? { | ||
get { | ||
observed[keyPath: storageKeyPath].wrappedValue | ||
} | ||
set { | ||
guard | ||
let publisher = observed.objectWillChange as? ObservableObjectPublisher | ||
else { return } | ||
|
||
publisher.send() | ||
observed[keyPath: storageKeyPath].wrappedValue = newValue | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import SwiftUI | ||
import XCTest | ||
@testable import AppState | ||
|
||
fileprivate extension Application { | ||
var secureValue: SecureState { | ||
secureState(id: "secureState") | ||
} | ||
} | ||
|
||
fileprivate struct ExampleStoredValue { | ||
@SecureState(\.secureValue) var token: String? | ||
} | ||
|
||
fileprivate class ExampleStoringViewModel: ObservableObject { | ||
@SecureState(\.secureValue) var token: String? | ||
|
||
func testPropertyWrapper() { | ||
token = "QWERTY" | ||
_ = Picker("Picker", selection: $token, content: EmptyView.init) | ||
} | ||
} | ||
|
||
final class SecureStateTests: XCTestCase { | ||
override class func setUp() { | ||
Application.logging(isEnabled: true) | ||
} | ||
|
||
override class func tearDown() { | ||
Application.logger.debug("StoredStateTests \(Application.description)") | ||
} | ||
|
||
func testStoredState() { | ||
XCTAssertNil(Application.secureState(\.secureValue).value) | ||
|
||
let secureValue = ExampleStoredValue() | ||
|
||
XCTAssertEqual(secureValue.token, nil) | ||
|
||
secureValue.token = "QWERTY" | ||
|
||
XCTAssertEqual(secureValue.token, "QWERTY") | ||
|
||
Application.logger.debug("StoredStateTests \(Application.description)") | ||
|
||
secureValue.token = nil | ||
|
||
XCTAssertNil(Application.secureState(\.secureValue).value) | ||
} | ||
|
||
func testStoringViewModel() { | ||
XCTAssertNil(Application.secureState(\.secureValue).value) | ||
|
||
let viewModel = ExampleStoringViewModel() | ||
|
||
XCTAssertEqual(viewModel.token, nil) | ||
|
||
viewModel.testPropertyWrapper() | ||
|
||
XCTAssertEqual(viewModel.token, "QWERTY") | ||
|
||
Application.reset(secureState: \.secureValue) | ||
|
||
XCTAssertNil(viewModel.token) | ||
} | ||
} |