lnlink

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

commit 5a289cddd0a761cfc11c906c379a901d20ee2f0e
parent 150f18324114ba56cf4cadf80ba15a5f29bfb4af
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 26 Feb 2022 15:45:56 -0800

setup screen working

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

Diffstat:
Mlightninglink.xcodeproj/project.pbxproj | 16++++++++++++++--
Dlightninglink/ContentView.swift | 215-------------------------------------------------------------------------------
Mlightninglink/RPC.swift | 2+-
Alightninglink/Views/ContentView.swift | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rlightninglink/PayView.swift -> lightninglink/Views/PayView.swift | 0
Alightninglink/Views/SetupView.swift | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlightninglink/lightninglinkApp.swift | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
7 files changed, 502 insertions(+), 238 deletions(-)

diff --git a/lightninglink.xcodeproj/project.pbxproj b/lightninglink.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 4CCB0E2627C979F30026461C /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2227C979F30026461C /* CodeScanner.swift */; }; 4CCB0E2727C979F30026461C /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2327C979F30026461C /* ScannerCoordinator.swift */; }; 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2427C979F30026461C /* ScannerViewController.swift */; }; + 4CCB0E2B27CA71CA0026461C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCB0E2A27CA71CA0026461C /* SetupView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,6 +74,7 @@ 4CCB0E2227C979F30026461C /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = "<group>"; }; 4CCB0E2327C979F30026461C /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = "<group>"; }; 4CCB0E2427C979F30026461C /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; }; + 4CCB0E2A27CA71CA0026461C /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -129,15 +131,14 @@ 4C641D172788FF2F002A36C9 /* lightninglink */ = { isa = PBXGroup; children = ( + 4CCB0E2927CA71AA0026461C /* Views */, 4CCB0E1F27C979F30026461C /* CodeScanner */, 4C0359FE27AEEE8500FF92CE /* Info.plist */, 4C873FD427A6EF3F008C972C /* LNSocket.swift */, 4C641D182788FF2F002A36C9 /* lightninglinkApp.swift */, - 4C641D1A2788FF2F002A36C9 /* ContentView.swift */, 4C641D1C2788FF30002A36C9 /* Assets.xcassets */, 4C641D1E2788FF30002A36C9 /* Preview Content */, 4C873FD627A6F1F5008C972C /* RPC.swift */, - 4C0359FF27AEF90000FF92CE /* PayView.swift */, 4C035A0327AEFD2F00FF92CE /* Invoice.swift */, 4C8B289227B44EAF00DF3372 /* LNLink.swift */, ); @@ -200,6 +201,16 @@ path = CodeScanner; sourceTree = "<group>"; }; + 4CCB0E2927CA71AA0026461C /* Views */ = { + isa = PBXGroup; + children = ( + 4C0359FF27AEF90000FF92CE /* PayView.swift */, + 4C641D1A2788FF2F002A36C9 /* ContentView.swift */, + 4CCB0E2A27CA71CA0026461C /* SetupView.swift */, + ); + path = Views; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -341,6 +352,7 @@ 4CCB0E2827C979F30026461C /* ScannerViewController.swift in Sources */, 4C035A0427AEFD2F00FF92CE /* Invoice.swift in Sources */, 4C641D192788FF2F002A36C9 /* lightninglinkApp.swift in Sources */, + 4CCB0E2B27CA71CA0026461C /* SetupView.swift in Sources */, 4C873FD727A6F1F5008C972C /* RPC.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/lightninglink/ContentView.swift b/lightninglink/ContentView.swift @@ -1,215 +0,0 @@ -// -// ContentView.swift -// lightninglink -// -// Created by William Casarin on 2022-01-07. -// - -import SwiftUI -import AVFoundation - -extension Notification.Name { - static var sentPayment: Notification.Name { - return Notification.Name("did send payment") - } -} - -enum ActiveAlert: Identifiable { - var id: String { - switch self { - case .pay: - return "pay" - } - } - - case pay(InvoiceAmount, String) -} - -enum ActiveSheet: Identifiable { - var id: String { - switch self { - case .qr: - return "qrcode" - case .pay: - return "paysheet" - } - } - - case qr - case pay(InvoiceAmount, String) -} - -struct Funds { - public var onchain_sats: Int64 - public var channel_sats: Int64 - - public static var empty = Funds(onchain_sats: 0, channel_sats: 0) - - public static func from_listfunds(fs: ListFunds) -> Funds { - var onchain_sats: Int64 = 0 - var channel_sats: Int64 = 0 - - for channel in fs.channels { - channel_sats += channel.channel_sat - } - - for output in fs.outputs { - onchain_sats += output.value - } - - return Funds(onchain_sats: onchain_sats, channel_sats: channel_sats) - } -} - -let SCAN_TYPES: [AVMetadataObject.ObjectType] = [.qr] - -struct ContentView: View { - @State private var info: GetInfo - @State private var active_sheet: ActiveSheet? - @State private var active_alert: ActiveAlert? - @State private var has_alert: Bool - @State private var last_pay: Pay? - @State private var funds: Funds - - private var lnlink: LNLink - - init(info: GetInfo, lnlink: LNLink, funds: ListFunds) { - self.info = info - self.lnlink = lnlink - self.has_alert = false - self.funds = Funds.from_listfunds(fs: funds) - } - - func refresh_funds() { - let ln = LNSocket() - guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { - return - } - let funds = fetch_funds(ln: ln, token: lnlink.token) - self.funds = Funds.from_listfunds(fs: funds) - } - - func format_last_pay() -> String { - guard let pay = last_pay else { - return "" - } - - if (pay.msatoshi >= 1000) { - let sats = pay.msatoshi / 1000 - let fee = (pay.msatoshi_sent - pay.msatoshi) / 1000 - return "-\(sats) sats (\(fee) sats fee)" - } - - return "-\(pay.msatoshi) msats (\(pay.msatoshi_sent) msats sent)" - } - - func check_pay() { - guard let (amt, inv) = get_clipboard_invoice() else { - self.active_sheet = .qr - self.has_alert = false - return - } - - self.active_sheet = nil - self.active_alert = .pay(amt, inv) - self.has_alert = true - } - - var body: some View { - VStack { - Group { - Text(self.info.alias) - .font(.largeTitle) - .padding() - Text("\(self.info.num_active_channels) active channels") - Text("\(self.info.msatoshi_fees_collected / 1000) sats collected in fees") - } - Spacer() - Text("\(format_last_pay())") - .foregroundColor(Color.red) - - Text("\(self.funds.channel_sats) sats") - .font(.title) - .padding() - Text("\(self.funds.onchain_sats) onchain") - Spacer() - HStack { - Spacer() - Button("Pay", action: check_pay) - .font(.title) - .padding() - } - } - .alert("Use invoice in clipboard?", isPresented: $has_alert, presenting: active_alert) { alert in - Button("Use QR") { - self.has_alert = false - self.active_sheet = .qr - } - Button("Yes") { - self.has_alert = false - self.active_alert = nil - switch alert { - case .pay(let amt, let inv): - self.active_sheet = .pay(amt, inv) - } - } - } - .sheet(item: $active_sheet) { sheet in - switch sheet { - case .qr: - CodeScannerView(codeTypes: SCAN_TYPES) { res in - switch res { - case .success(let scan_res): - let code = scan_res.string - var invstr: String = code - if code.starts(with: "lightning:") { - let index = code.index(code.startIndex, offsetBy: 10) - invstr = String(code[index...]) - } - let m_parsed = parseInvoiceAmount(invstr) - guard let parsed = m_parsed else { - return - } - self.active_sheet = .pay(parsed, invstr) - - case .failure: - self.active_sheet = nil - return - } - - } - - case .pay(let amt, let raw): - PayView(invoice_str: raw, amount: amt, lnlink: self.lnlink) - } - } - .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in - last_pay = payment.object as! Pay - self.active_sheet = nil - refresh_funds() - } - } -} - -/* -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - Group { - ContentView(info: .empty, lnlink: ln, token: "", funds: .empty) - } - } -} - */ - - -func get_clipboard_invoice() -> (InvoiceAmount, String)? { - guard let inv = UIPasteboard.general.string else { - return nil - } - - guard let amt = parseInvoiceAmount(inv) else { - return nil - } - - return (amt, inv) -} diff --git a/lightninglink/RPC.swift b/lightninglink/RPC.swift @@ -256,7 +256,7 @@ func commando_read_all(ln: LNSocket, timeout_ms: Int32 = 2000) -> RequestRes<Dat public let default_timeout: Int32 = 8000 -public func rpc_getinfo(ln: LNSocket, token: String) -> RequestRes<GetInfo> +public func rpc_getinfo(ln: LNSocket, token: String, timeout: Int32 = default_timeout) -> RequestRes<GetInfo> { let params: Array<String> = [] return performRpc(ln: ln, operation: "getinfo", authToken: token, timeout_ms: default_timeout, params: params) diff --git a/lightninglink/Views/ContentView.swift b/lightninglink/Views/ContentView.swift @@ -0,0 +1,215 @@ +// +// ContentView.swift +// lightninglink +// +// Created by William Casarin on 2022-01-07. +// + +import SwiftUI +import AVFoundation + +extension Notification.Name { + static var sentPayment: Notification.Name { + return Notification.Name("did send payment") + } +} + +enum ActiveAlert: Identifiable { + var id: String { + switch self { + case .pay: + return "pay" + } + } + + case pay(InvoiceAmount, String) +} + +public enum ActiveSheet: Identifiable { + public var id: String { + switch self { + case .qr: + return "qrcode" + case .pay: + return "paysheet" + } + } + + case qr + case pay(InvoiceAmount, String) +} + +struct Funds { + public var onchain_sats: Int64 + public var channel_sats: Int64 + + public static var empty = Funds(onchain_sats: 0, channel_sats: 0) + + public static func from_listfunds(fs: ListFunds) -> Funds { + var onchain_sats: Int64 = 0 + var channel_sats: Int64 = 0 + + for channel in fs.channels { + channel_sats += channel.channel_sat + } + + for output in fs.outputs { + onchain_sats += output.value + } + + return Funds(onchain_sats: onchain_sats, channel_sats: channel_sats) + } +} + +let SCAN_TYPES: [AVMetadataObject.ObjectType] = [.qr] + +struct ContentView: View { + @State private var active_sheet: ActiveSheet? + @State private var active_alert: ActiveAlert? + @State private var has_alert: Bool + @State private var last_pay: Pay? + @State private var dashboard: Dashboard + @State private var funds: Funds + + private var lnlink: LNLink + + init(dashboard: Dashboard, lnlink: LNLink) { + self.dashboard = dashboard + self.lnlink = lnlink + self.has_alert = false + self.funds = Funds.from_listfunds(fs: dashboard.funds) + } + + func refresh_funds() { + let ln = LNSocket() + guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { + return + } + let funds = fetch_funds(ln: ln, token: lnlink.token) + self.funds = Funds.from_listfunds(fs: funds) + } + + func format_last_pay() -> String { + guard let pay = last_pay else { + return "" + } + + if (pay.msatoshi >= 1000) { + let sats = pay.msatoshi / 1000 + let fee = (pay.msatoshi_sent - pay.msatoshi) / 1000 + return "-\(sats) sats (\(fee) sats fee)" + } + + return "-\(pay.msatoshi) msats (\(pay.msatoshi_sent) msats sent)" + } + + func check_pay() { + guard let (amt, inv) = get_clipboard_invoice() else { + self.active_sheet = .qr + self.has_alert = false + return + } + + self.active_sheet = nil + self.active_alert = .pay(amt, inv) + self.has_alert = true + } + + var body: some View { + VStack { + Group { + Text(self.dashboard.info.alias) + .font(.largeTitle) + .padding() + Text("\(self.dashboard.info.num_active_channels) active channels") + Text("\(self.dashboard.info.msatoshi_fees_collected / 1000) sats collected in fees") + } + Spacer() + Text("\(format_last_pay())") + .foregroundColor(Color.red) + + Text("\(self.funds.channel_sats) sats") + .font(.title) + .padding() + Text("\(self.funds.onchain_sats) onchain") + Spacer() + HStack { + Spacer() + Button("Pay", action: check_pay) + .font(.title) + .padding() + } + } + .alert("Use invoice in clipboard?", isPresented: $has_alert, presenting: active_alert) { alert in + Button("Use QR") { + self.has_alert = false + self.active_sheet = .qr + } + Button("Yes") { + self.has_alert = false + self.active_alert = nil + switch alert { + case .pay(let amt, let inv): + self.active_sheet = .pay(amt, inv) + } + } + } + .sheet(item: $active_sheet) { sheet in + switch sheet { + case .qr: + CodeScannerView(codeTypes: SCAN_TYPES) { res in + switch res { + case .success(let scan_res): + let code = scan_res.string + var invstr: String = code + if code.starts(with: "lightning:") { + let index = code.index(code.startIndex, offsetBy: 10) + invstr = String(code[index...]) + } + let m_parsed = parseInvoiceAmount(invstr) + guard let parsed = m_parsed else { + return + } + self.active_sheet = .pay(parsed, invstr) + + case .failure: + self.active_sheet = nil + return + } + + } + + case .pay(let amt, let raw): + PayView(invoice_str: raw, amount: amt, lnlink: self.lnlink) + } + } + .onReceive(NotificationCenter.default.publisher(for: .sentPayment)) { payment in + last_pay = payment.object as! Pay + self.active_sheet = nil + refresh_funds() + } + } +} + +/* +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + Group { + ContentView(info: .empty, lnlink: ln, token: "", funds: .empty) + } + } +} + */ + + +func get_clipboard_invoice() -> (InvoiceAmount, String)? { + guard let inv = UIPasteboard.general.string else { + return nil + } + + guard let amt = parseInvoiceAmount(inv) else { + return nil + } + + return (amt, inv) +} diff --git a/lightninglink/PayView.swift b/lightninglink/Views/PayView.swift diff --git a/lightninglink/Views/SetupView.swift b/lightninglink/Views/SetupView.swift @@ -0,0 +1,198 @@ +// +// SetupView.swift +// lightninglink +// +// Created by William Casarin on 2022-02-26. +// + +import SwiftUI +import Foundation + +public enum ActiveAuthSheet: Identifiable { + public var id: String { + switch self { + case .qr: + return "qrcode" + } + } + + case qr +} + +public enum SetupResult { + case connection_failed + case plugin_missing + case auth_invalid(String) + case success(GetInfo, ListFunds) +} + +public enum SetupViewState { + case initial + case validating + case validated +} + +struct SetupView: View { + @State var active_sheet: ActiveAuthSheet? = nil + @State var state: SetupViewState = .initial + @State var error: String? = nil + @State var dashboard: Dashboard = .empty + @State var lnlink: LNLink? = nil + + func perform_validation(_ lnlink: LNLink) { + validate_connection(lnlink: lnlink) { res in + switch res { + case .connection_failed: + self.state = .initial + self.error = "Connection failed" + case .plugin_missing: + self.state = .initial + self.error = "Connected but could not retrieve data, plugin missing?" + case .auth_invalid(let str): + self.state = .initial + self.error = "Auth issue: \(str)" + case .success(let info, let funds): + save_lnlink(lnlink: lnlink) + self.lnlink = lnlink + self.dashboard = Dashboard(info: info, funds: funds) + self.state = .validated + self.error = nil + } + } + } + + func setup_view() -> some View { + VStack { + Button("Scan auth QR") { + self.active_sheet = .qr + } + if self.error != nil { + Text("\(self.error!)") + } + } + .sheet(item: $active_sheet) { active_sheet in + switch active_sheet { + case .qr: + CodeScannerView(codeTypes: SCAN_TYPES) { code_res in + switch code_res { + case .success(let scan_res): + let auth_qr = scan_res.string + // auth_qr ~ lnlink:host:port?nodeid=nodeid&token=rune + let m_lnlink = parse_auth_qr(auth_qr) + + switch m_lnlink { + case .left(let err): + self.error = err + case .right(let lnlink): + self.state = .validating + self.perform_validation(lnlink) + } + + case .failure(let scan_err): + self.error = scan_err.localizedDescription + } + } + } + } + + } + + func validating_view() -> some View { + Text("Checking connection...") + } + + var body: some View { + Group { + switch self.state { + case .initial: + setup_view() + case .validating: + validating_view() + case .validated: + ContentView(dashboard: self.dashboard, lnlink: self.lnlink!) + } + } + } +} + +struct SetupView_Previews: PreviewProvider { + static var previews: some View { + SetupView() + } +} + + +func get_qs_param(qs: URLComponents, param: String) -> String? { + return qs.queryItems?.first(where: { $0.name == param })?.value +} + + +func parse_auth_qr(_ qr: String) -> Either<String, LNLink> { + var auth_qr = qr + if auth_qr.hasPrefix("lnlink:") && !auth_qr.hasPrefix("lnlink://") { + auth_qr = qr.replacingOccurrences(of: "lnlink:", with: "lnlink://") + } + + guard let url = URL(string: auth_qr) else { + return .left("Invalid url") + } + + guard let host = url.host else { + return .left("No hostname found in auth qr") + } + + guard let qs = URLComponents(string: auth_qr) else { + return .left("Invalid url querystring") + } + + guard let nodeid = get_qs_param(qs: qs, param: "nodeid") else { + return .left("No nodeid found in auth qr") + } + + guard let token = get_qs_param(qs: qs, param: "token") else { + return .left("No token found in auth qr") + } + + let lnlink = LNLink(token: token, host: host, node_id: nodeid) + return .right(lnlink) +} + + +func validate_connection(lnlink: LNLink, completion: @escaping (SetupResult) -> Void) { + let ln = LNSocket() + + guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { + completion(.connection_failed) + return + } + + let res = rpc_getinfo(ln: ln, token: lnlink.token, timeout: 5000) + + switch res { + case .failure(let rpc_err): + switch rpc_err.errorType { + case .timeout: + completion(.plugin_missing) + return + default: + break + } + + guard let decoded = rpc_err.decoded else { + completion(.auth_invalid(rpc_err.description)) + return + } + + completion(.auth_invalid(decoded.message)) + + case .success(let getinfo): + let funds_res = rpc_listfunds(ln: ln, token: lnlink.token) + + switch funds_res { + case .failure: + completion(.success(getinfo, .empty)) + case .success(let listfunds): + completion(.success(getinfo, listfunds)) + } + } +} diff --git a/lightninglink/lightninglinkApp.swift b/lightninglink/lightninglinkApp.swift @@ -7,31 +7,67 @@ import SwiftUI -@main -struct lightninglinkApp: App { - var info: GetInfo = .empty - var funds: ListFunds = .empty - var lnlink: LNLink - - init() { - self.ln = LNSocket() - self.token = "" - let node_id = "03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71" - let host = "24.84.152.187" - let lnlink = LNLink(token: token, host: host, node_id: node_id) - self.lnlink = lnlink - - guard ln.connect_and_init(node_id: node_id, host: host) else { - return - } +public struct Dashboard { + public let info: GetInfo + public let funds: ListFunds + + public static var empty: Dashboard = Dashboard(info: .empty, funds: .empty) +} - self.info = fetch_info(ln: ln, token: token) - self.funds = fetch_funds(ln: ln, token: token) +func fetch_dashboard(lnlink: LNLink) -> Either<String, Dashboard> { + let ln = LNSocket() + + guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { + return .left("Connect failed :(") } + let res = rpc_getinfo(ln: ln, token: lnlink.token) + switch res { + case .failure(let res_err): + return .left(res_err.decoded?.message ?? res_err.description) + case .success(let info): + let res2 = rpc_listfunds(ln: ln, token: lnlink.token) + switch res2 { + case .failure(let err): + return .left(err.decoded?.message ?? err.description) + case .success(let funds): + return .right(Dashboard(info: info, funds: funds)) + } + } +} + +@main +struct lightninglinkApp: App { + @State var dashboard: Dashboard? + @State var lnlink: LNLink? = load_lnlink() + @State var error: String? + var body: some Scene { WindowGroup { - ContentView(info: self.info, lnlink: self.lnlink, funds: self.funds) + if self.error != nil { + Text("Error: \(self.error!)") + } else { + if self.lnlink != nil { + if self.dashboard != nil { + ContentView(dashboard: self.dashboard!, lnlink: self.lnlink!) + } else { + VStack { + Text("Connecting...") + .onAppear() { + let res = fetch_dashboard(lnlink: self.lnlink!) + switch res { + case .left(let err): + self.error = err + case .right(let dash): + self.dashboard = dash + } + } + } + } + } else { + SetupView() + } + } } } } @@ -56,3 +92,21 @@ func fetch_funds(ln: LNSocket, token: String) -> ListFunds { return funds } } + +func save_lnlink(lnlink: LNLink) { + UserDefaults.standard.set(lnlink.token, forKey: "lnlink_token") + UserDefaults.standard.set(lnlink.node_id, forKey: "lnlink_nodeid") + UserDefaults.standard.set(lnlink.host, forKey: "lnlink_host") +} + +func load_lnlink() -> LNLink? { + let m_token = UserDefaults.standard.string(forKey: "lnlink_token") + let m_nodeid = UserDefaults.standard.string(forKey: "lnlink_nodeid") + let m_host = UserDefaults.standard.string(forKey: "lnlink_host") + + guard let token = m_token else { return nil } + guard let node_id = m_nodeid else { return nil } + guard let host = m_host else { return nil } + + return LNLink(token: token, host: host, node_id: node_id) +}