ngui,nd: screenlock feature implementation
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/tag/woodpecker Pipeline was successful Details

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.
master v0.8.0
alex 8 months ago
parent a080e1ac79
commit e55471e48c
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -89,7 +89,13 @@ pub const MessageTag = enum(u16) {
set_nodename = 0x15, set_nodename = 0x15,
// nd -> ngui: all ndg settings // nd -> ngui: all ndg settings
settings = 0x0d, 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. /// daemon and gui exchange messages of this type.
@ -115,6 +121,9 @@ pub const Message = union(MessageTag) {
switch_sysupdates: SysupdatesChan, switch_sysupdates: SysupdatesChan,
set_nodename: []const u8, set_nodename: []const u8,
settings: Settings, settings: Settings,
unlock_screen: []const u8, // pincode
screen_unlock_result: ScreenUnlockResult,
slock_set_pincode: ?[]const u8,
pub const WifiConnect = struct { pub const WifiConnect = struct {
ssid: []const u8, ssid: []const u8,
@ -250,11 +259,17 @@ pub const Message = union(MessageTag) {
}; };
pub const Settings = struct { pub const Settings = struct {
slock_enabled: bool,
hostname: []const u8, // see .set_nodename hostname: []const u8, // see .set_nodename
sysupdates: struct { sysupdates: struct {
channel: SysupdatesChan, channel: SysupdatesChan,
}, },
}; };
pub const ScreenUnlockResult = struct {
ok: bool,
err: ?[]const u8 = null, // error message when !ok
};
}; };
/// the return value type from `read` fn. /// 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()), .switch_sysupdates => try json.stringify(msg.switch_sysupdates, .{}, data.writer()),
.set_nodename => try json.stringify(msg.set_nodename, .{}, data.writer()), .set_nodename => try json.stringify(msg.set_nodename, .{}, data.writer()),
.settings => try json.stringify(msg.settings, .{}, 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)) { if (data.items.len > std.math.maxInt(u64)) {
return Error.CommWriteTooLarge; return Error.CommWriteTooLarge;

@ -8,6 +8,7 @@ const Address = std.net.Address;
const nif = @import("nif"); const nif = @import("nif");
const comm = @import("comm.zig"); const comm = @import("comm.zig");
const Config = @import("nd/Config.zig");
const Daemon = @import("nd/Daemon.zig"); const Daemon = @import("nd/Daemon.zig");
const screen = @import("ui/screen.zig"); const screen = @import("ui/screen.zig");
@ -158,9 +159,19 @@ pub fn main() !void {
// of its previous state. // of its previous state.
screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err}); 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 // start ngui, unless -nogui mode
const gui_path = args.gui.?; // guaranteed to be non-null 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.stdin_behavior = .Pipe;
ngui.stdout_behavior = .Pipe; ngui.stdout_behavior = .Pipe;
ngui.stderr_behavior = .Inherit; ngui.stderr_behavior = .Inherit;
@ -200,7 +211,7 @@ pub fn main() !void {
var nd = try Daemon.init(.{ var nd = try Daemon.init(.{
.allocator = gpa, .allocator = gpa,
.confpath = args.conf.?, .conf = conf,
.uir = uireader, .uir = uireader,
.uiw = uiwriter, .uiw = uiwriter,
.wpa = args.wpa.?, .wpa = args.wpa.?,

@ -40,7 +40,13 @@ data: Data,
/// top struct stored on disk. /// top struct stored on disk.
/// access with `safeReadOnly` or lock/unlock `mu`. /// access with `safeReadOnly` or lock/unlock `mu`.
///
/// for backwards compatibility, all newly introduced fields must have default values.
pub const Data = struct { 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, syschannel: SysupdatesChannel,
syscronscript: []const u8, syscronscript: []const u8,
sysrunscript: []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); 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. /// used by mutateLndConf to guard concurrent access.
var lndconf_mu: std.Thread.Mutex = .{}; var lndconf_mu: std.Thread.Mutex = .{};
@ -681,3 +731,92 @@ test "ndconfig: mutate LndConf" {
\\ \\
, cont); , 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"));
}
}

@ -32,6 +32,9 @@ uireader: std.fs.File.Reader, // ngui stdout
uiwriter: std.fs.File.Writer, // ngui stdin uiwriter: std.fs.File.Writer, // ngui stdin
wpa_ctrl: types.WpaControl, // guarded by mu once start'ed 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. /// guards all the fields below to sync between pub fns and main/poweroff threads.
mu: std.Thread.Mutex = .{}, mu: std.Thread.Mutex = .{},
@ -118,7 +121,7 @@ const Error = error{
const InitOpt = struct { const InitOpt = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
confpath: []const u8, conf: Config,
uir: std.fs.File.Reader, uir: std.fs.File.Reader,
uiw: std.fs.File.Writer, uiw: std.fs.File.Writer,
wpa: [:0]const u8, 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.LND, .{ .stop_wait_sec = 600 }));
try svlist.append(sys.Service.init(opt.allocator, sys.Service.BITCOIND, .{ .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 .{ return .{
.allocator = opt.allocator, .allocator = opt.allocator,
.conf = conf, .conf = opt.conf,
.uireader = opt.uir, .uireader = opt.uir,
.uiwriter = opt.uiw, .uiwriter = opt.uiw,
.wpa_ctrl = try types.WpaControl.open(opt.wpa), .wpa_ctrl = try types.WpaControl.open(opt.wpa),
.state = .stopped, .state = .stopped,
.screenstate = if (opt.conf.data.slock != null) .locked else .unlocked,
.services = .{ .list = try svlist.toOwnedSlice() }, .services = .{ .list = try svlist.toOwnedSlice() },
// send persisted settings immediately on start // send persisted settings immediately on start
.want_settings = true, .want_settings = true,
@ -168,7 +170,6 @@ pub fn init(opt: InitOpt) !Daemon {
pub fn deinit(self: *Daemon) void { pub fn deinit(self: *Daemon) void {
self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err});
self.services.deinit(self.allocator); self.services.deinit(self.allocator);
self.conf.deinit();
} }
/// start launches daemon threads and returns immediately. /// start launches daemon threads and returns immediately.
@ -184,6 +185,7 @@ pub fn start(self: *Daemon) !void {
} }
try self.wpa_ctrl.attach(); try self.wpa_ctrl.attach();
self.want_stop = false;
errdefer { errdefer {
self.wpa_ctrl.detach() catch {}; self.wpa_ctrl.detach() catch {};
self.want_stop = true; 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.wpa_ctrl.detach() catch |err| logger.err("wait: wpa_ctrl.detach: {any}", .{err});
self.want_stop = false;
self.state = .stopped; self.state = .stopped;
} }
@ -237,8 +238,16 @@ fn standby(self: *Daemon) !void {
.stopped, .poweroff => return Error.InvalidState, .stopped, .poweroff => return Error.InvalidState,
.wallet_reset => return Error.WalletResetActive, .wallet_reset => return Error.WalletResetActive,
.running => { .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; 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 { fn f(conf: Config.Data, static: Config.StaticData) bool {
const msg: comm.Message.Settings = .{ const msg: comm.Message.Settings = .{
.hostname = static.hostname, .hostname = static.hostname,
.slock_enabled = conf.slock != null,
.sysupdates = .{ .sysupdates = .{
.channel = switch (conf.syschannel) { .channel = switch (conf.syschannel) {
.dev => .edge, .dev => .edge,
@ -374,6 +384,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void {
// onchain bitcoin stats // onchain bitcoin stats
if (self.want_onchain_report or self.bitcoin_timer.read() > self.onchain_report_interval) { 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()) { if (self.sendOnchainReport()) {
self.bitcoin_timer.reset(); self.bitcoin_timer.reset();
self.want_onchain_report = false; self.want_onchain_report = false;
@ -385,6 +396,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void {
// lightning stats // lightning stats
if (self.state != .wallet_reset) { if (self.state != .wallet_reset) {
if (self.want_lnd_report or self.lnd_timer.read() > self.lnd_report_interval) { 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()) { if (self.sendLightningReport()) {
self.lnd_timer.reset(); self.lnd_timer.reset();
self.want_lnd_report = false; self.want_lnd_report = false;
@ -439,9 +451,13 @@ fn commThreadLoop(self: *Daemon) void {
self.reportNetworkStatus(.{ .scan = req.scan }); self.reportNetworkStatus(.{ .scan = req.scan });
}, },
.wifi_connect => |req| { .wifi_connect => |req| {
if (self.screenstate != .locked) {
self.startConnectWifi(req.ssid, req.password) catch |err| { self.startConnectWifi(req.ssid, req.password) catch |err| {
logger.err("startConnectWifi: {any}", .{err}); logger.err("startConnectWifi: {any}", .{err});
}; };
} else {
logger.warn("refusing wifi connect: screen is locked", .{});
}
}, },
.standby => { .standby => {
logger.info("entering standby mode", .{}); logger.info("entering standby mode", .{});
@ -452,38 +468,68 @@ fn commThreadLoop(self: *Daemon) void {
self.wakeup() catch |err| logger.err("nd.wakeup: {any}", .{err}); self.wakeup() catch |err| logger.err("nd.wakeup: {any}", .{err});
}, },
.switch_sysupdates => |chan| { .switch_sysupdates => |chan| {
if (self.screenstate != .locked) {
logger.info("switching sysupdates channel to {s}", .{@tagName(chan)}); logger.info("switching sysupdates channel to {s}", .{@tagName(chan)});
self.switchSysupdates(chan) catch |err| { self.switchSysupdates(chan) catch |err| {
logger.err("switchSysupdates: {any}", .{err}); logger.err("switchSysupdates: {any}", .{err});
// TODO: send err back to ngui // TODO: send err back to ngui
}; };
} else {
logger.warn("ignoring sysupdates switch: screen is locked", .{});
}
}, },
.set_nodename => |newname| { .set_nodename => |newname| {
if (self.screenstate != .locked) {
self.setNodename(newname) catch |err| { self.setNodename(newname) catch |err| {
logger.err("setNodename: {!}", .{err}); logger.err("setNodename: {!}", .{err});
// TODO: send err back to ngui // TODO: send err back to ngui
}; };
} else {
logger.warn("ignoring nodename change: screen is locked", .{});
}
}, },
.lightning_genseed => { .lightning_genseed => {
// non commital: ok even if the screen is locked
self.generateWalletSeed() catch |err| { self.generateWalletSeed() catch |err| {
logger.err("generateWalletSeed: {!}", .{err}); logger.err("generateWalletSeed: {!}", .{err});
// TODO: send err back to ngui // TODO: send err back to ngui
}; };
}, },
.lightning_init_wallet => |req| { .lightning_init_wallet => |req| {
if (self.screenstate != .locked) {
self.initWallet(req) catch |err| { self.initWallet(req) catch |err| {
logger.err("initWallet: {!}", .{err}); logger.err("initWallet: {!}", .{err});
// TODO: send err back to ngui // TODO: send err back to ngui
}; };
} else {
logger.warn("ignoring lnd wallet init: screen is locked", .{});
}
}, },
.lightning_get_ctrlconn => { .lightning_get_ctrlconn => {
if (self.screenstate != .locked) {
self.sendLightningPairingConn() catch |err| { self.sendLightningPairingConn() catch |err| {
logger.err("sendLightningPairingConn: {!}", .{err}); logger.err("sendLightningPairingConn: {!}", .{err});
// TODO: send err back to ngui // TODO: send err back to ngui
}; };
} else {
logger.warn("refusing to give out lnd pairing: screen is locked", .{});
}
}, },
.lightning_reset => { .lightning_reset => {
if (self.screenstate != .locked) {
self.resetLndNode() catch |err| logger.err("resetLndNode: {!}", .{err}); 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)}), 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", .{}); 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. /// sends poweroff progress to uiwriter in comm.Message.PoweroffProgress format.
fn sendPoweroffReport(self: *Daemon) !void { fn sendPoweroffReport(self: *Daemon) !void {
var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.list.len); 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); return allocator.dupe(u8, trimmed);
} }
test "start-stop" { test "daemon: start-stop" {
const t = std.testing; const t = std.testing;
const pipe = try types.IoPipe.create(); const pipe = try types.IoPipe.create();
var daemon = try Daemon.init(.{ var daemon = try Daemon.init(.{
.allocator = t.allocator, .allocator = t.allocator,
.confpath = "/unused.json", .conf = try dummyTestConfig(),
.uir = pipe.reader(), .uir = pipe.reader(),
.uiw = pipe.writer(), .uiw = pipe.writer(),
.wpa = "/dev/null", .wpa = "/dev/null",
}); });
defer daemon.conf.deinit();
daemon.want_settings = false; daemon.want_settings = false;
daemon.want_network_report = false; daemon.want_network_report = false;
daemon.want_onchain_report = false; daemon.want_onchain_report = false;
@ -1262,7 +1327,7 @@ test "start-stop" {
try t.expect(!daemon.wpa_ctrl.opened); try t.expect(!daemon.wpa_ctrl.opened);
} }
test "start-poweroff" { test "daemon: start-poweroff" {
const t = std.testing; const t = std.testing;
const tt = @import("../test.zig"); const tt = @import("../test.zig");
@ -1275,7 +1340,7 @@ test "start-poweroff" {
const gui_reader = gui_stdin.reader(); const gui_reader = gui_stdin.reader();
var daemon = try Daemon.init(.{ var daemon = try Daemon.init(.{
.allocator = arena, .allocator = arena,
.confpath = "/unused.json", .conf = try dummyTestConfig(),
.uir = gui_stdout.reader(), .uir = gui_stdout.reader(),
.uiw = gui_stdin.writer(), .uiw = gui_stdin.writer(),
.wpa = "/dev/null", .wpa = "/dev/null",
@ -1286,6 +1351,7 @@ test "start-poweroff" {
daemon.want_lnd_report = false; daemon.want_lnd_report = false;
defer { defer {
daemon.deinit(); daemon.deinit();
daemon.conf.deinit();
gui_stdin.close(); gui_stdin.close();
} }
@ -1326,3 +1392,78 @@ test "start-poweroff" {
// need custom runner to set up a global registry for child processes. // need custom runner to set up a global registry for child processes.
// https://github.com/ziglang/zig/pull/13411 // 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,
},
};
}

@ -8,6 +8,7 @@ const types = @import("types.zig");
const ui = @import("ui/ui.zig"); const ui = @import("ui/ui.zig");
const lvgl = @import("ui/lvgl.zig"); const lvgl = @import("ui/lvgl.zig");
const screen = @import("ui/screen.zig"); const screen = @import("ui/screen.zig");
const screenlock = @import("ui/screenlock.zig");
const symbol = @import("ui/symbol.zig"); const symbol = @import("ui/symbol.zig");
const logger = std.log.scoped(.ngui); const logger = std.log.scoped(.ngui);
@ -26,13 +27,18 @@ var gpa: std.mem.Allocator = undefined;
var ui_mutex: std.Thread.Mutex = .{}; var ui_mutex: std.Thread.Mutex = .{};
/// current state of the GUI. /// 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 { var state: enum {
active, // normal operational mode active, // normal operational mode
standby, // idling standby, // idling
alert, // draw user attention; never go standby alert, // draw user attention; never go standby
} = .active; } = .active;
var slock_status: enum {
enabled,
disabled,
} = undefined; // set in main after parsing cmd line flags
/// last report received from comm. /// last report received from comm.
/// deinit'ed at program exit. /// deinit'ed at program exit.
/// while deinit and replace handle concurrency, field access requires holding mu. /// while deinit and replace handle concurrency, field access requires holding mu.
@ -224,34 +230,7 @@ fn commThreadLoopCycle() !void {
const msg = try comm.pipeRead(); // blocking const msg = try comm.pipeRead(); // blocking
ui_mutex.lock(); // guards the state and all UI calls below ui_mutex.lock(); // guards the state and all UI calls below
defer ui_mutex.unlock(); defer ui_mutex.unlock();
switch (state) { switch (msg.value) {
.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();
},
},
.active, .alert => switch (msg.value) {
.ping => { .ping => {
defer msg.deinit(); defer msg.deinit();
try comm.pipeWrite(comm.Message.pong); try comm.pipeWrite(comm.Message.pong);
@ -261,15 +240,21 @@ fn commThreadLoopCycle() !void {
msg.deinit(); msg.deinit();
}, },
.network_report => |rep| { .network_report => |rep| {
if (state != .standby) {
updateNetworkStatus(rep) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); updateNetworkStatus(rep) catch |err| logger.err("updateNetworkStatus: {any}", .{err});
}
last_report.replace(msg); last_report.replace(msg);
}, },
.onchain_report => |rep| { .onchain_report => |rep| {
if (state != .standby) {
ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err});
}
last_report.replace(msg); last_report.replace(msg);
}, },
.lightning_report, .lightning_error => { .lightning_report, .lightning_error => {
if (state != .standby) {
ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err});
}
last_report.replace(msg); last_report.replace(msg);
}, },
.lightning_genseed_result, .lightning_genseed_result,
@ -280,13 +265,24 @@ fn commThreadLoopCycle() !void {
}, },
.settings => |sett| { .settings => |sett| {
ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err});
slock_status = if (sett.slock_enabled) .enabled else .disabled;
msg.deinit(); 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 => { else => {
logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}); logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)});
msg.deinit(); msg.deinit();
}, },
},
} }
} }
@ -306,6 +302,9 @@ fn uiThreadLoop() void {
// go into a screen sleep mode due to no user activity // go into a screen sleep mode due to no user activity
wakeup.reset(); wakeup.reset();
comm.pipeWrite(comm.Message.standby) catch |err| logger.err("standby: {any}", .{err}); comm.pipeWrite(comm.Message.standby) catch |err| logger.err("standby: {any}", .{err});
if (slock_status == .enabled) {
screenlock.activate();
}
screen.sleep(&ui_mutex, &wakeup); // blocking screen.sleep(&ui_mutex, &wakeup); // blocking
// wake up due to touch screen activity or wakeup event is set // 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", .{}); 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); var args = try std.process.ArgIterator.initWithAllocator(alloc);
defer args.deinit(); defer args.deinit();
const prog = args.next() orelse return error.NoProgName; 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")) { } else if (std.mem.eql(u8, a, "-v")) {
try stderr.print("{any}\n", .{buildopts.semver}); try stderr.print("{any}\n", .{buildopts.semver});
std.process.exit(0); std.process.exit(0);
} else if (std.mem.eql(u8, a, "-slock")) {
flags.slock = true;
} else { } else {
logger.err("unknown arg name {s}", .{a}); logger.err("unknown arg name {s}", .{a});
return error.UnknownArgName; return error.UnknownArgName;
} }
} }
} return flags;
/// 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});
} }
/// handles sig TERM and INT: makes the program exit. /// handles sig TERM and INT: makes the program exit.
@ -395,8 +404,9 @@ pub fn main() anyerror!void {
logger.err("memory leaks detected", .{}); logger.err("memory leaks detected", .{});
}; };
gpa = gpa_state.allocator(); gpa = gpa_state.allocator();
try parseArgs(gpa); const flags = try parseArgs(gpa);
logger.info("ndg version {any}", .{buildopts.semver}); 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; // ensure timer is available on this platform before doing anything else;
// the UI is unusable otherwise. // 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() }); comm.initPipe(gpa, .{ .r = std.io.getStdIn(), .w = std.io.getStdOut() });
// initalizes display, input driver and finally creates the user interface. // 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}); logger.err("ui.init: {any}", .{err});
return err; return err;
}; };
@ -422,6 +432,7 @@ pub fn main() anyerror!void {
const th = try std.Thread.spawn(.{}, uiThreadLoop, .{}); const th = try std.Thread.spawn(.{}, uiThreadLoop, .{});
th.detach(); th.detach();
} }
{ {
// start comms with daemon in a seaparate thread. // start comms with daemon in a seaparate thread.
const th = try std.Thread.spawn(.{}, commThreadLoop, .{}); const th = try std.Thread.spawn(.{}, commThreadLoop, .{});

@ -29,6 +29,7 @@ fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
const Flags = struct { const Flags = struct {
ngui_path: ?[:0]const u8 = null, ngui_path: ?[:0]const u8 = null,
slock: bool = false, // gui screen lock
fn deinit(self: @This(), allocator: std.mem.Allocator) void { fn deinit(self: @This(), allocator: std.mem.Allocator) void {
if (self.ngui_path) |p| allocator.free(p); 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")) { if (std.mem.eql(u8, a, "-ngui")) {
lastarg = .ngui_path; lastarg = .ngui_path;
} else if (std.mem.eql(u8, a, "-slock")) {
flags.slock = true;
} else { } else {
fatal("unknown arg name {s}", .{a}); fatal("unknown arg name {s}", .{a});
} }
@ -74,9 +77,18 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags {
} }
/// global vars for comm read/write threads /// global vars for comm read/write threads
var mu: std.Thread.Mutex = .{}; var state: struct {
var nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}; mu: std.Thread.Mutex = .{},
var settings_sent = false; 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 { 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}); 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}); comm.write(gpa, w, .{ .lightning_ctrlconn = conn }) catch |err| logger.err("{!}", .{err});
}, },
.set_nodename => |s| { .set_nodename => |s| {
mu.lock(); state.mu.lock();
defer mu.unlock(); defer state.mu.unlock();
nodename.set(s); state.nodename.set(s);
settings_sent = false; 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 => {}, else => {},
} }
@ -169,18 +210,19 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void {
} }
sectimer.reset(); sectimer.reset();
mu.lock(); state.mu.lock();
defer mu.unlock(); defer state.mu.unlock();
if (!settings_sent) { if (!state.settings_sent) {
settings_sent = true; state.settings_sent = true;
const sett: comm.Message.Settings = .{ const sett: comm.Message.Settings = .{
.hostname = nodename.val(), .slock_enabled = state.slock_pincode != null,
.hostname = state.nodename.val(),
.sysupdates = .{ .channel = .edge }, .sysupdates = .{ .channel = .edge },
}; };
comm.write(gpa, w, .{ .settings = sett }) catch |err| { comm.write(gpa, w, .{ .settings = sett }) catch |err| {
logger.err("{}", .{err}); logger.err("{}", .{err});
settings_sent = false; state.settings_sent = false;
}; };
} }
@ -306,9 +348,17 @@ pub fn main() !void {
const flags = try parseArgs(gpa); const flags = try parseArgs(gpa);
defer flags.deinit(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.stdin_behavior = .Pipe;
ngui_proc.stdout_behavior = .Pipe; ngui_proc.stdout_behavior = .Pipe;
ngui_proc.stderr_behavior = .Inherit; ngui_proc.stderr_behavior = .Inherit;

@ -10,6 +10,13 @@
#include <stdlib.h> #include <stdlib.h>
#include <unistd.h> #include <unistd.h>
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. * 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); 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. * 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); 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. * returns user-managed data previously set on an object with nm_obj_set_userdata.
* the returned value may be NULL. * the returned value may be NULL.
@ -254,6 +259,7 @@ static int create_settings_panel(lv_obj_t *parent)
********************/ ********************/
// ported to zig; // ported to zig;
lv_obj_t *nodename_panel = nm_create_settings_nodename(parent); 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); 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[] = {/**/ static lv_coord_t parent_grid_rows[] = {/**/
LV_GRID_CONTENT, /* wifi panel */ LV_GRID_CONTENT, /* wifi panel */
LV_GRID_CONTENT, /* nodename panel */ LV_GRID_CONTENT, /* nodename panel */
LV_GRID_CONTENT, /* screenlock panel */
LV_GRID_CONTENT, /* power panel */ LV_GRID_CONTENT, /* power panel */
LV_GRID_CONTENT, /* sysupdates panel */ LV_GRID_CONTENT, /* sysupdates panel */
LV_GRID_TEMPLATE_LAST}; LV_GRID_TEMPLATE_LAST};
lv_obj_set_grid_dsc_array(parent, parent_grid_cols, parent_grid_rows); 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(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(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(screenlock_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(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_cols[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
static lv_coord_t wifi_grid_rows[] = {/**/ 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 */ /* default theme is static */
lv_theme_t *theme = lv_theme_default_init(disp, /**/ 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_init(&style_btn_red);
lv_style_set_bg_color(&style_btn_red, lv_palette_main(LV_PALETTE_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 */ /* global virtual keyboard */
virt_keyboard = lv_keyboard_create(lv_scr_act()); virt_keyboard = lv_keyboard_create(scr);
if (virt_keyboard == NULL) { if (virt_keyboard == NULL) {
/* TODO: or continue without keyboard? */
return -1; return -1;
} }
lv_obj_set_style_max_height(virt_keyboard, NM_DISP_HOR * 2 / 3, 0); lv_obj_set_style_max_height(virt_keyboard, NM_DISP_HOR * 2 / 3, 0);
lv_obj_add_flag(virt_keyboard, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(virt_keyboard, LV_OBJ_FLAG_HIDDEN);
/* the paren of all main tabs */
const lv_coord_t tabh = 60; 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) { if (tabview == NULL) {
return -1; return -1;
} }

@ -427,6 +427,11 @@ pub const WidgetMethods = struct {
lv_obj_align(self.lvobj, @intFromEnum(a), xoffset, yoffset); 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. /// sets flex layout growth property; same meaning as in CSS flex.
pub fn flexGrow(self: anytype, val: u8) void { pub fn flexGrow(self: anytype, val: u8) void {
lv_obj_set_flex_grow(self.lvobj, val); lv_obj_set_flex_grow(self.lvobj, val);
@ -495,7 +500,7 @@ pub const Screen = struct {
/// makes a screen active. /// makes a screen active.
pub fn load(scr: Screen) void { 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. /// 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; 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 if (text) |s| {
lv_label_set_text(lv_label, text); lv_label_set_text(lv_label, s);
}
//lv_obj_set_height(lb, sizeContent); // default //lv_obj_set_height(lb, sizeContent); // default
if (opt.long_mode) |m| { if (opt.long_mode) |m| {
lv_label_set_long_mode(lv_label, @intFromEnum(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. /// sets label text to a new value.
/// previous value is dealloc'ed. /// previous value is dealloc'ed.
pub fn setText(self: Label, text: [*:0]const u8) void { pub fn setText(self: Label, text: [:0]const u8) void {
lv_label_set_text(self.lvobj, text); lv_label_set_text(self.lvobj, text.ptr);
} }
/// sets label text without heap alloc but assumes text outlives the label obj. /// 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. /// represents lv_obj_t type in C.
pub const LvObj = opaque { pub const LvObj = opaque {
/// feature-flags controlling object's behavior. /// 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_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(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_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_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; 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_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_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;

@ -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 => {},
}
}

@ -16,9 +16,13 @@ const logger = std.log.scoped(.ui);
/// label color mark start to make "label:" part of a "label: value" /// label color mark start to make "label:" part of a "label: value"
/// in a different color. /// in a different color.
const cmark = "#bbbbbb "; const cmark = "#bbbbbb ";
/// buttons text /// button labels and other text
const textSwitch = "SWITCH"; const textSwitch = "SWITCH";
const textChange = "CHANGE"; 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. // global allocator set in init.
// must be set before any call into pub funcs in this module. // must be set before any call into pub funcs in this module.
@ -32,6 +36,33 @@ var tab: struct {
textarea: lvgl.TextArea, textarea: lvgl.TextArea,
changebtn: lvgl.TextButton, 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 { sysupdates: struct {
card: lvgl.Card, card: lvgl.Card,
chansel: lvgl.Dropdown, chansel: lvgl.Dropdown,
@ -42,8 +73,12 @@ var tab: struct {
/// holds last values received from the daemon. /// holds last values received from the daemon.
var state: struct { var state: struct {
// node name
nodename_change_inprogress: bool = false, nodename_change_inprogress: bool = false,
curr_nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}, 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, curr_sysupdates_chan: ?comm.Message.SysupdatesChan = null,
} = .{}; } = .{};
@ -94,6 +129,38 @@ pub fn initNodenamePanel(cont: lvgl.Container) !lvgl.Card {
return tab.nodename.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. /// creates a settings panel UI to control system updates channel.
/// must be called only once at program startup. /// must be called only once at program startup.
pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card { pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card {
@ -171,6 +238,20 @@ pub fn update(sett: comm.Message.Settings) !void {
tab.nodename.changebtn.disable(); 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 { 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); 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 { export fn nm_sysupdates_chansel_changed(_: *lvgl.LvEvent) void {
var buf = [_]u8{0} ** 32; var buf = [_]u8{0} ** 32;
const name = tab.sysupdates.chansel.getSelectedStr(&buf); const name = tab.sysupdates.chansel.getSelectedStr(&buf);

@ -1,5 +1,6 @@
///! see lv_symbols_def.h ///! see lv_symbols_def.h
pub const Edit = &[_]u8{ 0xef, 0x8c, 0x84 }; pub const Edit = &[_]u8{ 0xef, 0x8c, 0x84 };
pub const EyeClose = &[_]u8{ 0xef, 0x81, 0xb0 };
pub const LightningBolt = &[_]u8{ 0xef, 0x83, 0xa7 }; pub const LightningBolt = &[_]u8{ 0xef, 0x83, 0xa7 };
pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 }; pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 };
pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c }; pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c };

@ -10,21 +10,28 @@ const widget = @import("widget.zig");
pub const bitcoin = @import("bitcoin.zig"); pub const bitcoin = @import("bitcoin.zig");
pub const lightning = @import("lightning.zig"); pub const lightning = @import("lightning.zig");
pub const poweroff = @import("poweroff.zig"); pub const poweroff = @import("poweroff.zig");
pub const screenlock = @import("screenlock.zig");
pub const settings = @import("settings.zig"); pub const settings = @import("settings.zig");
const logger = std.log.scoped(.ui); const logger = std.log.scoped(.ui);
// defined in src/ui/c/ui.c // 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. // 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. // global allocator set on init.
// must be set before a call to nm_ui_init. // must be set before a call to nm_ui_init.
var allocator: std.mem.Allocator = undefined; var allocator: std.mem.Allocator = undefined;
pub fn init(gpa: std.mem.Allocator) !void { pub const InitOpt = struct {
allocator = gpa; allocator: std.mem.Allocator,
settings.allocator = gpa; 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(); lvgl.init();
const disp = try drv.initDisplay(); const disp = try drv.initDisplay();
drv.initInput() catch |err| { drv.initInput() catch |err| {
@ -33,8 +40,16 @@ pub fn init(gpa: std.mem.Allocator) !void {
// otherwise, impossible to wake up the screen. */ // otherwise, impossible to wake up the screen. */
return err; 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; 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 { export fn nm_create_settings_sysupdates(parent: *lvgl.LvObj) ?*lvgl.LvObj {
const card = settings.initSysupdatesPanel(lvgl.Container{ .lvobj = parent }) catch |err| { const card = settings.initSysupdatesPanel(lvgl.Container{ .lvobj = parent }) catch |err| {
logger.err("initSysupdatesPanel: {any}", .{err}); logger.err("initSysupdatesPanel: {any}", .{err});