Replace the abandoned RN iOS path with a SwiftUI app (iOS 17+) that reuses the shared TipTap editor bundle, GRDB local mirror/outbox, and Android-aligned shell chrome for list, create, detail, and Me. Package the editor HTML in the app resources so Markdown renders, present edit from the workspace root with a UIKit FAB, and keep caret stable while typing.
75 lines
2.9 KiB
Swift
75 lines
2.9 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Keychain-backed storage with UserDefaults fallback when Keychain is unavailable
|
|
/// (unsigned simulator installs, missing entitlements, etc.).
|
|
enum KeychainStore {
|
|
static let service = "org.edgeever.mobile"
|
|
|
|
enum Key {
|
|
static let session = "edgeever.mobile.session"
|
|
static let deviceId = "edgeever.mobile.device-id"
|
|
}
|
|
|
|
private static let fallbackPrefix = "edgeever.keychain.fallback."
|
|
|
|
static func setString(_ value: String, forKey key: String) throws {
|
|
let data = Data(value.utf8)
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
var add = query
|
|
add[kSecValueData as String] = data
|
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let status = SecItemAdd(add as CFDictionary, nil)
|
|
if status == errSecSuccess {
|
|
UserDefaults.standard.removeObject(forKey: fallbackPrefix + key)
|
|
return
|
|
}
|
|
// Fallback for environments where Keychain is restricted.
|
|
UserDefaults.standard.set(value, forKey: fallbackPrefix + key)
|
|
if status != errSecDuplicateItem && status != errSecInteractionNotAllowed && status != errSecMissingEntitlement {
|
|
// Still record unusual codes but do not fail the app bootstrap path.
|
|
#if DEBUG
|
|
print("KeychainStore.setString fallback status=\(status) key=\(key)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
static func string(forKey key: String) throws -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
if status == errSecSuccess, let data = item as? Data, let value = String(data: data, encoding: .utf8) {
|
|
return value
|
|
}
|
|
if status == errSecItemNotFound || status != errSecSuccess {
|
|
return UserDefaults.standard.string(forKey: fallbackPrefix + key)
|
|
}
|
|
return UserDefaults.standard.string(forKey: fallbackPrefix + key)
|
|
}
|
|
|
|
static func delete(forKey key: String) throws {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: key,
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
UserDefaults.standard.removeObject(forKey: fallbackPrefix + key)
|
|
}
|
|
}
|
|
|
|
enum KeychainError: Error {
|
|
case unhandled(OSStatus)
|
|
}
|