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});