Add some support for signer requests

This commit is contained in:
2025-01-25 10:19:07 -05:00
parent e495d10d92
commit aae25543e0
16 changed files with 372 additions and 153 deletions

View File

@@ -436,6 +436,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Yeti/Preview Content\"";
DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -466,6 +467,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Yeti/Preview Content\"";
DEVELOPMENT_TEAM = S99A5B637C;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;

View File

@@ -10,12 +10,10 @@ import SwiftData
@Model
final class NostrClientModel {
@Attribute(.unique) var id: String
@Attribute(.unique) var name: String
@Relationship(deleteRule: .cascade) var signEventPermissions: [SignEventPermissionModel] = []
var signingPolicy: SigningPolicy?
var readPublicKeyPermission: Bool = false
var nip04EncryptPermission: Bool = false
var nip44EncryptPermission: Bool = false
@@ -24,7 +22,7 @@ final class NostrClientModel {
var getRelaysPermission: Bool = false
var decryptZapEventPermission: Bool = false
init(id: String) {
self.id = id
init(name: String) {
self.name = name
}
}

View File

@@ -19,3 +19,11 @@ final class ProfileSettingsModel {
self.publicKey = publicKey
}
}
extension ProfileSettingsModel {
static func predicateByPublicKey(_ publicKey: String) -> Predicate<ProfileSettingsModel> {
#Predicate<ProfileSettingsModel> { profileSettingsModel in
profileSettingsModel.publicKey == publicKey
}
}
}

View File

