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 2 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,
// 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;

@ -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.?,

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

@ -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,
},
};
}

@ -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, .{});

@ -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;

@ -10,6 +10,13 @@
#include <stdlib.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.
*/
@ -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;
}

@ -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 {