From e55471e48cfec1f3e623e8793e940ad10ffa1379 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 21 Mar 2024 12:25:31 +0100 Subject: [PATCH] ngui,nd: screenlock feature implementation the screenlock use case is a way to restrict access to the touchscreen, for example from children. it is in no way a security measure against theft or prolonged physical access. however, nd daemon will refuse to connect to a wifi, switch sysupdates channel, set a new nodename or init lnd wallet during active screenlock screenlock pin code can be enabled and disabled from the settings screen. upon loss of the code, the only way to disable screenlock is to set slock field to null in the nd daemon conf file. --- src/comm.zig | 20 ++++- src/nd.zig | 15 +++- src/nd/Config.zig | 139 ++++++++++++++++++++++++++++ src/nd/Daemon.zig | 205 +++++++++++++++++++++++++++++++++++------- src/ngui.zig | 157 +++++++++++++++++--------------- src/test/guiplay.zig | 80 +++++++++++++---- src/ui/c/ui.c | 37 +++++--- src/ui/lvgl.zig | 55 ++++++++++-- src/ui/screenlock.zig | 90 +++++++++++++++++++ src/ui/settings.zig | 137 +++++++++++++++++++++++++++- src/ui/symbol.zig | 1 + src/ui/ui.zig | 35 ++++++-- 12 files changed, 822 insertions(+), 149 deletions(-) create mode 100644 src/ui/screenlock.zig diff --git a/src/comm.zig b/src/comm.zig index 8e65287..ecaece4 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -89,7 +89,13 @@ pub const MessageTag = enum(u16) { set_nodename = 0x15, // nd -> ngui: all ndg settings settings = 0x0d, - // next: 0x16 + // ngui -> nd: verify pincode + unlock_screen = 0x17, + // nd -> ngui: result of try_unlock + screen_unlock_result = 0x18, + // ngui -> nd: set or disable screenlock pin code + slock_set_pincode = 0x19, + // next: 0x1a }; /// daemon and gui exchange messages of this type. @@ -115,6 +121,9 @@ pub const Message = union(MessageTag) { switch_sysupdates: SysupdatesChan, set_nodename: []const u8, settings: Settings, + unlock_screen: []const u8, // pincode + screen_unlock_result: ScreenUnlockResult, + slock_set_pincode: ?[]const u8, pub const WifiConnect = struct { ssid: []const u8, @@ -250,11 +259,17 @@ pub const Message = union(MessageTag) { }; pub const Settings = struct { + slock_enabled: bool, hostname: []const u8, // see .set_nodename sysupdates: struct { channel: SysupdatesChan, }, }; + + pub const ScreenUnlockResult = struct { + ok: bool, + err: ?[]const u8 = null, // error message when !ok + }; }; /// the return value type from `read` fn. @@ -347,6 +362,9 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .switch_sysupdates => try json.stringify(msg.switch_sysupdates, .{}, data.writer()), .set_nodename => try json.stringify(msg.set_nodename, .{}, data.writer()), .settings => try json.stringify(msg.settings, .{}, data.writer()), + .unlock_screen => try json.stringify(msg.unlock_screen, .{}, data.writer()), + .screen_unlock_result => try json.stringify(msg.screen_unlock_result, .{}, data.writer()), + .slock_set_pincode => try json.stringify(msg.slock_set_pincode, .{}, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; diff --git a/src/nd.zig b/src/nd.zig index dc855e9..2fa573f 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -8,6 +8,7 @@ const Address = std.net.Address; const nif = @import("nif"); const comm = @import("comm.zig"); +const Config = @import("nd/Config.zig"); const Daemon = @import("nd/Daemon.zig"); const screen = @import("ui/screen.zig"); @@ -158,9 +159,19 @@ pub fn main() !void { // of its previous state. screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err}); + // load config file to figure out whether to start ngui in screenlocked mode. + const conf = try Config.init(gpa, args.conf.?); + defer conf.deinit(); + // start ngui, unless -nogui mode const gui_path = args.gui.?; // guaranteed to be non-null - var ngui = std.ChildProcess.init(&.{gui_path}, gpa); + var ngui_args = std.ArrayList([]const u8).init(gpa); + defer ngui_args.deinit(); + try ngui_args.append(gui_path); + if (conf.data.slock != null) { + try ngui_args.append("-slock"); + } + var ngui = std.ChildProcess.init(ngui_args.items, gpa); ngui.stdin_behavior = .Pipe; ngui.stdout_behavior = .Pipe; ngui.stderr_behavior = .Inherit; @@ -200,7 +211,7 @@ pub fn main() !void { var nd = try Daemon.init(.{ .allocator = gpa, - .confpath = args.conf.?, + .conf = conf, .uir = uireader, .uiw = uiwriter, .wpa = args.wpa.?, diff --git a/src/nd/Config.zig b/src/nd/Config.zig index 2a20f49..bf00d2a 100644 --- a/src/nd/Config.zig +++ b/src/nd/Config.zig @@ -40,7 +40,13 @@ data: Data, /// top struct stored on disk. /// access with `safeReadOnly` or lock/unlock `mu`. +/// +/// for backwards compatibility, all newly introduced fields must have default values. pub const Data = struct { + slock: ?struct { // null indicates screenlock is disabled + bcrypt_hash: []const u8, // std.crypto.bcrypt .phc format + incorrect_attempts: u8, // reset after each successful unlock + } = null, syschannel: SysupdatesChannel, syscronscript: []const u8, sysrunscript: []const u8, @@ -175,6 +181,50 @@ pub fn safeReadOnly(self: *Config, comptime F: anytype) @typeInfo(@TypeOf(F)).Fn return F(self.data, self.static); } +/// matches the `input` against the hash in `Data.slock.bcrypt_hash` previously set with `setSlockPin`. +/// incrementing `Data.slock.incorrect_attempts` each unsuccessful result. +/// the number of attemps are persisted at `Config.confpath` upon function return. +pub fn verifySlockPin(self: *Config, input: []const u8) !void { + self.mu.lock(); + defer self.mu.unlock(); + const slock = self.data.slock orelse return; + defer self.dumpUnguarded() catch |errdump| logger.err("dumpUnguarded: {!}", .{errdump}); + std.crypto.pwhash.bcrypt.strVerify(slock.bcrypt_hash, input, .{}) catch |err| { + if (err == error.PasswordVerificationFailed) { + self.data.slock.?.incorrect_attempts += 1; + return error.IncorrectSlockPin; + } + logger.err("bcrypt.strVerify: {!}", .{err}); + return err; + }; + self.data.slock.?.incorrect_attempts = 0; +} + +/// enables or disables screenlock, persistently. null `code` indicates disabled. +/// safe for concurrent use. +pub fn setSlockPin(self: *Config, code: ?[]const u8) !void { + self.mu.lock(); + defer self.mu.unlock(); + // TODO: free existing slock.bcrypt_hash? it is in arena but still + if (code) |s| { + const bcrypt = std.crypto.pwhash.bcrypt; + const opt: bcrypt.HashOptions = .{ + .params = .{ .rounds_log = 12 }, + .encoding = .phc, + .silently_truncate_password = false, + }; + var buf: [bcrypt.hash_length * 2]u8 = undefined; + const hash = try bcrypt.strHash(s, opt, &buf); + self.data.slock = .{ + .bcrypt_hash = try self.arena.allocator().dupe(u8, hash), + .incorrect_attempts = 0, + }; + } else { + self.data.slock = null; + } + try self.dumpUnguarded(); +} + /// used by mutateLndConf to guard concurrent access. var lndconf_mu: std.Thread.Mutex = .{}; @@ -681,3 +731,92 @@ test "ndconfig: mutate LndConf" { \\ , cont); } + +test "ndconfig: screen lock" { + const t = std.testing; + const tt = @import("../test.zig"); + + // Config auto-deinits the arena. + var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + conf_arena.* = std.heap.ArenaAllocator.init(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + // nonexistent config file + { + var conf = try init(t.allocator, "/nonexistent.json"); + defer conf.deinit(); + try t.expect(conf.data.slock == null); + try conf.verifySlockPin(""); + try conf.verifySlockPin("any"); + } + + // conf file without slock field + { + const confpath = try tmp.join(&.{"conf.json"}); + try tmp.dir.writeFile(confpath, + \\{ + \\"syschannel": "dev", + \\"syscronscript": "/cron/sysupdates.sh", + \\"sysrunscript": "/sysupdates/run.sh" + \\} + ); + var conf = try init(t.allocator, confpath); + defer conf.deinit(); + try t.expect(conf.data.slock == null); + try conf.verifySlockPin(""); + try conf.verifySlockPin("0000"); + } + + // conf file with null slock + { + const confpath = try tmp.join(&.{"conf.json"}); + try tmp.dir.writeFile(confpath, + \\{ + \\"slock": null, + \\"syschannel": "dev", + \\"syscronscript": "/cron/sysupdates.sh", + \\"sysrunscript": "/sysupdates/run.sh" + \\} + ); + var conf = try init(t.allocator, confpath); + defer conf.deinit(); + try t.expect(conf.data.slock == null); + try conf.verifySlockPin(""); + try conf.verifySlockPin("1111"); + } + + const newpinconf = try tmp.join(&.{"newconf.json"}); + { + var conf = Config{ + .arena = conf_arena, + .confpath = newpinconf, + .data = .{ + .slock = null, + .syschannel = .master, // unused + .syscronscript = undefined, // unused + .sysrunscript = undefined, // unused + }, + .static = undefined, // unused + }; + defer conf.deinit(); + + // any pin should workd because slock is null + try conf.verifySlockPin(""); + try conf.verifySlockPin("any"); + // set a new pin code + try conf.setSlockPin("1357"); + try conf.verifySlockPin("1357"); + try t.expectError(error.IncorrectSlockPin, conf.verifySlockPin("")); + try t.expectError(error.IncorrectSlockPin, conf.verifySlockPin("any")); + } + // load conf from file and check + { + var conf = try init(t.allocator, newpinconf); + defer conf.deinit(); + try t.expect(conf.data.slock != null); + try conf.setSlockPin("1357"); + try conf.verifySlockPin("1357"); + try t.expectError(error.IncorrectSlockPin, conf.verifySlockPin("any2")); + } +} diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 640bcba..6bf16c0 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -32,6 +32,9 @@ uireader: std.fs.File.Reader, // ngui stdout uiwriter: std.fs.File.Writer, // ngui stdin wpa_ctrl: types.WpaControl, // guarded by mu once start'ed +/// used only in comm thread; move under mu when no longer the case +screenstate: enum { locked, unlocked }, + /// guards all the fields below to sync between pub fns and main/poweroff threads. mu: std.Thread.Mutex = .{}, @@ -118,7 +121,7 @@ const Error = error{ const InitOpt = struct { allocator: std.mem.Allocator, - confpath: []const u8, + conf: Config, uir: std.fs.File.Reader, uiw: std.fs.File.Writer, wpa: [:0]const u8, @@ -138,15 +141,14 @@ pub fn init(opt: InitOpt) !Daemon { try svlist.append(sys.Service.init(opt.allocator, sys.Service.LND, .{ .stop_wait_sec = 600 })); try svlist.append(sys.Service.init(opt.allocator, sys.Service.BITCOIND, .{ .stop_wait_sec = 600 })); - const conf = try Config.init(opt.allocator, opt.confpath); - errdefer conf.deinit(); return .{ .allocator = opt.allocator, - .conf = conf, + .conf = opt.conf, .uireader = opt.uir, .uiwriter = opt.uiw, .wpa_ctrl = try types.WpaControl.open(opt.wpa), .state = .stopped, + .screenstate = if (opt.conf.data.slock != null) .locked else .unlocked, .services = .{ .list = try svlist.toOwnedSlice() }, // send persisted settings immediately on start .want_settings = true, @@ -168,7 +170,6 @@ pub fn init(opt: InitOpt) !Daemon { pub fn deinit(self: *Daemon) void { self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); self.services.deinit(self.allocator); - self.conf.deinit(); } /// start launches daemon threads and returns immediately. @@ -184,6 +185,7 @@ pub fn start(self: *Daemon) !void { } try self.wpa_ctrl.attach(); + self.want_stop = false; errdefer { self.wpa_ctrl.detach() catch {}; self.want_stop = true; @@ -224,7 +226,6 @@ pub fn wait(self: *Daemon) void { } self.wpa_ctrl.detach() catch |err| logger.err("wait: wpa_ctrl.detach: {any}", .{err}); - self.want_stop = false; self.state = .stopped; } @@ -237,8 +238,16 @@ fn standby(self: *Daemon) !void { .stopped, .poweroff => return Error.InvalidState, .wallet_reset => return Error.WalletResetActive, .running => { - try screen.backlight(.off); + const has_lock = self.conf.safeReadOnly(struct { + fn f(data: Config.Data, _: Config.StaticData) bool { + return data.slock != null; + } + }.f); + if (has_lock) { + self.screenstate = .locked; + } self.state = .standby; + try screen.backlight(.off); }, } } @@ -338,6 +347,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { fn f(conf: Config.Data, static: Config.StaticData) bool { const msg: comm.Message.Settings = .{ .hostname = static.hostname, + .slock_enabled = conf.slock != null, .sysupdates = .{ .channel = switch (conf.syschannel) { .dev => .edge, @@ -374,6 +384,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { // onchain bitcoin stats if (self.want_onchain_report or self.bitcoin_timer.read() > self.onchain_report_interval) { + // TODO: this takes too long; run in a separate thread if (self.sendOnchainReport()) { self.bitcoin_timer.reset(); self.want_onchain_report = false; @@ -385,6 +396,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { // lightning stats if (self.state != .wallet_reset) { if (self.want_lnd_report or self.lnd_timer.read() > self.lnd_report_interval) { + // TODO: this takes too long; run in a separate thread if (self.sendLightningReport()) { self.lnd_timer.reset(); self.want_lnd_report = false; @@ -439,9 +451,13 @@ fn commThreadLoop(self: *Daemon) void { self.reportNetworkStatus(.{ .scan = req.scan }); }, .wifi_connect => |req| { - self.startConnectWifi(req.ssid, req.password) catch |err| { - logger.err("startConnectWifi: {any}", .{err}); - }; + if (self.screenstate != .locked) { + self.startConnectWifi(req.ssid, req.password) catch |err| { + logger.err("startConnectWifi: {any}", .{err}); + }; + } else { + logger.warn("refusing wifi connect: screen is locked", .{}); + } }, .standby => { logger.info("entering standby mode", .{}); @@ -452,38 +468,68 @@ fn commThreadLoop(self: *Daemon) void { self.wakeup() catch |err| logger.err("nd.wakeup: {any}", .{err}); }, .switch_sysupdates => |chan| { - logger.info("switching sysupdates channel to {s}", .{@tagName(chan)}); - self.switchSysupdates(chan) catch |err| { - logger.err("switchSysupdates: {any}", .{err}); - // TODO: send err back to ngui - }; + if (self.screenstate != .locked) { + logger.info("switching sysupdates channel to {s}", .{@tagName(chan)}); + self.switchSysupdates(chan) catch |err| { + logger.err("switchSysupdates: {any}", .{err}); + // TODO: send err back to ngui + }; + } else { + logger.warn("ignoring sysupdates switch: screen is locked", .{}); + } }, .set_nodename => |newname| { - self.setNodename(newname) catch |err| { - logger.err("setNodename: {!}", .{err}); - // TODO: send err back to ngui - }; + if (self.screenstate != .locked) { + self.setNodename(newname) catch |err| { + logger.err("setNodename: {!}", .{err}); + // TODO: send err back to ngui + }; + } else { + logger.warn("ignoring nodename change: screen is locked", .{}); + } }, .lightning_genseed => { + // non commital: ok even if the screen is locked self.generateWalletSeed() catch |err| { logger.err("generateWalletSeed: {!}", .{err}); // TODO: send err back to ngui }; }, .lightning_init_wallet => |req| { - self.initWallet(req) catch |err| { - logger.err("initWallet: {!}", .{err}); - // TODO: send err back to ngui - }; + if (self.screenstate != .locked) { + self.initWallet(req) catch |err| { + logger.err("initWallet: {!}", .{err}); + // TODO: send err back to ngui + }; + } else { + logger.warn("ignoring lnd wallet init: screen is locked", .{}); + } }, .lightning_get_ctrlconn => { - self.sendLightningPairingConn() catch |err| { - logger.err("sendLightningPairingConn: {!}", .{err}); - // TODO: send err back to ngui - }; + if (self.screenstate != .locked) { + self.sendLightningPairingConn() catch |err| { + logger.err("sendLightningPairingConn: {!}", .{err}); + // TODO: send err back to ngui + }; + } else { + logger.warn("refusing to give out lnd pairing: screen is locked", .{}); + } }, .lightning_reset => { - self.resetLndNode() catch |err| logger.err("resetLndNode: {!}", .{err}); + if (self.screenstate != .locked) { + self.resetLndNode() catch |err| logger.err("resetLndNode: {!}", .{err}); + } else { + logger.warn("refusing lnd reset: screen is locked", .{}); + } + }, + .slock_set_pincode => |pincode_or_null| { + self.conf.setSlockPin(pincode_or_null) catch |err| logger.err("conf.setSlockPin: {!}", .{err}); + self.mu.lock(); + self.want_settings = true; + self.mu.unlock(); + }, + .unlock_screen => |pincode| { + self.unlockScreen(pincode) catch |err| logger.err("unlockScreen: {!}", .{err}); }, else => |v| logger.warn("unhandled msg tag {s}", .{@tagName(v)}), } @@ -496,6 +542,24 @@ fn commThreadLoop(self: *Daemon) void { logger.info("exiting comm thread loop", .{}); } +/// all callers must belong to comm thread due to self.screenstate access. +fn unlockScreen(self: *Daemon, pincode: []const u8) !void { + const pindup = try self.allocator.dupe(u8, pincode); + defer self.allocator.free(pindup); + // TODO: slow down + self.conf.verifySlockPin(pindup) catch |err| { + logger.err("verifySlockPin: {!}", .{err}); + const errmsg: comm.Message = .{ .screen_unlock_result = .{ + .ok = false, + .err = if (err == error.IncorrectSlockPin) "incorrect pin code" else "unlock failed", + } }; + return comm.write(self.allocator, self.uiwriter, errmsg); + }; + const ok: comm.Message = .{ .screen_unlock_result = .{ .ok = true } }; + comm.write(self.allocator, self.uiwriter, ok) catch |err| logger.err("{!}", .{err}); + self.screenstate = .unlocked; +} + /// sends poweroff progress to uiwriter in comm.Message.PoweroffProgress format. fn sendPoweroffReport(self: *Daemon) !void { var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.list.len); @@ -1217,17 +1281,18 @@ fn allocSanitizeNodename(allocator: std.mem.Allocator, name: []const u8) ![]cons return allocator.dupe(u8, trimmed); } -test "start-stop" { +test "daemon: start-stop" { const t = std.testing; const pipe = try types.IoPipe.create(); var daemon = try Daemon.init(.{ .allocator = t.allocator, - .confpath = "/unused.json", + .conf = try dummyTestConfig(), .uir = pipe.reader(), .uiw = pipe.writer(), .wpa = "/dev/null", }); + defer daemon.conf.deinit(); daemon.want_settings = false; daemon.want_network_report = false; daemon.want_onchain_report = false; @@ -1262,7 +1327,7 @@ test "start-stop" { try t.expect(!daemon.wpa_ctrl.opened); } -test "start-poweroff" { +test "daemon: start-poweroff" { const t = std.testing; const tt = @import("../test.zig"); @@ -1275,7 +1340,7 @@ test "start-poweroff" { const gui_reader = gui_stdin.reader(); var daemon = try Daemon.init(.{ .allocator = arena, - .confpath = "/unused.json", + .conf = try dummyTestConfig(), .uir = gui_stdout.reader(), .uiw = gui_stdin.writer(), .wpa = "/dev/null", @@ -1286,6 +1351,7 @@ test "start-poweroff" { daemon.want_lnd_report = false; defer { daemon.deinit(); + daemon.conf.deinit(); gui_stdin.close(); } @@ -1326,3 +1392,78 @@ test "start-poweroff" { // need custom runner to set up a global registry for child processes. // https://github.com/ziglang/zig/pull/13411 } + +test "daemon: screen unlock" { + const t = std.testing; + const tt = @import("../test.zig"); + + var arena_alloc = std.heap.ArenaAllocator.init(t.allocator); + defer arena_alloc.deinit(); + const arena = arena_alloc.allocator(); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + const correct_pin = "12345"; + var ndconf = try Config.init(t.allocator, try tmp.join(&.{"ndconf.json"})); + defer ndconf.deinit(); + try ndconf.setSlockPin(correct_pin); + + const gui_stdin = try types.IoPipe.create(); + const gui_stdout = try types.IoPipe.create(); + const gui_reader = gui_stdin.reader(); + var daemon = try Daemon.init(.{ + .allocator = arena, + .conf = ndconf, + .uir = gui_stdout.reader(), + .uiw = gui_stdin.writer(), + .wpa = "/dev/null", + }); + defer { + daemon.deinit(); + gui_stdin.close(); + } + daemon.want_settings = false; + daemon.want_network_report = false; + daemon.want_onchain_report = false; + daemon.want_lnd_report = false; + + try daemon.start(); + try t.expect(daemon.screenstate == .locked); + try comm.write(arena, gui_stdout.writer(), .{ .unlock_screen = "000" }); + try comm.write(arena, gui_stdout.writer(), .{ .unlock_screen = correct_pin }); + + { + const msg = try comm.read(arena, gui_reader); + try t.expect(!msg.value.screen_unlock_result.ok); + } + { + const msg = try comm.read(arena, gui_reader); + try t.expect(msg.value.screen_unlock_result.ok); + } + + daemon.stop(); + gui_stdout.close(); + daemon.wait(); + try t.expect(daemon.screenstate == .unlocked); +} + +fn dummyTestConfig() !Config { + const talloc = std.testing.allocator; + var arena = try talloc.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(talloc); + return Config{ + .arena = arena, + .confpath = "/dummy.conf", + .data = .{ + .slock = null, + .syschannel = .master, + .syscronscript = "", + .sysrunscript = "", + }, + .static = .{ + .hostname = "testhost", + .lnd_user = null, + .lnd_tor_hostname = null, + .bitcoind_rpc_pass = null, + }, + }; +} diff --git a/src/ngui.zig b/src/ngui.zig index 6b15240..bf6db69 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -8,6 +8,7 @@ const types = @import("types.zig"); const ui = @import("ui/ui.zig"); const lvgl = @import("ui/lvgl.zig"); const screen = @import("ui/screen.zig"); +const screenlock = @import("ui/screenlock.zig"); const symbol = @import("ui/symbol.zig"); const logger = std.log.scoped(.ngui); @@ -26,13 +27,18 @@ var gpa: std.mem.Allocator = undefined; var ui_mutex: std.Thread.Mutex = .{}; /// current state of the GUI. -/// guarded by ui_mutex since some nm_xxx funcs branch based off of the state. +/// guarded by `ui_mutex` since some `nm_xxx` funcs branch based off of the state. var state: enum { active, // normal operational mode standby, // idling alert, // draw user attention; never go standby } = .active; +var slock_status: enum { + enabled, + disabled, +} = undefined; // set in main after parsing cmd line flags + /// last report received from comm. /// deinit'ed at program exit. /// while deinit and replace handle concurrency, field access requires holding mu. @@ -224,68 +230,58 @@ fn commThreadLoopCycle() !void { const msg = try comm.pipeRead(); // blocking ui_mutex.lock(); // guards the state and all UI calls below defer ui_mutex.unlock(); - switch (state) { - .standby => switch (msg.value) { - .ping => { - defer msg.deinit(); - try comm.pipeWrite(comm.Message.pong); - }, - .network_report, - .onchain_report, - .lightning_report, - .lightning_error, - => last_report.replace(msg), - .lightning_genseed_result, - .lightning_ctrlconn, - // TODO: merge standby vs active switch branches - => { - ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); - msg.deinit(); - }, - .settings => |sett| { - ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); - msg.deinit(); - }, - else => { - logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}); - msg.deinit(); - }, + switch (msg.value) { + .ping => { + defer msg.deinit(); + try comm.pipeWrite(comm.Message.pong); }, - .active, .alert => switch (msg.value) { - .ping => { - defer msg.deinit(); - try comm.pipeWrite(comm.Message.pong); - }, - .poweroff_progress => |rep| { - ui.poweroff.updateStatus(rep) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); - msg.deinit(); - }, - .network_report => |rep| { + .poweroff_progress => |rep| { + ui.poweroff.updateStatus(rep) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + msg.deinit(); + }, + .network_report => |rep| { + if (state != .standby) { updateNetworkStatus(rep) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); - last_report.replace(msg); - }, - .onchain_report => |rep| { + } + last_report.replace(msg); + }, + .onchain_report => |rep| { + if (state != .standby) { ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); - last_report.replace(msg); - }, - .lightning_report, .lightning_error => { - ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); - last_report.replace(msg); - }, - .lightning_genseed_result, - .lightning_ctrlconn, - => { + } + last_report.replace(msg); + }, + .lightning_report, .lightning_error => { + if (state != .standby) { ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); - msg.deinit(); - }, - .settings => |sett| { - ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); - msg.deinit(); - }, - else => { - logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}); - msg.deinit(); - }, + } + last_report.replace(msg); + }, + .lightning_genseed_result, + .lightning_ctrlconn, + => { + ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + msg.deinit(); + }, + .settings => |sett| { + ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); + slock_status = if (sett.slock_enabled) .enabled else .disabled; + msg.deinit(); + }, + .screen_unlock_result => |unlock| { + if (unlock.ok) { + ui.screenlock.unlockSuccess(); + } else { + var errmsg: [:0]const u8 = "invalid pin code"; + if (unlock.err) |s| { + errmsg = gpa.dupeZ(u8, s) catch errmsg; + } + ui.screenlock.unlockFailure(errmsg); + } + }, + else => { + logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}); + msg.deinit(); }, } } @@ -306,6 +302,9 @@ fn uiThreadLoop() void { // go into a screen sleep mode due to no user activity wakeup.reset(); comm.pipeWrite(comm.Message.standby) catch |err| logger.err("standby: {any}", .{err}); + if (slock_status == .enabled) { + screenlock.activate(); + } screen.sleep(&ui_mutex, &wakeup); // blocking // wake up due to touch screen activity or wakeup event is set @@ -346,7 +345,25 @@ fn uiThreadLoop() void { logger.info("exiting UI thread loop", .{}); } -fn parseArgs(alloc: std.mem.Allocator) !void { +/// prints usage help text to stderr. +fn usage(prog: []const u8) !void { + try stderr.print( + \\usage: {s} [-v] [-slock] + \\ + \\ngui is nakamochi GUI interface. it communicates with nd, nakamochi daemon, + \\via stdio and is typically launched by the daemon as a child process. + \\ + \\-slock makes the interface start up in a screenlocked mode. + , .{prog}); +} + +const CmdFlags = struct { + slock: bool, // whether to start the UI in screen locked mode +}; + +fn parseArgs(alloc: std.mem.Allocator) !CmdFlags { + var flags = CmdFlags{ .slock = false }; + var args = try std.process.ArgIterator.initWithAllocator(alloc); defer args.deinit(); const prog = args.next() orelse return error.NoProgName; @@ -358,22 +375,14 @@ fn parseArgs(alloc: std.mem.Allocator) !void { } else if (std.mem.eql(u8, a, "-v")) { try stderr.print("{any}\n", .{buildopts.semver}); std.process.exit(0); + } else if (std.mem.eql(u8, a, "-slock")) { + flags.slock = true; } else { logger.err("unknown arg name {s}", .{a}); return error.UnknownArgName; } } -} - -/// prints usage help text to stderr. -fn usage(prog: []const u8) !void { - try stderr.print( - \\usage: {s} [-v] - \\ - \\ngui is nakamochi GUI interface. it communicates with nd, nakamochi daemon, - \\via stdio and is typically launched by the daemon as a child process. - \\ - , .{prog}); + return flags; } /// handles sig TERM and INT: makes the program exit. @@ -395,8 +404,9 @@ pub fn main() anyerror!void { logger.err("memory leaks detected", .{}); }; gpa = gpa_state.allocator(); - try parseArgs(gpa); + const flags = try parseArgs(gpa); logger.info("ndg version {any}", .{buildopts.semver}); + slock_status = if (flags.slock) .enabled else .disabled; // ensure timer is available on this platform before doing anything else; // the UI is unusable otherwise. @@ -406,7 +416,7 @@ pub fn main() anyerror!void { comm.initPipe(gpa, .{ .r = std.io.getStdIn(), .w = std.io.getStdOut() }); // initalizes display, input driver and finally creates the user interface. - ui.init(gpa) catch |err| { + ui.init(.{ .allocator = gpa, .slock = flags.slock }) catch |err| { logger.err("ui.init: {any}", .{err}); return err; }; @@ -422,6 +432,7 @@ pub fn main() anyerror!void { const th = try std.Thread.spawn(.{}, uiThreadLoop, .{}); th.detach(); } + { // start comms with daemon in a seaparate thread. const th = try std.Thread.spawn(.{}, commThreadLoop, .{}); diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 65401a7..c0734c3 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -29,6 +29,7 @@ fn fatal(comptime fmt: []const u8, args: anytype) noreturn { const Flags = struct { ngui_path: ?[:0]const u8 = null, + slock: bool = false, // gui screen lock fn deinit(self: @This(), allocator: std.mem.Allocator) void { if (self.ngui_path) |p| allocator.free(p); @@ -57,6 +58,8 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { } if (std.mem.eql(u8, a, "-ngui")) { lastarg = .ngui_path; + } else if (std.mem.eql(u8, a, "-slock")) { + flags.slock = true; } else { fatal("unknown arg name {s}", .{a}); } @@ -74,9 +77,18 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { } /// global vars for comm read/write threads -var mu: std.Thread.Mutex = .{}; -var nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}; -var settings_sent = false; +var state: struct { + mu: std.Thread.Mutex = .{}, + nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}, + slock_pincode: ?[]const u8 = null, // disabled when null + settings_sent: bool = false, + + fn deinit(self: @This(), gpa: std.mem.Allocator) void { + if (self.slock_pincode) |s| { + gpa.free(s); + } + } +} = .{}; fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err}); @@ -144,10 +156,39 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .{ .lightning_ctrlconn = conn }) catch |err| logger.err("{!}", .{err}); }, .set_nodename => |s| { - mu.lock(); - defer mu.unlock(); - nodename.set(s); - settings_sent = false; + state.mu.lock(); + defer state.mu.unlock(); + state.nodename.set(s); + state.settings_sent = false; + }, + .unlock_screen => |pin| { + logger.info("unlock pincode: {s}", .{pin}); + time.sleep(1 * time.ns_per_s); + state.mu.lock(); + defer state.mu.unlock(); + if (state.slock_pincode == null or std.mem.eql(u8, pin, state.slock_pincode.?)) { + const res: comm.Message.ScreenUnlockResult = .{ + .ok = true, + .err = null, + }; + comm.write(gpa, w, .{ .screen_unlock_result = res }) catch |err| logger.err("{!}", .{err}); + } else { + comm.write(gpa, w, .{ .screen_unlock_result = .{ + .ok = false, + .err = "incorrect pin code", + } }) catch |err| logger.err("{!}", .{err}); + } + }, + .slock_set_pincode => |newpin| { + logger.info("slock_set_pincode: {?s}", .{newpin}); + time.sleep(1 * time.ns_per_s); + state.mu.lock(); + defer state.mu.unlock(); + if (state.slock_pincode) |s| { + gpa.free(s); + } + state.slock_pincode = if (newpin) |pin| gpa.dupe(u8, pin) catch unreachable else null; + state.settings_sent = false; }, else => {}, } @@ -169,18 +210,19 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { } sectimer.reset(); - mu.lock(); - defer mu.unlock(); + state.mu.lock(); + defer state.mu.unlock(); - if (!settings_sent) { - settings_sent = true; + if (!state.settings_sent) { + state.settings_sent = true; const sett: comm.Message.Settings = .{ - .hostname = nodename.val(), + .slock_enabled = state.slock_pincode != null, + .hostname = state.nodename.val(), .sysupdates = .{ .channel = .edge }, }; comm.write(gpa, w, .{ .settings = sett }) catch |err| { logger.err("{}", .{err}); - settings_sent = false; + state.settings_sent = false; }; } @@ -306,9 +348,17 @@ pub fn main() !void { const flags = try parseArgs(gpa); defer flags.deinit(gpa); - nodename.set("guiplayhost"); + state.slock_pincode = if (flags.slock) try gpa.dupe(u8, "0000") else null; + state.nodename.set("guiplayhost"); + defer state.deinit(gpa); - ngui_proc = std.ChildProcess.init(&.{flags.ngui_path.?}, gpa); + var a = std.ArrayList([]const u8).init(gpa); + defer a.deinit(); + try a.append(flags.ngui_path.?); + if (flags.slock) { + try a.append("-slock"); + } + ngui_proc = std.ChildProcess.init(a.items, gpa); ngui_proc.stdin_behavior = .Pipe; ngui_proc.stdout_behavior = .Pipe; ngui_proc.stderr_behavior = .Inherit; diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index 8737f75..81a9b81 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -10,6 +10,13 @@ #include #include +static lv_style_t style_title; +static lv_style_t style_text_muted; +static lv_style_t style_btn_red; +static const lv_font_t *font_large; +static lv_obj_t *virt_keyboard; +static lv_obj_t *tabview; /* main tabs content parent; lv_tabview_create */ + /** * initiates system shutdown leading to poweroff. */ @@ -35,6 +42,11 @@ int nm_create_lightning_panel(lv_obj_t *parent); */ lv_obj_t *nm_create_settings_nodename(lv_obj_t *parent); +/** + * creates screenlock card of the settings panel. + */ +lv_obj_t *nm_create_settings_screenlock(lv_obj_t *parent); + /** * creates the sysupdates section of the settings panel. */ @@ -56,13 +68,6 @@ int nm_wifi_start_connect(const char *ssid, const char *password); */ void nm_poweroff_btn_callback(lv_event_t *e); -static lv_style_t style_title; -static lv_style_t style_text_muted; -static lv_style_t style_btn_red; -static const lv_font_t *font_large; -static lv_obj_t *virt_keyboard; -static lv_obj_t *tabview; /* main tabs content parent; lv_tabview_create */ - /** * returns user-managed data previously set on an object with nm_obj_set_userdata. * the returned value may be NULL. @@ -254,6 +259,7 @@ static int create_settings_panel(lv_obj_t *parent) ********************/ // ported to zig; lv_obj_t *nodename_panel = nm_create_settings_nodename(parent); + lv_obj_t *screenlock_panel = nm_create_settings_screenlock(parent); lv_obj_t *sysupdates_panel = nm_create_settings_sysupdates(parent); /******************** @@ -263,14 +269,16 @@ static int create_settings_panel(lv_obj_t *parent) static lv_coord_t parent_grid_rows[] = {/**/ LV_GRID_CONTENT, /* wifi panel */ LV_GRID_CONTENT, /* nodename panel */ + LV_GRID_CONTENT, /* screenlock panel */ LV_GRID_CONTENT, /* power panel */ LV_GRID_CONTENT, /* sysupdates panel */ LV_GRID_TEMPLATE_LAST}; lv_obj_set_grid_dsc_array(parent, parent_grid_cols, parent_grid_rows); lv_obj_set_grid_cell(wifi_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 0, 1); lv_obj_set_grid_cell(nodename_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 1, 1); - lv_obj_set_grid_cell(power_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 2, 1); - lv_obj_set_grid_cell(sysupdates_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 3, 1); + lv_obj_set_grid_cell(screenlock_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 2, 1); + lv_obj_set_grid_cell(power_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 3, 1); + lv_obj_set_grid_cell(sysupdates_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 4, 1); static lv_coord_t wifi_grid_cols[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; static lv_coord_t wifi_grid_rows[] = {/**/ @@ -325,7 +333,7 @@ static void tab_changed_event_cb(lv_event_t *e) } } -extern int nm_ui_init(lv_disp_t *disp) +extern void nm_ui_init_theme(lv_disp_t *disp) { /* default theme is static */ lv_theme_t *theme = lv_theme_default_init(disp, /**/ @@ -344,18 +352,21 @@ extern int nm_ui_init(lv_disp_t *disp) lv_style_init(&style_btn_red); lv_style_set_bg_color(&style_btn_red, lv_palette_main(LV_PALETTE_RED)); +} +extern int nm_ui_init_main_tabview(lv_obj_t *scr) +{ /* global virtual keyboard */ - virt_keyboard = lv_keyboard_create(lv_scr_act()); + virt_keyboard = lv_keyboard_create(scr); if (virt_keyboard == NULL) { - /* TODO: or continue without keyboard? */ return -1; } lv_obj_set_style_max_height(virt_keyboard, NM_DISP_HOR * 2 / 3, 0); lv_obj_add_flag(virt_keyboard, LV_OBJ_FLAG_HIDDEN); + /* the paren of all main tabs */ const lv_coord_t tabh = 60; - tabview = lv_tabview_create(lv_scr_act(), LV_DIR_TOP, tabh); + tabview = lv_tabview_create(scr, LV_DIR_TOP, tabh); if (tabview == NULL) { return -1; } diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 152f535..39e0886 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -427,6 +427,11 @@ pub const WidgetMethods = struct { lv_obj_align(self.lvobj, @intFromEnum(a), xoffset, yoffset); } + /// similar to `posAlign` but the alignment is in relation to another object `rel`. + pub fn posAlignTo(self: anytype, rel: anytype, a: PosAlign, xoffset: Coord, yoffset: Coord) void { + lv_obj_align_to(self.lvobj, rel.lvobj, @intFromEnum(a), xoffset, yoffset); + } + /// sets flex layout growth property; same meaning as in CSS flex. pub fn flexGrow(self: anytype, val: u8) void { lv_obj_set_flex_grow(self.lvobj, val); @@ -495,7 +500,7 @@ pub const Screen = struct { /// makes a screen active. pub fn load(scr: Screen) void { - lv_disp_load_scr(scr.obj); + lv_disp_load_scr(scr.lvobj); } }; @@ -710,10 +715,11 @@ pub const Label = struct { }; /// the text value is copied into a heap-allocated alloc. - pub fn new(parent: anytype, text: [*:0]const u8, opt: Opt) !Label { + pub fn new(parent: anytype, text: ?[*:0]const u8, opt: Opt) !Label { var lv_label = lv_label_create(parent.lvobj) orelse return error.OutOfMemory; - //lv_label_set_text_static(lb, text); // static doesn't work with .dot - lv_label_set_text(lv_label, text); + if (text) |s| { + lv_label_set_text(lv_label, s); + } //lv_obj_set_height(lb, sizeContent); // default if (opt.long_mode) |m| { lv_label_set_long_mode(lv_label, @intFromEnum(m)); @@ -736,8 +742,8 @@ pub const Label = struct { /// sets label text to a new value. /// previous value is dealloc'ed. - pub fn setText(self: Label, text: [*:0]const u8) void { - lv_label_set_text(self.lvobj, text); + pub fn setText(self: Label, text: [:0]const u8) void { + lv_label_set_text(self.lvobj, text.ptr); } /// sets label text without heap alloc but assumes text outlives the label obj. @@ -955,6 +961,38 @@ pub const QrCode = struct { } }; +pub const Keyboard = struct { + lvobj: *LvObj, + + pub usingnamespace BaseObjMethods; + pub usingnamespace WidgetMethods; + + const Mode = enum(c.lv_keyboard_mode_t) { + lower = c.LV_KEYBOARD_MODE_TEXT_LOWER, + upper = c.LV_KEYBOARD_MODE_TEXT_UPPER, + special = c.LV_KEYBOARD_MODE_SPECIAL, + number = c.LV_KEYBOARD_MODE_NUMBER, + user1 = c.LV_KEYBOARD_MODE_USER_1, + user2 = c.LV_KEYBOARD_MODE_USER_2, + user3 = c.LV_KEYBOARD_MODE_USER_3, + user4 = c.LV_KEYBOARD_MODE_USER_4, + }; + + pub fn new(parent: anytype, mode: Mode) !Keyboard { + const kb = lv_keyboard_create(parent.lvobj) orelse return error.OutOfMemory; + lv_keyboard_set_mode(kb, @intFromEnum(mode)); + return .{ .lvobj = kb }; + } + + pub fn attach(self: Keyboard, ta: TextArea) void { + lv_keyboard_set_textarea(self.lvobj, ta.lvobj); + } + + pub fn setMode(self: Keyboard, m: Mode) void { + lv_keyboard_set_mode(self.lvobj, m); + } +}; + /// represents lv_obj_t type in C. pub const LvObj = opaque { /// feature-flags controlling object's behavior. @@ -1191,6 +1229,7 @@ extern fn lv_obj_clear_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; extern fn lv_obj_has_flag(obj: *LvObj, v: c.lv_obj_flag_t) bool; extern fn lv_obj_align(obj: *LvObj, a: c.lv_align_t, x: c.lv_coord_t, y: c.lv_coord_t) void; +extern fn lv_obj_align_to(obj: *LvObj, rel: *LvObj, a: c.lv_align_t, x: c.lv_coord_t, y: c.lv_coord_t) void; extern fn lv_obj_set_height(obj: *LvObj, h: c.lv_coord_t) void; extern fn lv_obj_set_width(obj: *LvObj, w: c.lv_coord_t) void; extern fn lv_obj_set_size(obj: *LvObj, w: c.lv_coord_t, h: c.lv_coord_t) void; @@ -1243,3 +1282,7 @@ extern fn lv_win_get_content(win: *LvObj) *LvObj; extern fn lv_qrcode_create(parent: *LvObj, size: c.lv_coord_t, dark: Color, light: Color) ?*LvObj; extern fn lv_qrcode_update(qrcode: *LvObj, data: *const anyopaque, data_len: u32) c.lv_res_t; + +extern fn lv_keyboard_create(parent: *LvObj) ?*LvObj; +extern fn lv_keyboard_set_textarea(kb: *LvObj, ta: *LvObj) void; +extern fn lv_keyboard_set_mode(kb: *LvObj, mode: c.lv_keyboard_mode_t) void; diff --git a/src/ui/screenlock.zig b/src/ui/screenlock.zig new file mode 100644 index 0000000..e5fb7a3 --- /dev/null +++ b/src/ui/screenlock.zig @@ -0,0 +1,90 @@ +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); + +const logger = std.log.scoped(.ui_screenlock); +const infoTextInit = "please enter pin code to unlock the screen"; + +var main_screen: lvgl.Screen = undefined; +var locked_screen: lvgl.Screen = undefined; + +var pincode: lvgl.TextArea = undefined; +var info: lvgl.Label = undefined; +var spinner: lvgl.Spinner = undefined; +var keyboard: lvgl.Keyboard = undefined; + +var is_active: bool = false; + +pub fn init(main_scr: lvgl.Screen) !void { + main_screen = main_scr; + locked_screen = try lvgl.Screen.new(); + + pincode = try lvgl.TextArea.new(locked_screen, .{ .password_mode = true }); + pincode.posAlign(.top_mid, 0, 20); + _ = pincode.on(.ready, nm_pincode_input, null); + + info = try lvgl.Label.new(locked_screen, null, .{}); + info.setTextStatic(infoTextInit); + info.posAlignTo(pincode, .out_bottom_mid, 0, 10); + + spinner = try lvgl.Spinner.new(locked_screen); + spinner.center(); + spinner.hide(); + + keyboard = try lvgl.Keyboard.new(locked_screen, .number); + keyboard.attach(pincode); +} + +pub fn activate() void { + if (is_active) { + logger.info("screenlock already active", .{}); + return; + } + is_active = true; + locked_screen.load(); + spinner.hide(); + pincode.enable(); + pincode.setText(""); + info.setTextStatic(infoTextInit); + info.posAlignTo(pincode, .out_bottom_mid, 0, 10); + keyboard.show(); + logger.info("screenlock active", .{}); +} + +/// msg lifetime is only until function return. +pub fn unlockFailure(msg: [:0]const u8) void { + info.setText(msg); + info.posAlignTo(pincode, .out_bottom_mid, 0, 10); + spinner.hide(); + pincode.setText(""); + pincode.enable(); + keyboard.show(); +} + +pub fn unlockSuccess() void { + logger.info("deactivating screenlock", .{}); + pincode.setText(""); + main_screen.load(); + is_active = false; +} + +export fn nm_pincode_input(e: *lvgl.LvEvent) void { + switch (e.code()) { + .ready => { + keyboard.hide(); + pincode.disable(); + spinner.show(); + comm.pipeWrite(.{ .unlock_screen = pincode.text() }) catch |err| { + logger.err("unlock_screen pipe write: {!}", .{err}); + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrintZ(&buf, "internal error: {!}", .{err}) catch { + unlockFailure("internal error"); + return; + }; + unlockFailure(msg); + }; + }, + else => {}, + } +} diff --git a/src/ui/settings.zig b/src/ui/settings.zig index c9eb298..53f3932 100644 --- a/src/ui/settings.zig +++ b/src/ui/settings.zig @@ -16,9 +16,13 @@ const logger = std.log.scoped(.ui); /// label color mark start to make "label:" part of a "label: value" /// in a different color. const cmark = "#bbbbbb "; -/// buttons text +/// button labels and other text const textSwitch = "SWITCH"; const textChange = "CHANGE"; +const textDisable = "DISABLE"; +const textSlockBtnEnable = "SET PIN CODE"; +const textSlockDisabled = "screenlock is disabled\nset a pin code to activate"; +const textSlockEnabled = "screenlock is enabled\nit activates once in standby mode"; // global allocator set in init. // must be set before any call into pub funcs in this module. @@ -32,6 +36,33 @@ var tab: struct { textarea: lvgl.TextArea, changebtn: lvgl.TextButton, }, + screenlock: struct { + card: lvgl.Card, + textlabel: lvgl.Label, + enbtn: lvgl.TextButton, + disbtn: lvgl.TextButton, + + setpin_win: lvgl.Window, + setpin_label: lvgl.Label, + setpin_input: lvgl.TextArea, + + fn beginSetPin(self: *@This()) !void { + self.setpin_win = try lvgl.Window.newTop(60, "SET SCREENLOCK PIN CODE"); + const wincont = self.setpin_win.content().flex(.column, .{ .cross = .center, .track = .center }); + self.setpin_input = try lvgl.TextArea.new(wincont, .{ .password_mode = true }); + self.setpin_input.posAlign(.top_mid, 0, 20); + _ = self.setpin_input.on(.ready, nm_screenlock_pincode_input, null); + self.setpin_label = try lvgl.Label.new(wincont, null, .{}); + self.setpin_label.posAlignTo(self.setpin_input, .out_bottom_mid, 0, 10); + self.setpin_label.setTextStatic("please enter a new pin code"); + const kb = try lvgl.Keyboard.new(self.setpin_win, .number); + kb.attach(self.setpin_input); + } + + fn endSetPin(self: @This()) void { + self.setpin_win.destroy(); + } + }, sysupdates: struct { card: lvgl.Card, chansel: lvgl.Dropdown, @@ -42,8 +73,12 @@ var tab: struct { /// holds last values received from the daemon. var state: struct { + // node name nodename_change_inprogress: bool = false, curr_nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}, + // screenlock + slock_pin_input1: ?[]const u8 = null, // verified against a second time input + // sysupdates channel curr_sysupdates_chan: ?comm.Message.SysupdatesChan = null, } = .{}; @@ -94,6 +129,38 @@ pub fn initNodenamePanel(cont: lvgl.Container) !lvgl.Card { return tab.nodename.card; } +/// creates a settings panel for setting screenlock pin code. +pub fn initScreenlockPanel(cont: lvgl.Container) !lvgl.Card { + tab.screenlock.card = try lvgl.Card.new(cont, symbol.EyeClose ++ " SCREENLOCK", .{ .spinner = true }); + + const row = try lvgl.FlexLayout.new(tab.screenlock.card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{ .height = .content }); + left.flexGrow(1); + left.setPad(10, .row, .{}); + tab.screenlock.textlabel = try lvgl.Label.new(left, null, .{}); + tab.screenlock.textlabel.setTextStatic("no info available yet"); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{ .height = .content }); + right.flexGrow(1); + right.setPad(10, .row, .{}); + tab.screenlock.enbtn = try lvgl.TextButton.new(right, textSlockBtnEnable); + tab.screenlock.enbtn.setWidth(lvgl.sizePercent(100)); + tab.screenlock.enbtn.hide(); + _ = tab.screenlock.enbtn.on(.click, nm_screenlock_enbtn_click, null); + tab.screenlock.disbtn = try lvgl.TextButton.new(right, textDisable); + tab.screenlock.disbtn.setWidth(lvgl.sizePercent(100)); + tab.screenlock.disbtn.addStyle(lvgl.nm_style_btn_red(), .{}); + tab.screenlock.disbtn.hide(); + _ = tab.screenlock.disbtn.on(.click, nm_screenlock_disbtn_click, null); + + return tab.screenlock.card; +} + /// creates a settings panel UI to control system updates channel. /// must be called only once at program startup. pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card { @@ -171,6 +238,20 @@ pub fn update(sett: comm.Message.Settings) !void { tab.nodename.changebtn.disable(); } } + + // screenlock + tab.screenlock.card.spin(.off); + if (sett.slock_enabled) { + tab.screenlock.textlabel.setTextStatic(textSlockEnabled); + tab.screenlock.enbtn.hide(); + tab.screenlock.disbtn.enable(); + tab.screenlock.disbtn.show(); + } else { + tab.screenlock.textlabel.setTextStatic(textSlockDisabled); + tab.screenlock.enbtn.enable(); + tab.screenlock.enbtn.show(); + tab.screenlock.disbtn.hide(); + } } export fn nm_nodename_textarea_input(e: *lvgl.LvEvent) void { @@ -202,6 +283,60 @@ export fn nm_nodename_change_btn_click(_: *lvgl.LvEvent) void { tab.nodename.card.spin(.on); } +export fn nm_screenlock_enbtn_click(_: *lvgl.LvEvent) void { + tab.screenlock.beginSetPin() catch |err| { + logger.err("screenlock.beginSetPin: {!}", .{err}); + }; +} + +export fn nm_screenlock_pincode_input(_: *lvgl.LvEvent) void { + // first time input; prompt user for second input to verify + if (state.slock_pin_input1 == null) { + state.slock_pin_input1 = allocator.dupe(u8, tab.screenlock.setpin_input.text()) catch |err| { + logger.err("unable to continue setting screenlock pin code: {!}", .{err}); + tab.screenlock.endSetPin(); + return; + }; + tab.screenlock.setpin_label.setTextStatic("please enter the pin once more to verify"); + tab.screenlock.setpin_input.setText(""); + return; + } + + // ensure first and second time inputs match + const pininput2 = tab.screenlock.setpin_input.text(); + if (!std.mem.eql(u8, pininput2, state.slock_pin_input1.?)) { + allocator.free(state.slock_pin_input1.?); + state.slock_pin_input1 = null; + tab.screenlock.setpin_label.setTextStatic("pin codes mismatch, please try again"); + tab.screenlock.setpin_input.setText(""); + return; + } + + // send the pin code to nd and return to the main settings screen + defer { + tab.screenlock.endSetPin(); + allocator.free(state.slock_pin_input1.?); + state.slock_pin_input1 = null; + } + tab.screenlock.card.spin(.on); + tab.screenlock.enbtn.disable(); + comm.pipeWrite(.{ .slock_set_pincode = pininput2 }) catch |err| { + logger.err("comm slock_set_pincode: {!}", .{err}); + tab.screenlock.card.spin(.off); + tab.screenlock.enbtn.enable(); + }; +} + +export fn nm_screenlock_disbtn_click(_: *lvgl.LvEvent) void { + tab.screenlock.card.spin(.on); + tab.screenlock.disbtn.disable(); + comm.pipeWrite(.{ .slock_set_pincode = null }) catch |err| { + logger.err("comm slock_set_pincode(null): {!}", .{err}); + tab.screenlock.card.spin(.off); + tab.screenlock.disbtn.enable(); + }; +} + export fn nm_sysupdates_chansel_changed(_: *lvgl.LvEvent) void { var buf = [_]u8{0} ** 32; const name = tab.sysupdates.chansel.getSelectedStr(&buf); diff --git a/src/ui/symbol.zig b/src/ui/symbol.zig index 22e58c7..633ccdb 100644 --- a/src/ui/symbol.zig +++ b/src/ui/symbol.zig @@ -1,5 +1,6 @@ ///! see lv_symbols_def.h pub const Edit = &[_]u8{ 0xef, 0x8c, 0x84 }; +pub const EyeClose = &[_]u8{ 0xef, 0x81, 0xb0 }; pub const LightningBolt = &[_]u8{ 0xef, 0x83, 0xa7 }; pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 }; pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c }; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index a9576bd..25f1e5b 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -10,21 +10,28 @@ const widget = @import("widget.zig"); pub const bitcoin = @import("bitcoin.zig"); pub const lightning = @import("lightning.zig"); pub const poweroff = @import("poweroff.zig"); +pub const screenlock = @import("screenlock.zig"); pub const settings = @import("settings.zig"); const logger = std.log.scoped(.ui); // defined in src/ui/c/ui.c +extern "c" fn nm_ui_init_theme(disp: *lvgl.LvDisp) void; // calls back into nm_create_xxx_panel functions defined here during init. -extern "c" fn nm_ui_init(disp: *lvgl.LvDisp) c_int; +extern "c" fn nm_ui_init_main_tabview(screen: *lvgl.LvObj) c_int; // global allocator set on init. // must be set before a call to nm_ui_init. var allocator: std.mem.Allocator = undefined; -pub fn init(gpa: std.mem.Allocator) !void { - allocator = gpa; - settings.allocator = gpa; +pub const InitOpt = struct { + allocator: std.mem.Allocator, + slock: bool, // whether to start the UI in screen locked mode +}; + +pub fn init(opt: InitOpt) !void { + allocator = opt.allocator; + settings.allocator = opt.allocator; lvgl.init(); const disp = try drv.initDisplay(); drv.initInput() catch |err| { @@ -33,8 +40,16 @@ pub fn init(gpa: std.mem.Allocator) !void { // otherwise, impossible to wake up the screen. */ return err; }; - if (nm_ui_init(disp) != 0) { - return error.UiInitFailure; + + nm_ui_init_theme(disp); + + const main_scr = try lvgl.Screen.active(); + if (nm_ui_init_main_tabview(main_scr.lvobj) != 0) { + return error.UiInitMainTabview; + } + try screenlock.init(main_scr); + if (opt.slock) { + screenlock.activate(); } } @@ -70,6 +85,14 @@ export fn nm_create_settings_nodename(parent: *lvgl.LvObj) ?*lvgl.LvObj { return card.lvobj; } +export fn nm_create_settings_screenlock(parent: *lvgl.LvObj) ?*lvgl.LvObj { + const card = settings.initScreenlockPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("initScreenlockPanel: {any}", .{err}); + return null; + }; + return card.lvobj; +} + export fn nm_create_settings_sysupdates(parent: *lvgl.LvObj) ?*lvgl.LvObj { const card = settings.initSysupdatesPanel(lvgl.Container{ .lvobj = parent }) catch |err| { logger.err("initSysupdatesPanel: {any}", .{err});