@@ -11,10 +11,10 @@ import SwiftData
@Model
final class SignEventPermissionModel {
var kind: Int
var allowed: Bool
var approved: Bool
init(kind: Int, allowed: Bool) {
init(kind: Int, approved: Bool) {
self.kind = kind
self.allowed = allowed
self.approved = approved
}
}

View File

@@ -0,0 +1,68 @@
//
// SignerRequestModel.swift
// Yeti
//
// Created by Terry Yiu on 1/22/25.
//
import Foundation
import SwiftData
@Model
final class SignerRequestModel {
var type: NostrSignerType
var returnType: NostrSignerReturnType
var compressionType: NostrSignerCompressionType
var createdAt: Date
var callbackURL: String?
var pubkey: String?
var sourceApplication: String?
var targetApplication: String?
var approved: Bool?
var decidedAt: Date?
init(
type: NostrSignerType,
returnType: NostrSignerReturnType,
compressionType: NostrSignerCompressionType,
createdAt: Date,
callbackURL: String? = nil,
pubkey: String? = nil,
sourceApplication: String? = nil,
targetApplication: String? = nil,
approved: Bool? = nil,
decidedAt: Date? = nil
) {
self.type = type
self.returnType = returnType
self.compressionType = compressionType
self.createdAt = createdAt
self.callbackURL = callbackURL
self.pubkey = pubkey
self.sourceApplication = sourceApplication
self.targetApplication = targetApplication
self.approved = approved
self.decidedAt = decidedAt
}
}
enum NostrSignerType: String, Codable, CaseIterable {
case getPublicKey = "get_public_key"
case signEvent = "sign_event"
case nip04Encrypt = "nip04_encrypt"
case nip44Encrypt = "nip44_encrypt"
case nip04Decrypt = "nip04_decrypt"
case nip44Decrypt = "nip44_decrypt"
case getRelays = "get_relays"
case decryptZapEvent = "decrypt_zap_event"
}
enum NostrSignerReturnType: String, Codable {
case signature
case event
}
enum NostrSignerCompressionType: String, Codable {
case none
case gzip
}

View File

@@ -7,9 +7,9 @@
import Foundation
enum SigningPolicy: Int, Codable, CaseIterable {
case basic = 0
case manual = 1
enum SigningPolicy: Codable, CaseIterable {
case basic
case manual
var name: String {
switch self {

View File

@@ -7,9 +7,15 @@
"Add your key" : {
"comment" : "Title of view to add the users private key."
},
"Allow" : {
"comment" : "Button to allow a permission request."
},
"Approve basic actions" : {
"comment" : "Name of event signing policy that approves basic actions."
},
"Approved: %@" : {
"comment" : "Text indicating that a signer request is either approved or not approved."
},
"Create a key" : {
"comment" : "Button to create a key."
},
@@ -22,6 +28,9 @@
"Decrypt zap events" : {
"comment" : "Permission toggle to allow Nostr client to decrypt zap events."
},
"Deny" : {
"comment" : "Button to deny a permission request."
},
"Done" : {
"comment" : "Button to go to the next view that adds the users entered private key."
},
@@ -49,9 +58,15 @@
"Next" : {
"comment" : "Button to go to the next view that adds the users entered private key."
},
"Nothing to approve yet" : {
"comment" : "Text on Home tab to indicate that there are no permission requests to review for approval."
},
"nsec / private key" : {
"comment" : "Prompt asking user to enter in a Nostr private key."
},
"Pending" : {
"comment" : "Text indicating that a signer request is pending approval."
},
"Permissions" : {
"comment" : "Title for Permissions tab"
},
@@ -76,6 +91,9 @@
"Sign kind %@ events" : {
"comment" : "Permission toggle to allow Nostr client to sign events of a specific kind.."
},
"Why not explore your favorite Nostr app a bit?" : {
"comment" : "Text on Home tab to suggest what the user could do instead\nsince there are no permission requests to review for approval."
},
"Yeti: Nostr Helper" : {
"comment" : "Application title."
},

View File

@@ -6,9 +6,29 @@
//
import Foundation
import SwiftData
import UIKit
class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
private static let nostrSignerURLScheme = "nostrsigner"
private let modelContainer: ModelContainer
override init() {
let schema = Schema([
GeneralSettingsModel.self,
ProfileSettingsModel.self,
SignerRequestModel.self
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
@@ -26,32 +46,29 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
}
private func scene(_ scene: UIScene, openURLContext: UIOpenURLContext) {
let sendingAppID = openURLContext.options.sourceApplication
let url = openURLContext.url
var generalSettingsModelDescriptor = FetchDescriptor<GeneralSettingsModel>()
generalSettingsModelDescriptor.fetchLimit = 1
guard let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true),
components.scheme == "nostrsigner",
let queryItems = components.queryItems else {
print("Invalid URL or path missing")
guard let generalSettingsModel = try? modelContainer.mainContext.fetch(generalSettingsModelDescriptor).first,
let activePublicKey = generalSettingsModel.activePublicKey
else {
return
}
print("source application = \(sendingAppID ?? "Unknown")")
print("url = \(url)")
let sourceApplication = openURLContext.options.sourceApplication
let url = openURLContext.url
if let path = components.path {
print("path = \(path)")
guard let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true),
components.scheme == SceneDelegate.nostrSignerURLScheme,
let queryItems = components.queryItems else {
print("Invalid URL or path missing. \(url.absoluteString)")
return
}
if let host = components.host {
print("host = \(host)")
}
print("queryItems = \(queryItems)")
let params = queryItems.reduce(into: [:]) { $0[$1.name] = $1.value }
let compressionType: NostrSignerCompressionType
if let rawCompressionType = params["compressionType"] {
if let rawCompressionType = params[NostrSignerURLQueryItemName.compressionType.rawValue] {
if let maybeCompressionType = NostrSignerCompressionType(rawValue: rawCompressionType) {
compressionType = maybeCompressionType
} else {
@@ -61,44 +78,52 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
compressionType = .none
}
guard let rawType = params["type"],
guard let rawType = params[NostrSignerURLQueryItemName.type.rawValue],
let type = NostrSignerType(rawValue: rawType),
let rawReturnType = params["returnType"],
let returnType = NostrSignerReturnType(rawValue: rawReturnType) else {
let rawReturnType = params[NostrSignerURLQueryItemName.returnType.rawValue],
let returnType = NostrSignerReturnType(rawValue: rawReturnType)
else {
return
}
print("type = \(type)")
print("returnType = \(returnType)")
print("compressionType = \(compressionType)")
let callbackURLString = params[NostrSignerURLQueryItemName.callbackURL.rawValue]
let targetApplication = targetApplication(callbackURLString)
if let pubkey = params["pubkey"] {
print("pubkey = \(pubkey)")
}
let pubkey = params[NostrSignerURLQueryItemName.pubkey.rawValue]
if let callbackURL = params["callbackUrl"] {
print("callbackURL = \(callbackURL)")
let signerRequestModel = SignerRequestModel(
type: type,
returnType: returnType,
compressionType: compressionType,
createdAt: Date.now,
callbackURL: callbackURLString,
pubkey: pubkey,
sourceApplication: sourceApplication,
targetApplication: targetApplication
)
modelContainer.mainContext.insert(signerRequestModel)
}
private func targetApplication(_ callbackURLString: String?) -> String? {
if let callbackURLString, let callbackURL = URL(string: callbackURLString) {
if let scheme = callbackURL.scheme {
let lowercasedScheme = scheme.lowercased()
if lowercasedScheme == "http" || lowercasedScheme == "https" {
return callbackURL.host()
} else {
return lowercasedScheme
}
}
}
return nil
}
}
enum NostrSignerType: String, CaseIterable {
case getPublicKey = "get_public_key"
case signEvent = "sign_event"
case nip04Encrypt = "nip04_encrypt"
case nip44Encrypt = "nip44_encrypt"
case nip04Decrypt = "nip04_decrypt"
case nip44Decrypt = "nip44_decrypt"
case getRelays = "get_relays"
case decryptZapEvent = "decrypt_zap_event"
}
enum NostrSignerReturnType: String, CaseIterable {
case signature
case event
}
enum NostrSignerCompressionType: String, CaseIterable {
case none
case gzip
private enum NostrSignerURLQueryItemName: String {
case callbackURL = "callbackUrl"
case compressionType
case pubkey
case returnType
case type
}

View File

@@ -14,10 +14,10 @@ struct ContentView: View {
var body: some View {
Group {
if generalSettingsModels.first!.activePublicKey == nil {
OnboardingView()
if let activePublicKey = generalSettingsModels.first?.activePublicKey {
LoggedInView(publicKey: activePublicKey)
} else {
LoggedInView()
OnboardingView()
}
}
}
@@ -25,5 +25,5 @@ struct ContentView: View {
#Preview {
ContentView()
.modelContainer(for: ProfileSettingsModel.self, inMemory: true)
.modelContainer(for: GeneralSettingsModel.self, inMemory: true)
}

View File

@@ -5,11 +5,40 @@
// Created by Terry Yiu on 1/20/25.
//
import SwiftData
import SwiftUI
struct HistoryView: View {
@Query(sort: \SignerRequestModel.createdAt, order: .reverse) private var signerRequestModels: [SignerRequestModel]
var body: some View {
EmptyView()
List {
ForEach(signerRequestModels, id: \.self) { signerRequestModel in
Section {
VStack {
Text(signerRequestModel.type.rawValue)
Text(signerRequestModel.returnType.rawValue)
Text(signerRequestModel.compressionType.rawValue)
Text(signerRequestModel.createdAt.description)
Text(signerRequestModel.callbackURL ?? "Unknown callbackURL")
Text(signerRequestModel.pubkey ?? "Unknown pubkey")
Text(signerRequestModel.sourceApplication ?? "Unknown sourceApplication")
Text(signerRequestModel.targetApplication ?? "Unknown targetApplication")
switch signerRequestModel.approved {
case .none:
Text("Pending", comment: "Text indicating that a signer request is pending approval.")
case .some(let approved):
Text(
"Approved: \(approved.description)",
comment: "Text indicating that a signer request is either approved or not approved.")
}
Text(signerRequestModel.decidedAt?.description ?? "Unknown decisionTimestamp")
}
}
}
}
}
}

View File

@@ -5,11 +5,72 @@
// Created by Terry Yiu on 1/20/25.
//
import SwiftData
import SwiftUI
struct HomeView: View {
@Query(
filter: #Predicate<SignerRequestModel> { signerRequestModel in
signerRequestModel.approved == nil
},
sort: \SignerRequestModel.createdAt, order: .reverse
)
private var signerRequestModels: [SignerRequestModel]
var body: some View {
EmptyView()
if let signerRequestModel = signerRequestModels.first {
Section {
VStack {
Text(signerRequestModel.type.rawValue)
Text(signerRequestModel.returnType.rawValue)
Text(signerRequestModel.compressionType.rawValue)
Text(signerRequestModel.createdAt.description)
Text(signerRequestModel.callbackURL ?? "Unknown callbackURL")
Text(signerRequestModel.pubkey ?? "Unknown pubkey")
Text(signerRequestModel.sourceApplication ?? "Unknown sourceApplication")
Text(signerRequestModel.targetApplication ?? "Unknown targetApplication")
HStack {
Button(
String(localized: "Allow", comment: "Button to allow a permission request."),
systemImage: "checkmark"
) {
signerRequestModel.approved = true
}
.buttonStyle(.borderedProminent)
Button(
String(localized: "Deny", comment: "Button to deny a permission request."),
systemImage: "xmark"
) {
signerRequestModel.approved = false
}
.buttonStyle(.borderedProminent)
}
}
}
} else {
VStack {
Text(
"Nothing to approve yet",
comment:
"""
Text on Home tab to indicate that there are no permission requests to review for approval.
"""
)
.font(.headline)
Text(
"Why not explore your favorite Nostr app a bit?",
comment:
"""
Text on Home tab to suggest what the user could do instead
since there are no permission requests to review for approval.
"""
)
.font(.caption)
}
}
}
}

View File

@@ -5,9 +5,13 @@
// Created by Terry Yiu on 1/20/25.
//
import NostrSDK
import SwiftData
import SwiftUI
struct LoggedInView: View {
let publicKey: String
@State var selectedTab: YetiTab = .home
var body: some View {
@@ -18,7 +22,7 @@ struct LoggedInView: View {
case .home:
HomeView()
case .permissions:
PermissionsView()
PermissionsView(publicKey: publicKey)
case .history:
HistoryView()
case .settings:
@@ -64,5 +68,5 @@ enum YetiTab: CustomStringConvertible, CaseIterable {
}
#Preview {
LoggedInView()
LoggedInView(publicKey: Keypair()!.publicKey.hex)
}

View File

@@ -11,82 +11,82 @@ import SwiftUI
struct NostrClientView: View {
@Query private var nostrClientModels: [NostrClientModel]
init(nostrClientModelId: String) {
self._nostrClientModels = Query(filter: #Predicate<NostrClientModel> {
$0.id == nostrClientModelId
})
init(nostrClientModelName: String) {
self._nostrClientModels = Query(filter: NostrClientModel.predicateByName(nostrClientModelName))
}
var nostrClientModel: NostrClientModel {
nostrClientModels.first!
var nostrClientModel: NostrClientModel? {
nostrClientModels.first
}
var body: some View {
VStack {
let bindableNostrClientModel = Bindable(nostrClientModel)
if let nostrClientModel {
let bindableNostrClientModel = Bindable(nostrClientModel)
Text(bindableNostrClientModel.id)
.font(.headline)
Text(bindableNostrClientModel.name.wrappedValue)
.font(.headline)
List {
Toggle(
String(
localized: "Read your profile",
comment: "Permission toggle to allow Nostr client to read your profile."
),
isOn: bindableNostrClientModel.readPublicKeyPermission
)
Toggle(
String(
localized: "Get relays",
comment: "Permission toggle to allow Nostr client to get relays."
),
isOn: bindableNostrClientModel.getRelaysPermission
)
Toggle(
String(
localized: "Encrypt DMs",
comment: "Permission toggle to allow Nostr client to encrypt direct messages."
),
isOn: bindableNostrClientModel.nip44EncryptPermission
)
Toggle(
String(
localized: "Decrypt DMs",
comment: "Permission toggle to allow Nostr client to decrypt direct messages."
),
isOn: bindableNostrClientModel.nip44DecryptPermission
)
Toggle(
String(
localized: "Encrypt legacy DMs",
comment: "Permission toggle to allow Nostr client to encrypt legacy direct messages."
),
isOn: bindableNostrClientModel.nip04EncryptPermission
)
Toggle(
String(
localized: "Decrypt legacy DMs",
comment: "Permission toggle to allow Nostr client to decrypt legacy direct messages."
),
isOn: bindableNostrClientModel.nip04DecryptPermission
)
Toggle(
String(
localized: "Decrypt zap events",
comment: "Permission toggle to allow Nostr client to decrypt zap events."
),
isOn: bindableNostrClientModel.decryptZapEventPermission
)
ForEach(bindableNostrClientModel.signEventPermissions, id: \.self) { signEventPermissionModel in
List {
Toggle(
String(
localized: "Sign kind \(signEventPermissionModel.kind.wrappedValue.description) events",
comment: "Permission toggle to allow Nostr client to sign events of a specific kind.."
localized: "Read your profile",
comment: "Permission toggle to allow Nostr client to read your profile."
),
isOn: signEventPermissionModel.allowed
isOn: bindableNostrClientModel.readPublicKeyPermission
)
Toggle(
String(
localized: "Get relays",
comment: "Permission toggle to allow Nostr client to get relays."
),
isOn: bindableNostrClientModel.getRelaysPermission
)
Toggle(
String(
localized: "Encrypt DMs",
comment: "Permission toggle to allow Nostr client to encrypt direct messages."
),
isOn: bindableNostrClientModel.nip44EncryptPermission
)
Toggle(
String(
localized: "Decrypt DMs",
comment: "Permission toggle to allow Nostr client to decrypt direct messages."
),
isOn: bindableNostrClientModel.nip44DecryptPermission
)
Toggle(
String(
localized: "Encrypt legacy DMs",
comment: "Permission toggle to allow Nostr client to encrypt legacy direct messages."
),
isOn: bindableNostrClientModel.nip04EncryptPermission
)
Toggle(
String(
localized: "Decrypt legacy DMs",
comment: "Permission toggle to allow Nostr client to decrypt legacy direct messages."
),
isOn: bindableNostrClientModel.nip04DecryptPermission
)
Toggle(
String(
localized: "Decrypt zap events",
comment: "Permission toggle to allow Nostr client to decrypt zap events."
),
isOn: bindableNostrClientModel.decryptZapEventPermission
)
ForEach(bindableNostrClientModel.signEventPermissions, id: \.self) { signEventPermissionModel in
Toggle(
String(
localized: "Sign kind \(signEventPermissionModel.kind.wrappedValue.description) events",
comment: "Permission toggle to allow Nostr client to sign events of a specific kind.."
),
isOn: signEventPermissionModel.approved
)
}
}
}
}
@@ -94,13 +94,13 @@ struct NostrClientView: View {
}
extension NostrClientModel {
static func predicateById(_ id: String) -> Predicate<NostrClientModel> {
static func predicateByName(_ name: String) -> Predicate<NostrClientModel> {
#Predicate<NostrClientModel> { nostrClientModel in
nostrClientModel.id == id
nostrClientModel.name == name
}
}
}
#Preview {
NostrClientView(nostrClientModelId: UUID().uuidString)
NostrClientView(nostrClientModelName: UUID().uuidString)
}

View File

@@ -5,41 +5,46 @@
// Created by Terry Yiu on 1/20/25.
//
import NostrSDK
import SwiftData
import SwiftUI
struct PermissionsView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \NostrClientModel.id) var nostrClientModels: [NostrClientModel]
@Query var profileSettingsModels: [ProfileSettingsModel]
init(publicKey: String) {
var descriptor = FetchDescriptor<ProfileSettingsModel>(
predicate: #Predicate { profileSettingsModel in
profileSettingsModel.publicKey == publicKey
}
)
descriptor.fetchLimit = 1
self._profileSettingsModels = Query(descriptor)
}
var profileSettingsModel: ProfileSettingsModel? {
profileSettingsModels.first
}
var nostrClientModels: [NostrClientModel] {
profileSettingsModel?.nostrClientModels ?? []
}
var body: some View {
NavigationStack {
List {
ForEach(nostrClientModels, id: \.self) { nostrClientModel in
NavigationLink(
nostrClientModel.id,
destination: NostrClientView(nostrClientModelId: nostrClientModel.id)
nostrClientModel.name,
destination: NostrClientView(nostrClientModelName: nostrClientModel.name)
)
}
}
}
// TODO Remove this dummy code
.onAppear(perform: addNostrClientModel)
}
// TODO Remove this dummy code
private func addNostrClientModel() {
withAnimation {
let newNostrClientModel = NostrClientModel(id: Date().timeIntervalSince1970.rounded().description)
newNostrClientModel.signEventPermissions = [
SignEventPermissionModel(kind: 1, allowed: true),
SignEventPermissionModel(kind: 31923, allowed: true)
]
modelContext.insert(newNostrClientModel)
}
}
}
#Preview {
PermissionsView()
PermissionsView(publicKey: Keypair()!.publicKey.hex)
}

View File

@@ -49,7 +49,7 @@ Youre set for now. Youll need to come back here with every new app and app
profileSettingsModel.signingPolicy = signingPolicy
modelContext.insert(profileSettingsModel)
generalSettingsModels.first!.activePublicKey = keypair.publicKey.hex
generalSettingsModels.first?.activePublicKey = keypair.publicKey.hex
})
.buttonStyle(.borderedProminent)
}

View File

@@ -17,7 +17,8 @@ struct YetiApp: App {
init() {
let schema = Schema([
GeneralSettingsModel.self,
ProfileSettingsModel.self
ProfileSettingsModel.self,
SignerRequestModel.self
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)