lnlink

iOS app for connecting to lightning nodes
git clone git://jb55.com/lnlink
Log | Files | Refs | Submodules | README | LICENSE

commit 88288bca30013b60b769abf6ef10f133f6416354
parent 64849054e9bb5d96a9ab57023ce4bea2d9198f97
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 25 Mar 2022 17:06:37 -0700

Receive view

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mlightninglink.xcodeproj/project.pbxproj | 12++++++++++++
Mlightninglink/RPC.swift | 29+++++++++++++++++++++++++++++
Alightninglink/Views/AmountInputView.swift | 34++++++++++++++++++++++++++++++++++
Mlightninglink/Views/ContentView.swift | 26+++++++++++++++++++++-----
Mlightninglink/Views/PayView.swift | 23++---------------------
Alightninglink/Views/ReceiveView.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 198 insertions(+), 26 deletions(-)

diff --git a/lightninglink.xcodeproj/project.pbxproj b/lightninglink.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C035A0327AEFD2F00FF92CE /* Invoice.swift */; }; 4C3DC97127D4829A00773021 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3DC97027D4829A00773021 /* SettingsView.swift */; }; 4C4D7A5627DD84BA005A4F7F /* LNUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */; }; + 4C5EAC5427ED30A8002A6FE5 /* Rates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5EAC5327ED30A8002A6FE5 /* Rates.swift */; }; + 4C5EAC5627EE8544002A6FE5 /* ReceiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5EAC5527EE8544002A6FE5 /* ReceiveView.swift */; }; + 4C5EAC5827EE8942002A6FE5 /* AmountInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5EAC5727EE8942002A6FE5 /* AmountInputView.swift */; }; 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D182788FF2F002A36C9 /* lightninglinkApp.swift */; }; 4C641D1B2788FF2F002A36C9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C641D1A2788FF2F002A36C9 /* ContentView.swift */; }; 4C641D1D2788FF30002A36C9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C641D1C2788FF30002A36C9 /* Assets.xcassets */; }; @@ -55,6 +58,9 @@ 4C035A0327AEFD2F00FF92CE /* Invoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Invoice.swift; sourceTree = "<group>"; }; 4C3DC97027D4829A00773021 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNUrl.swift; sourceTree = "<group>"; }; + 4C5EAC5327ED30A8002A6FE5 /* Rates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rates.swift; sourceTree = "<group>"; }; + 4C5EAC5527EE8544002A6FE5 /* ReceiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveView.swift; sourceTree = "<group>"; }; + 4C5EAC5727EE8942002A6FE5 /* AmountInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountInputView.swift; sourceTree = "<group>"; }; 4C641D152788FF2F002A36C9 /* lightninglink.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = lightninglink.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4C641D182788FF2F002A36C9 /* lightninglinkApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = lightninglinkApp.swift; sourceTree = "<group>"; }; 4C641D1A2788FF2F002A36C9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; @@ -149,6 +155,7 @@ 4C8B289227B44EAF00DF3372 /* LNLink.swift */, 4C4D7A5527DD84BA005A4F7F /* LNUrl.swift */, 4C72DB2427DD9D9B00834545 /* QR.swift */, + 4C5EAC5327ED30A8002A6FE5 /* Rates.swift */, ); path = lightninglink; sourceTree = "<group>"; @@ -216,6 +223,8 @@ 4C641D1A2788FF2F002A36C9 /* ContentView.swift */, 4CCB0E2A27CA71CA0026461C /* SetupView.swift */, 4C3DC97027D4829A00773021 /* SettingsView.swift */, + 4C5EAC5527EE8544002A6FE5 /* ReceiveView.swift */, + 4C5EAC5727EE8942002A6FE5 /* AmountInputView.swift */, ); path = Views; sourceTree = "<group>"; @@ -351,11 +360,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C5EAC5827EE8942002A6FE5 /* AmountInputView.swift in Sources */, 4C035A0027AEF90000FF92CE /* PayView.swift in Sources */, 4C873FD527A6EF3F008C972C /* LNSocket.swift in Sources */, 4C641D1B2788FF2F002A36C9 /* ContentView.swift in Sources */, 4C8B289327B44EAF00DF3372 /* LNLink.swift in Sources */, 4CCB0E2727C979F30026461C /* ScannerCoordinator.swift in Sources */, + 4C5EAC5427ED30A8002A6FE5 /* Rates.swift in Sources */, 4C641D492789083E002A36C9 /* lightninglink.c in Sources */, 4CCB0E2627C979F30026461C /* CodeScanner.swift in Sources */, 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */, @@ -364,6 +375,7 @@ 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */, 4CCB0E2B27CA71CA0026461C /* SetupView.swift in Sources */, 4C873FD727A6F1F5008C972C /* RPC.swift in Sources */, + 4C5EAC5627EE8544002A6FE5 /* ReceiveView.swift in Sources */, 4C3DC97127D4829A00773021 /* SettingsView.swift in Sources */, 4C4D7A5627DD84BA005A4F7F /* LNUrl.swift in Sources */, ); diff --git a/lightninglink/RPC.swift b/lightninglink/RPC.swift @@ -47,6 +47,10 @@ public struct Channel: Decodable { public var channel_total_sat: Int64 } +public struct InvoiceRes: Decodable { + public let bolt11: String +} + public struct LNUrlPayDecode { public let description: String? public let longDescription: String? @@ -350,6 +354,31 @@ public func rpc_getinfo(ln: LNSocket, token: String, timeout: Int32 = default_ti return performRpc(ln: ln, operation: "getinfo", authToken: token, timeout_ms: default_timeout, params: params) } +public func rpc_invoice(ln: LNSocket, token: String, amount: InvoiceAmount = .any, description: String? = nil, expiry: String? = nil) -> RequestRes<InvoiceRes> { + + let now = Date().timeIntervalSince1970 + let label = "lnlink-\(now)" + let desc = description ?? "lnlink invoice" + var params: [String: String] = ["description": desc, "label": label] + + if let exp = expiry { + params["expiry"] = exp + } + + switch amount { + case .amount(let val): + params["msatoshi"] = "\(val)msat" + case .any: + params["msatoshi"] = "any" + case .min(let val): + params["msatoshi"] = "\(val)msat" + case .range(let min, let max): + params["msatoshi"] = "\(min)msat" + } + + return performRpc(ln: ln, operation: "invoice", authToken: token, timeout_ms: default_timeout, params: params) +} + public func rpc_pay(ln: LNSocket, token: String, bolt11: String, amount_msat: Int64?) -> RequestRes<Pay> { var params: Array<String> = [ bolt11 ] diff --git a/lightninglink/Views/AmountInputView.swift b/lightninglink/Views/AmountInputView.swift @@ -0,0 +1,34 @@ +// +// AmountInput.swift +// lightninglink +// +// Created by William Casarin on 2022-03-25. +// + +import SwiftUI +import Combine + +struct AmountInput: View { + let text: Binding<String> + let onReceive: (String) -> () + + var body: some View { + Form { + Section { + HStack(alignment: .lastTextBaseline) { + TextField("10,000", text: self.text) + .font(.title) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .onReceive(Just(self.text)) { + onReceive($0.wrappedValue) + } + Text("sats") + } + } + } + .frame(height: 100) + } +} + + diff --git a/lightninglink/Views/ContentView.swift b/lightninglink/Views/ContentView.swift @@ -36,6 +36,8 @@ enum ActiveAlert: Identifiable { public enum ActiveSheet: Identifiable { public var id: String { switch self { + case .receive: + return "receive" case .qr: return "qrcode" case .pay: @@ -44,6 +46,7 @@ public enum ActiveSheet: Identifiable { } case qr + case receive case pay(DecodeType) } @@ -111,6 +114,10 @@ struct ContentView: View { return "-\(render_amount_msats(pay.msatoshi)) (\(render_amount_msats(fee)) fee)" } + func receive_pay() { + self.active_sheet = .receive + } + func check_pay() { guard let decode = get_clipboard_invoice() else { self.active_sheet = .qr @@ -150,7 +157,6 @@ struct ContentView: View { Spacer() } } - .padding() Spacer() Text("\(format_last_pay())") @@ -168,13 +174,20 @@ struct ContentView: View { Spacer() HStack { + Button(action: receive_pay) { + Label("", systemImage: "arrow.down.circle") + } + .font(.largeTitle) + Spacer() - Button("Pay", action: check_pay) - .font(.title) - .buttonStyle(.bordered) - .padding() + + Button(action: check_pay) { + Label("", systemImage: "qrcode.viewfinder") + } + .font(.largeTitle) } } + .padding() .alert("Use invoice in clipboard?", isPresented: $has_alert, presenting: active_alert) { alert in Button("Use QR") { self.has_alert = false @@ -204,6 +217,9 @@ struct ContentView: View { } + case .receive: + ReceiveView(lnlink: lnlink) + case .pay(let decode): PayView(decode: decode, lnlink: self.lnlink) } diff --git a/lightninglink/Views/PayView.swift b/lightninglink/Views/PayView.swift @@ -331,7 +331,7 @@ struct PayView: View { Text("\(render_amount_msats(amt))") .font(.title) } else { - InputView { + AmountInput(text: $custom_amount_input) { handle_custom_receive($0) if self.custom_amount_input != "" { if self.custom_amount_msats < min_amt { @@ -353,7 +353,7 @@ struct PayView: View { Text("\(render_amount_msats(amt))") .font(.title) } else { - InputView { + AmountInput(text: $custom_amount_input) { handle_custom_receive($0) } } @@ -365,25 +365,6 @@ struct PayView: View { } } - func InputView(onReceive: @escaping (String) -> ()) -> some View { - // TODO remove from class, pass input binding? - return Form { - Section { - HStack(alignment: .lastTextBaseline) { - TextField("10,000", text: $custom_amount_input) - .font(.title) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .onReceive(Just(self.custom_amount_input)) { - onReceive($0) - } - Text("sats") - } - } - } - .frame(height: 100) - } - func confirm_pay(ln: LNSocket?, inv: String, pay_amt: PayAmount?) { let res = confirm_payment(ln: ln, lnlink: self.lnlink, bolt11: inv, pay_amt: pay_amt) switch res { diff --git a/lightninglink/Views/ReceiveView.swift b/lightninglink/Views/ReceiveView.swift @@ -0,0 +1,100 @@ +// +// ReceiveView.swift +// lightninglink +// +// Created by William Casarin on 2022-03-25. +// + +import SwiftUI +import AVFoundation +import CoreImage.CIFilterBuiltins + +struct ReceiveView: View { + @State private var loading: Bool = true + @State private var qr: Image? = nil + @State private var qr_data: String? = nil + + let lnlink: LNLink + + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack { + Text("Receive payment") + .font(.title) + + Spacer() + + if let qr = self.qr { + qr + .resizable() + .scaledToFit() + .frame(width: 300, height: 300) + .onTapGesture { + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + UIPasteboard.general.string = self.qr_data + } + } else { + ProgressView() + .progressViewStyle(.circular) + } + + Spacer() + + HStack { + Button("Back") { + dismiss() + } + Spacer() + } + } + .padding() + .onAppear() { + make_invoice(lnlink: lnlink, expiry: "12h") { res in + switch res { + case .failure: + break + case .success(let invres): + self.qr = generate_qr(from: invres.bolt11) + self.qr_data = invres.bolt11 + } + } + } + } + + private func dismiss() { + self.presentationMode.wrappedValue.dismiss() + } + +} + + +func generate_qr(from string: String) -> Image { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(string.uppercased().utf8) + if let output_img = filter.outputImage { + if let cgimg = context.createCGImage(output_img, from: output_img.extent) { + let uiimg = UIImage(cgImage: cgimg) + return Image(uiImage: uiimg).interpolation(.none) + } + } + + let uiimg = UIImage(systemName: "xmark.circle") ?? UIImage() + return Image(uiImage: uiimg) +} + +func make_invoice(lnlink: LNLink, expiry: String, callback: @escaping (RequestRes<InvoiceRes>) -> ()) { + let ln = LNSocket() + + ln.genkey() + guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { + return + } + + DispatchQueue.global(qos: .background).async { + let res = rpc_invoice(ln: ln, token: lnlink.token, amount: .any, description: "lnlink invoice", expiry: "12h") + callback(res) + } +}