mirror of
https://github.com/NixOS/mobile-nixos.git
synced 2024-12-17 13:10:29 +03:00
Merge pull request #234 from samueldr-wip/feature/stage-1-passphrase
stage-1: Add interactive LUKS decrypting
This commit is contained in:
commit
63d49f51ad
@ -75,6 +75,10 @@ module Mounting
|
|||||||
[mount_point, task]
|
[mount_point, task]
|
||||||
end.to_h
|
end.to_h
|
||||||
auto_depend_mount_points(mount_points)
|
auto_depend_mount_points(mount_points)
|
||||||
|
|
||||||
|
(Configuration["luksDevices"] or []).each do |mapper, info|
|
||||||
|
Tasks::Luks.new(info["device"], mapper)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1,44 +1,120 @@
|
|||||||
# Progress-reporting plumbing
|
# Progress-reporting plumbing
|
||||||
module Progress
|
module Progress
|
||||||
SOCKET = "/run/mobile-nixos-init.socket"
|
SOCKET_PREFIX = "/run/mobile-nixos-init"
|
||||||
|
|
||||||
|
# Starts the queue sockets.
|
||||||
|
# This is waiting for /run/ to be available.
|
||||||
|
# It needs to be possible to let some consumers (e.g. splash) alive from
|
||||||
|
# stage-1 and waiting for fresh messages from stage-2.
|
||||||
|
# A stage-2 process could ask that splash to "hand-off" to a stage-2 splash.
|
||||||
def self.start()
|
def self.start()
|
||||||
@progress = 0
|
@progress = 0
|
||||||
$logger.debug("Starting progress IPC through ZeroMQ")
|
$logger.debug("Starting progress IPC through ZeroMQ")
|
||||||
$logger.debug(" -> #{SOCKET}")
|
|
||||||
@pub = ZMQ::Pub.new("ipc://#{SOCKET}")
|
$logger.debug(" -> messages: #{SOCKET_PREFIX}")
|
||||||
|
@messages_socket = ZMQ::Pub.new("ipc://#{SOCKET_PREFIX}-messages")
|
||||||
|
|
||||||
|
$logger.debug(" -> replies: #{SOCKET_PREFIX}")
|
||||||
|
@replies_socket = ZMQ::Sub.new("ipc://#{SOCKET_PREFIX}-replies", "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prefer not sending messages directly, rather use the helpers.
|
# Given values (in a Hash), it will update the state with them, and send the
|
||||||
def self.publish(msg)
|
# updated state to the messages queue.
|
||||||
msg = msg.to_json
|
# +nil+ values are compacted out of the state.
|
||||||
if @pub
|
def self.update(values)
|
||||||
|
@state ||= {}
|
||||||
|
@state.merge!(values).compact!
|
||||||
|
send_state()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a specific value from the state.
|
||||||
|
# This should be done as little as possible.
|
||||||
|
def self.get(attr)
|
||||||
|
@state ||= {}
|
||||||
|
@state[attr]
|
||||||
|
end
|
||||||
|
|
||||||
|
# See +#get+
|
||||||
|
def self.[](name)
|
||||||
|
get(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send the current state over the messages socket.
|
||||||
|
def self.send_state()
|
||||||
|
msg = @state.to_json
|
||||||
|
if @messages_socket
|
||||||
$logger.debug("[send] #{msg}")
|
$logger.debug("[send] #{msg}")
|
||||||
@pub.send(msg)
|
@messages_socket.send(msg)
|
||||||
else
|
else
|
||||||
$logger.debug("[send] Couldn't send #{msg}")
|
$logger.debug("[send] Socket not open yet.")
|
||||||
|
$logger.debug("[send] Couldn't send: #{msg}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sets the progress to a specific amount
|
|
||||||
def self.set(amount)
|
|
||||||
@progress = amount
|
|
||||||
|
|
||||||
publish({
|
|
||||||
progress: @progress,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
# Executes the given block, showing the message beforehand, and removing the
|
# Executes the given block, showing the message beforehand, and removing the
|
||||||
# message once done.
|
# message once done.
|
||||||
def self.with_message(msg)
|
def self.exec_with_message(label)
|
||||||
publish({
|
previous = get(:label)
|
||||||
progress: @progress,
|
update({label: label})
|
||||||
label: msg,
|
ret = yield
|
||||||
})
|
update({label: previous})
|
||||||
yield
|
ret
|
||||||
publish({
|
end
|
||||||
progress: @progress,
|
|
||||||
|
def self.ask(placeholder, label: nil)
|
||||||
|
identifier = "0x#{Random.rand(0xFFFFF).to_s(16)}"
|
||||||
|
|
||||||
|
previous_label = get(:label)
|
||||||
|
Progress.update({label: label}) if label
|
||||||
|
|
||||||
|
update(command: {
|
||||||
|
name: "ask",
|
||||||
|
identifier: identifier,
|
||||||
|
placeholder: placeholder,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
value = loop do
|
||||||
|
# Keep progress state updated for processes attaching late.
|
||||||
|
send_state()
|
||||||
|
value =
|
||||||
|
each_replies do |reply|
|
||||||
|
# A reply for the current question?
|
||||||
|
if reply and reply["type"] == "reply" and reply["identifier"] == identifier
|
||||||
|
break reply["value"]
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
break value if value
|
||||||
|
|
||||||
|
# Leave some breathing room to the CPU!
|
||||||
|
sleep(0.1)
|
||||||
|
end
|
||||||
|
|
||||||
|
update({label: previous_label})
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
# Read one reply
|
||||||
|
# If none are available, returns nil
|
||||||
|
def self.read_reply()
|
||||||
|
begin
|
||||||
|
msg = @replies_socket.recv(LibZMQ::DONTWAIT).to_str
|
||||||
|
$logger.debug("[recv] #{msg}")
|
||||||
|
JSON.parse(msg)
|
||||||
|
rescue Errno::EWOULDBLOCK
|
||||||
|
# No message?
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads replies until there are none
|
||||||
|
def self.each_replies()
|
||||||
|
loop do
|
||||||
|
msg = read_reply
|
||||||
|
break unless msg
|
||||||
|
yield msg
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -51,6 +51,30 @@ module System
|
|||||||
Kernel.spawn(*args)
|
Kernel.spawn(*args)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Runs a long-running task in the background while we keep the progress
|
||||||
|
# reporting active.
|
||||||
|
def self.run_long_running(*args)
|
||||||
|
pretty_command = prettify_command(*args)
|
||||||
|
pid = System.spawn(*args)
|
||||||
|
ret = nil
|
||||||
|
|
||||||
|
loop do
|
||||||
|
# Update progress
|
||||||
|
Progress.send_state()
|
||||||
|
# Look at the status
|
||||||
|
break if ret = Process.wait(pid, Process::WNOHANG)
|
||||||
|
# Don't loop too tightly
|
||||||
|
sleep(0.1)
|
||||||
|
end
|
||||||
|
|
||||||
|
status = $?.exitstatus
|
||||||
|
if status == 127
|
||||||
|
raise CommandNotFound.new("Command not found... `#{pretty_command}` (#{status})")
|
||||||
|
elsif !$?.success?
|
||||||
|
raise CommandError.new("Command failed... `#{pretty_command}` (#{status})")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Discovers the location of given program name.
|
# Discovers the location of given program name.
|
||||||
def self.which(program_name)
|
def self.which(program_name)
|
||||||
ENV["PATH"].split(":").each do |path|
|
ENV["PATH"].split(":").each do |path|
|
||||||
|
@ -43,7 +43,7 @@ module Tasks
|
|||||||
|
|
||||||
# Update the current progress
|
# Update the current progress
|
||||||
count = @tasks.length.to_f
|
count = @tasks.length.to_f
|
||||||
Progress.set((100 * (1 - (todo.length / count))).ceil)
|
Progress.update({progress: (100 * (1 - (todo.length / count))).ceil})
|
||||||
|
|
||||||
todo.each do |task|
|
todo.each do |task|
|
||||||
if task._try_run_task then
|
if task._try_run_task then
|
||||||
|
@ -11,7 +11,7 @@ class Tasks::AutoResize < Task
|
|||||||
def run()
|
def run()
|
||||||
log("Resizing #{@device}...")
|
log("Resizing #{@device}...")
|
||||||
if @type.match(/^ext[234]$/)
|
if @type.match(/^ext[234]$/)
|
||||||
Progress.with_message("Verifying #{@device}...") do
|
Progress.exec_with_message("Verifying #{@device}...") do
|
||||||
# TODO: Understand the actual underlying issue with e2fsck.
|
# TODO: Understand the actual underlying issue with e2fsck.
|
||||||
# It seems `e2fsck` succeeds, according to the output, but has a >0 exit
|
# It seems `e2fsck` succeeds, according to the output, but has a >0 exit
|
||||||
# status. Running it again in those situations is a no-op, which is weird
|
# status. Running it again in those situations is a no-op, which is weird
|
||||||
@ -19,14 +19,14 @@ class Tasks::AutoResize < Task
|
|||||||
# This is why we unconditionally run it once, then twice.
|
# This is why we unconditionally run it once, then twice.
|
||||||
# The second will hopefully abort the boot if it fails too.
|
# The second will hopefully abort the boot if it fails too.
|
||||||
begin
|
begin
|
||||||
System.run("e2fsck", "-fp", @device)
|
System.run_long_running("e2fsck", "-fp", @device)
|
||||||
rescue System::CommandError
|
rescue System::CommandError
|
||||||
$logger.info("Re-running e2fsc...")
|
$logger.info("Re-running e2fsc...")
|
||||||
System.run("e2fsck", "-fp", @device)
|
System.run_long_running("e2fsck", "-fp", @device)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Progress.with_message("Resizing #{@device}...") do
|
Progress.exec_with_message("Resizing #{@device}...") do
|
||||||
System.run("resize2fs", "-f", @device)
|
System.run_long_running("resize2fs", "-f", @device)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
$logger.warn("Cannot resize #{@type}... filesystem left untouched.")
|
$logger.warn("Cannot resize #{@type}... filesystem left untouched.")
|
||||||
|
64
boot/init/tasks/luks.rb
Normal file
64
boot/init/tasks/luks.rb
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Opens LUKS devices
|
||||||
|
class Tasks::Luks < Task
|
||||||
|
attr_reader :source
|
||||||
|
attr_reader :mapper
|
||||||
|
|
||||||
|
TRIES = 10
|
||||||
|
|
||||||
|
class ExistingLuksTask < StandardError
|
||||||
|
end
|
||||||
|
|
||||||
|
class CouldNotUnlock < StandardError
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.register(mapper, instance)
|
||||||
|
@registry ||= {}
|
||||||
|
unless @registry[mapper].nil? then
|
||||||
|
raise ExistingLuksTask.new("LUKS task for '#{mapper}' already exists.")
|
||||||
|
end
|
||||||
|
@registry[mapper] = instance
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.registry()
|
||||||
|
@registry
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(source, mapper)
|
||||||
|
@source = source
|
||||||
|
@mapper = mapper
|
||||||
|
|
||||||
|
add_dependency(:Task, Tasks::UDev.instance)
|
||||||
|
add_dependency(:Files, source)
|
||||||
|
add_dependency(:Mount, "/run")
|
||||||
|
add_dependency(:Target, :Environment)
|
||||||
|
self.class.register(@mapper, self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def run()
|
||||||
|
FileUtils.mkdir_p("/run/cryptsetup")
|
||||||
|
|
||||||
|
TRIES.times do
|
||||||
|
passphrase = Progress.ask("Passphrase for #{mapper}")
|
||||||
|
|
||||||
|
begin
|
||||||
|
Progress.exec_with_message("Checking...") do
|
||||||
|
# TODO: implement with process redirection rather than shelling out
|
||||||
|
System.run("echo #{passphrase.shellescape} | exec cryptsetup luksOpen #{source.shellescape} #{mapper.shellescape}")
|
||||||
|
end
|
||||||
|
Progress.update({label: nil})
|
||||||
|
|
||||||
|
# If we're there, we're done!
|
||||||
|
return
|
||||||
|
rescue System::CommandError
|
||||||
|
Progress.update({label: "Wrong passphrase given..."})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# We failed multiple times.
|
||||||
|
raise CouldNotUnlock.new("Could not unlock #{source}; tried #{TRIES} times.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def name()
|
||||||
|
"#{super}(#{source}, #{mapper})"
|
||||||
|
end
|
||||||
|
end
|
@ -24,14 +24,17 @@ class Tasks::Splash < SingletonTask
|
|||||||
|
|
||||||
# Implementation details-y; ask for the splash applet to be exited.
|
# Implementation details-y; ask for the splash applet to be exited.
|
||||||
def quit(reason)
|
def quit(reason)
|
||||||
|
# Ensures the progress is shown
|
||||||
|
Progress.update({progress: 100, label: reason})
|
||||||
|
|
||||||
|
# Command it to quit
|
||||||
|
Progress.update({command: {name: "quit"}})
|
||||||
|
|
||||||
# Ensures that if for any reason the splash didn't start in time for the
|
# Ensures that if for any reason the splash didn't start in time for the
|
||||||
# socket to listen to this message, that we'll be quitting it.
|
# socket to listen to this message, that we'll be quitting it.
|
||||||
loop do
|
loop do
|
||||||
# Ensures the progress is shown
|
# Repeatedly send the current state (which has the quit command).
|
||||||
Progress.publish({progress: 100, label: reason})
|
Progress.send_state()
|
||||||
# Command it to quit
|
|
||||||
Progress.publish("quit")
|
|
||||||
|
|
||||||
# If it has quit, break out!
|
# If it has quit, break out!
|
||||||
break if Process.wait(@pid, Process::WNOHANG)
|
break if Process.wait(@pid, Process::WNOHANG)
|
||||||
|
|
||||||
|
61
boot/splash/lib/keyboard.rb
Normal file
61
boot/splash/lib/keyboard.rb
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Wraps a raw +lv_keyboard+ in minimal helpers
|
||||||
|
# This is intended to be used as a singleton instance, where you can
|
||||||
|
# "re-parent" the keyboard as needed.
|
||||||
|
class Keyboard < LVGUI::Widget
|
||||||
|
include Singleton
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def initialize()
|
||||||
|
@shown = false
|
||||||
|
# Attach the keyboard to the current active screen, by default.
|
||||||
|
super(LVGL::LVKeyboard.new(LVGL::LVDisplay.get_scr_act()))
|
||||||
|
set_cursor_manage(true)
|
||||||
|
|
||||||
|
get_style(LVGL::KB_STYLE::BG).dup.tap do |style|
|
||||||
|
set_style(LVGL::KB_STYLE::BG, style)
|
||||||
|
padding = 4
|
||||||
|
style.body_padding_top = padding
|
||||||
|
style.body_padding_left = padding
|
||||||
|
style.body_padding_right = padding
|
||||||
|
style.body_padding_bottom = padding
|
||||||
|
style.body_padding_inner = padding
|
||||||
|
end
|
||||||
|
set_y(get_parent.get_height())
|
||||||
|
end
|
||||||
|
|
||||||
|
public
|
||||||
|
|
||||||
|
def set_height(value)
|
||||||
|
super(value)
|
||||||
|
_set_position()
|
||||||
|
end
|
||||||
|
|
||||||
|
def show()
|
||||||
|
_animate_y(get_parent.get_height() - get_height())
|
||||||
|
end
|
||||||
|
|
||||||
|
def hide()
|
||||||
|
_animate_y(get_parent.get_height)
|
||||||
|
end
|
||||||
|
|
||||||
|
def _set_position()
|
||||||
|
if @shown
|
||||||
|
_animate_y(get_parent.get_height() - get_height())
|
||||||
|
else
|
||||||
|
_animate_y(get_parent.get_height())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def _animate_y(ending)
|
||||||
|
LVGL::LVAnim.new().tap do |anim|
|
||||||
|
anim.set_exec_cb(self, :lv_obj_set_y)
|
||||||
|
anim.set_time(300, 0)
|
||||||
|
anim.set_values(get_y(), ending)
|
||||||
|
anim.set_path_cb(LVGL::LVAnim::Path::EASE_OUT)
|
||||||
|
|
||||||
|
# Launch the animation
|
||||||
|
anim.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -60,6 +60,7 @@ class ProgressBar < LVGUI::Widget
|
|||||||
end
|
end
|
||||||
|
|
||||||
def progress=(val)
|
def progress=(val)
|
||||||
|
val = 100 if val > 100
|
||||||
@changed = true
|
@changed = true
|
||||||
@progress_amount = val
|
@progress_amount = val
|
||||||
refresh_progress()
|
refresh_progress()
|
||||||
|
90
boot/splash/lib/text_area.rb
Normal file
90
boot/splash/lib/text_area.rb
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# Wraps a raw +lv_ta+ in minimal helpers
|
||||||
|
class TextArea < LVGUI::Widget
|
||||||
|
attr_reader :hidden
|
||||||
|
|
||||||
|
def initialize(parent)
|
||||||
|
super(LVGL::LVTextArea.new(parent))
|
||||||
|
|
||||||
|
@hidden = false
|
||||||
|
set_text("")
|
||||||
|
set_placeholder_text("")
|
||||||
|
set_pwd_mode(true)
|
||||||
|
set_one_line(true)
|
||||||
|
set_opa_scale_enable(true)
|
||||||
|
get_style(LVGL::TA_STYLE::BG).dup.tap do |style|
|
||||||
|
set_style(LVGL::TA_STYLE::BG, style)
|
||||||
|
style.body_main_color = 0xFF000000
|
||||||
|
style.body_grad_color = 0xFF000000
|
||||||
|
style.body_radius = 5
|
||||||
|
style.body_border_color = 0xFFFFFFFF
|
||||||
|
style.body_border_width = 3
|
||||||
|
style.body_border_opa = 255
|
||||||
|
style.text_color = 0xFFFFFFFF
|
||||||
|
end
|
||||||
|
get_style(LVGL::TA_STYLE::PLACEHOLDER).dup.tap do |style|
|
||||||
|
set_style(LVGL::TA_STYLE::PLACEHOLDER, style)
|
||||||
|
style.text_color = 0xFFAAAAAA
|
||||||
|
end
|
||||||
|
set_cursor_type(get_cursor_type() | LVGL::CURSOR::HIDDEN)
|
||||||
|
|
||||||
|
self.event_handler = -> (event) do
|
||||||
|
return if hidden()
|
||||||
|
case event
|
||||||
|
when LVGL::EVENT::CLICKED
|
||||||
|
Keyboard.instance.set_ta(self)
|
||||||
|
Keyboard.instance.show()
|
||||||
|
when LVGL::EVENT::INSERT
|
||||||
|
# Not exactly right, but right enough.
|
||||||
|
char = LVGL::FFI.lv_event_get_data().to_str(1)
|
||||||
|
# Assume there is only one input.
|
||||||
|
# Also assume Enter sends; that it is a single line.
|
||||||
|
if char == "\n"
|
||||||
|
Keyboard.instance.set_ta(nil)
|
||||||
|
Keyboard.instance.hide()
|
||||||
|
# Create a new string
|
||||||
|
# get_text() gives us a Fiddle::Pointer (leaky abstraction!!!)
|
||||||
|
value = "#{get_text()}"
|
||||||
|
@on_submit.call(value) if @on_submit
|
||||||
|
hide()
|
||||||
|
end
|
||||||
|
#else
|
||||||
|
# puts "Unhandled event for #{self}: #{LVGL::EVENT.from_value(event)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show()
|
||||||
|
@hidden = false
|
||||||
|
LVGL::LVAnim.new().tap do |anim|
|
||||||
|
anim.set_exec_cb(self, :lv_obj_set_opa_scale)
|
||||||
|
anim.set_time(FADE_LENGTH, 0)
|
||||||
|
anim.set_values(0, 255)
|
||||||
|
anim.set_path_cb(LVGL::LVAnim::Path::EASE_OUT)
|
||||||
|
|
||||||
|
# Launch the animation
|
||||||
|
anim.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def hide(skip_animation: false)
|
||||||
|
@hidden = true
|
||||||
|
if skip_animation
|
||||||
|
set_opa_scale(0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
LVGL::LVAnim.new().tap do |anim|
|
||||||
|
anim.set_exec_cb(self, :lv_obj_set_opa_scale)
|
||||||
|
anim.set_time(FADE_LENGTH, 0)
|
||||||
|
anim.set_values(255, 0)
|
||||||
|
anim.set_path_cb(LVGL::LVAnim::Path::EASE_IN)
|
||||||
|
|
||||||
|
# Launch the animation
|
||||||
|
anim.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_submit=(cb)
|
||||||
|
@on_submit = cb
|
||||||
|
end
|
||||||
|
end
|
@ -13,22 +13,28 @@ end
|
|||||||
class UI
|
class UI
|
||||||
attr_reader :screen
|
attr_reader :screen
|
||||||
attr_reader :progress_bar
|
attr_reader :progress_bar
|
||||||
|
attr_reader :ask_identifier
|
||||||
|
|
||||||
# As this is not using BaseWindow, LVGUI::init isn't handled for us.
|
# As this is not using BaseWindow, LVGUI::init isn't handled for us.
|
||||||
LVGUI.init()
|
LVGUI.init()
|
||||||
|
|
||||||
def initialize()
|
def initialize()
|
||||||
add_screen
|
add_screen
|
||||||
|
add_page
|
||||||
# Biggest of horizontal or vertical; a percent.
|
# Biggest of horizontal or vertical; a percent.
|
||||||
@unit = ([@screen.get_width, @screen.get_height].max * 0.01).ceil
|
@unit = ([@screen.get_width, @screen.get_height].max * 0.01).ceil
|
||||||
add_logo
|
add_logo
|
||||||
add_progress_bar
|
add_progress_bar
|
||||||
add_label
|
add_label
|
||||||
|
|
||||||
|
add_textarea
|
||||||
|
add_keyboard
|
||||||
|
|
||||||
add_cover # last
|
add_cover # last
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_label()
|
def add_label()
|
||||||
@label = LVGL::LVLabel.new(@screen)
|
@label = LVGL::LVLabel.new(@page)
|
||||||
@label.get_style(LVGL::LABEL_STYLE::MAIN).dup.tap do |style|
|
@label.get_style(LVGL::LABEL_STYLE::MAIN).dup.tap do |style|
|
||||||
@label.set_style(LVGL::LABEL_STYLE::MAIN, style)
|
@label.set_style(LVGL::LABEL_STYLE::MAIN, style)
|
||||||
style.text_color = 0xFFFFFFFF
|
style.text_color = 0xFFFFFFFF
|
||||||
@ -36,7 +42,7 @@ class UI
|
|||||||
@label.set_long_mode(LVGL::LABEL_LONG::BREAK)
|
@label.set_long_mode(LVGL::LABEL_LONG::BREAK)
|
||||||
@label.set_align(LVGL::LABEL_ALIGN::CENTER)
|
@label.set_align(LVGL::LABEL_ALIGN::CENTER)
|
||||||
|
|
||||||
@label.set_width(@screen.get_width * 0.9)
|
@label.set_width(@page.get_width * 0.9)
|
||||||
@label.set_pos(*center(@label, 0, 5*@unit))
|
@label.set_pos(*center(@label, 0, 5*@unit))
|
||||||
@label.set_text("")
|
@label.set_text("")
|
||||||
end
|
end
|
||||||
@ -48,15 +54,15 @@ class UI
|
|||||||
file = "./logo.svg" if File.exist?("./logo.svg")
|
file = "./logo.svg" if File.exist?("./logo.svg")
|
||||||
return unless file
|
return unless file
|
||||||
|
|
||||||
if @screen.get_height > @screen.get_width
|
if @page.get_height > @page.get_width
|
||||||
# 80% of the width
|
# 80% of the width
|
||||||
LVGL::Hacks::LVNanoSVG.resize_next_width((@screen.get_width * 0.8).to_i)
|
LVGL::Hacks::LVNanoSVG.resize_next_width((@page.get_width * 0.8).to_i)
|
||||||
else
|
else
|
||||||
# 15% of the height
|
# 15% of the height
|
||||||
LVGL::Hacks::LVNanoSVG.resize_next_height((@screen.get_height * 0.15).to_i)
|
LVGL::Hacks::LVNanoSVG.resize_next_height((@page.get_height * 0.15).to_i)
|
||||||
end
|
end
|
||||||
|
|
||||||
@logo = LVGL::LVImage.new(@screen)
|
@logo = LVGL::LVImage.new(@page)
|
||||||
@logo.set_src(file)
|
@logo.set_src(file)
|
||||||
|
|
||||||
# Position the logo
|
# Position the logo
|
||||||
@ -64,9 +70,9 @@ class UI
|
|||||||
end
|
end
|
||||||
|
|
||||||
def add_progress_bar()
|
def add_progress_bar()
|
||||||
@progress_bar = ProgressBar.new(@screen)
|
@progress_bar = ProgressBar.new(@page)
|
||||||
@progress_bar.set_height(3 * @unit)
|
@progress_bar.set_height(3 * @unit)
|
||||||
@progress_bar.set_width(@screen.get_width * 0.7)
|
@progress_bar.set_width(@page.get_width * 0.7)
|
||||||
@progress_bar.set_pos(*center(@progress_bar))
|
@progress_bar.set_pos(*center(@progress_bar))
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -81,14 +87,27 @@ class UI
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_page()
|
||||||
|
@page = LVGL::LVContainer.new(@screen)
|
||||||
|
@page.set_width(@screen.get_width)
|
||||||
|
@page.set_height(@screen.get_height)
|
||||||
|
@page.get_style(LVGL::CONT_STYLE::MAIN).dup.tap do |style|
|
||||||
|
@page.set_style(LVGL::CONT_STYLE::MAIN, style)
|
||||||
|
style.body_main_color = 0xFF000000
|
||||||
|
style.body_grad_color = 0xFF000000
|
||||||
|
style.body_border_width = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Used to handle fade-in/fade-out
|
# Used to handle fade-in/fade-out
|
||||||
# This is because opacity handles multiple overlaid objects wrong.
|
# This is because opacity handles multiple overlaid objects wrong.
|
||||||
def add_cover()
|
def add_cover()
|
||||||
@cover = LVGL::LVObject.new(@screen)
|
@cover = LVGL::LVObject.new(@screen)
|
||||||
# Make it so we can use the opacity to fade in/out
|
# Make it so we can use the opacity to fade in/out
|
||||||
@cover.set_opa_scale_enable(1)
|
@cover.set_opa_scale_enable(true)
|
||||||
@cover.set_width(@screen.get_width())
|
@cover.set_width(@screen.get_width())
|
||||||
@cover.set_height(@screen.get_height())
|
@cover.set_height(@screen.get_height())
|
||||||
|
@cover.set_click(false)
|
||||||
|
|
||||||
@cover.get_style().dup.tap do |style|
|
@cover.get_style().dup.tap do |style|
|
||||||
@cover.set_style(style)
|
@cover.set_style(style)
|
||||||
@ -101,6 +120,22 @@ class UI
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_textarea()
|
||||||
|
@ta = TextArea.new(@page)
|
||||||
|
@ta.set_width(@page.get_width * 0.9)
|
||||||
|
@ta.set_pos(*center(@ta, 0, @unit * 14))
|
||||||
|
# Always present, but initially hidden
|
||||||
|
@ta.hide(skip_animation: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_keyboard()
|
||||||
|
@keyboard = Keyboard.instance()
|
||||||
|
# The keyboard is not added to the page; the page holds the elements that
|
||||||
|
# may move to ensure they're not covered by the keyboard.
|
||||||
|
@keyboard.set_parent(@screen)
|
||||||
|
@keyboard.set_height(@screen.get_width * 0.55)
|
||||||
|
end
|
||||||
|
|
||||||
def set_progress(amount)
|
def set_progress(amount)
|
||||||
progress_bar.progress = amount
|
progress_bar.progress = amount
|
||||||
end
|
end
|
||||||
@ -109,6 +144,39 @@ class UI
|
|||||||
@label.set_text(text)
|
@label.set_text(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# +cb+ is a proc that wille be +#call+'d with the text once submitted.
|
||||||
|
def ask_user(placeholder: "", identifier: , cb:)
|
||||||
|
return if identifier == @ask_identifier
|
||||||
|
|
||||||
|
@ask_identifier = identifier
|
||||||
|
@ta.set_placeholder_text(placeholder)
|
||||||
|
@ta.show()
|
||||||
|
@keyboard.set_ta(@ta)
|
||||||
|
@keyboard.show()
|
||||||
|
|
||||||
|
bottom_space = @screen.get_height() - (@ta.get_y() + @ta.get_height())
|
||||||
|
delta = bottom_space - @keyboard.get_height() - 3*@unit
|
||||||
|
offset_page(delta) if delta < 0
|
||||||
|
|
||||||
|
@ta.on_submit = ->(value) do
|
||||||
|
@ta.set_text("")
|
||||||
|
offset_page(0)
|
||||||
|
cb.call(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def offset_page(delta)
|
||||||
|
LVGL::LVAnim.new().tap do |anim|
|
||||||
|
anim.set_exec_cb(@page, :lv_obj_set_y)
|
||||||
|
anim.set_time(300, 0)
|
||||||
|
anim.set_values(@page.get_y(), delta)
|
||||||
|
anim.set_path_cb(LVGL::LVAnim::Path::EASE_OUT)
|
||||||
|
|
||||||
|
# Launch the animation
|
||||||
|
anim.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Fade-in animation
|
# Fade-in animation
|
||||||
# Note that this looks like inverted logic because it is!
|
# Note that this looks like inverted logic because it is!
|
||||||
# We're actually fading-out the cover!
|
# We're actually fading-out the cover!
|
||||||
|
@ -9,14 +9,17 @@ FADE_LENGTH = 400
|
|||||||
PROGRESS_UPDATE_LENGTH = 500
|
PROGRESS_UPDATE_LENGTH = 500
|
||||||
|
|
||||||
VERBOSE = !!Args.get(:verbose, false)
|
VERBOSE = !!Args.get(:verbose, false)
|
||||||
SOCKET = File.expand_path(Args.get(:socket, "/run/mobile-nixos-init.socket"))
|
SOCKET = File.expand_path(Args.get(:socket, "/run/mobile-nixos-init"))
|
||||||
|
|
||||||
# Create the UI
|
# Create the UI
|
||||||
ui = UI.new
|
ui = UI.new
|
||||||
|
|
||||||
# Socket for status updates
|
# Socket for status updates
|
||||||
puts "[splash] Listening on: ipc://#{SOCKET}"
|
puts "[splash] Listening on: ipc://#{SOCKET}-messages"
|
||||||
$sub = ZMQ::Sub.new("ipc://#{SOCKET}", "")
|
$messages = ZMQ::Sub.new("ipc://#{SOCKET}-messages", "")
|
||||||
|
|
||||||
|
puts "[splash] Replying on: ipc://#{SOCKET}-replies"
|
||||||
|
$replies = ZMQ::Pub.new("ipc://#{SOCKET}-replies")
|
||||||
|
|
||||||
# Initial fade-in
|
# Initial fade-in
|
||||||
ui.fade_in()
|
ui.fade_in()
|
||||||
@ -28,7 +31,7 @@ LVGUI.main_loop do
|
|||||||
# Empty all messages from the queue before continuing.
|
# Empty all messages from the queue before continuing.
|
||||||
loop do
|
loop do
|
||||||
begin
|
begin
|
||||||
msg = JSON.parse($sub.recv(LibZMQ::DONTWAIT).to_str)
|
msg = JSON.parse($messages.recv(LibZMQ::DONTWAIT).to_str)
|
||||||
rescue Errno::EWOULDBLOCK
|
rescue Errno::EWOULDBLOCK
|
||||||
# No messages left? break out!
|
# No messages left? break out!
|
||||||
break
|
break
|
||||||
@ -39,14 +42,33 @@ LVGUI.main_loop do
|
|||||||
p msg
|
p msg
|
||||||
end
|
end
|
||||||
|
|
||||||
# We might have a special command, if we got a String rather than a Hash.
|
# We might have a special command; handle it.
|
||||||
if msg.is_a? String then
|
if msg["command"] then
|
||||||
if msg == "quit"
|
command = msg["command"]
|
||||||
|
|
||||||
|
case command["name"]
|
||||||
|
when "quit"
|
||||||
ui.quit!
|
ui.quit!
|
||||||
else
|
when "ask"
|
||||||
$stderr.puts "[splash] Unexpected command #{msg}..."
|
ui.ask_user(placeholder: command["placeholder"], identifier: command["identifier"], cb: ->(value) do
|
||||||
|
msg = {
|
||||||
|
type: "reply",
|
||||||
|
identifier: command["identifier"],
|
||||||
|
value: value,
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
if VERBOSE
|
||||||
|
print "[splash:send] "
|
||||||
|
p msg
|
||||||
end
|
end
|
||||||
|
|
||||||
|
$replies.send(msg)
|
||||||
|
end)
|
||||||
else
|
else
|
||||||
|
$stderr.puts "[splash] Unexpected command #{command.to_json}..."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Update the UI...
|
# Update the UI...
|
||||||
|
|
||||||
# First updating the current progress
|
# First updating the current progress
|
||||||
@ -60,7 +82,6 @@ LVGUI.main_loop do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
$stderr.puts "[splash] Broke out of the rendering loop. That's not supposed to happen."
|
$stderr.puts "[splash] Broke out of the rendering loop. That's not supposed to happen."
|
||||||
exit(1)
|
exit(1)
|
||||||
|
13
examples/testing/README.md
Normal file
13
examples/testing/README.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Testing examples
|
||||||
|
================
|
||||||
|
|
||||||
|
These examples are made specifically to test features.
|
||||||
|
|
||||||
|
They are not to be used as a normal system, as they likely have some fatal
|
||||||
|
flaws, either usability-wise (e.g. not usable at all) or security-wise (e.g.
|
||||||
|
default passwords).
|
||||||
|
|
||||||
|
More details are available inside each testing systems directories.
|
||||||
|
|
||||||
|
Though, please refer to those systems as reference implementation for
|
||||||
|
validating changes around their features under test!
|
45
examples/testing/qemu-cryptsetup/README.md
Normal file
45
examples/testing/qemu-cryptsetup/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
`qemu-cryptsetup`
|
||||||
|
=================
|
||||||
|
|
||||||
|
What does this test?
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Using the `hello` system, pre-configured to use the `qemu` system type.
|
||||||
|
|
||||||
|
This tests:
|
||||||
|
|
||||||
|
- Encryption passphrase at boot
|
||||||
|
|
||||||
|
**This test is manual.**
|
||||||
|
|
||||||
|
The passphrase in use is:
|
||||||
|
|
||||||
|
```
|
||||||
|
1234
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Why is this scary?
|
||||||
|
------------------
|
||||||
|
|
||||||
|
- Secrets in the store!
|
||||||
|
- Well-known insecure passphrase
|
||||||
|
|
||||||
|
|
||||||
|
How is success defined?
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
The `hello` system applet is booted-to after the user supplies the encryption
|
||||||
|
passphrase during boot.
|
||||||
|
|
||||||
|
|
||||||
|
How is success defined?
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Assuming you are `cd`'d into the root of a Mobile NixOS checkout:
|
||||||
|
|
||||||
|
```
|
||||||
|
nix-build ./examples/testing/qemu-cryptsetup && ./result
|
||||||
|
```
|
||||||
|
|
||||||
|
As always, be mindful of your `NIX_PATH`.
|
68
examples/testing/qemu-cryptsetup/configuration.nix
Normal file
68
examples/testing/qemu-cryptsetup/configuration.nix
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# This is not a secure or safe way to create an encrypted drive in a build.
|
||||||
|
# This is SOLELY for testing purposes.
|
||||||
|
passphrase = "1234";
|
||||||
|
uuid = "12345678-1234-1234-1234-123456789abc"; # heh
|
||||||
|
|
||||||
|
# We are re-using the raw filesystem from the hello system.
|
||||||
|
rootfsExt4 = (
|
||||||
|
import ../../hello { device = config.mobile.device.name; }
|
||||||
|
).build.rootfs;
|
||||||
|
|
||||||
|
# This is not a facility from the disk images builder because **it is really
|
||||||
|
# insecure to use**.
|
||||||
|
# So, for now, we have an implementation details-y way of producing an
|
||||||
|
# encrypted rootfs.
|
||||||
|
encryptedRootfs = pkgs.vmTools.runInLinuxVM (
|
||||||
|
pkgs.runCommand "encrypted-rootfs" {
|
||||||
|
buildInputs = [ pkgs.cryptsetup ];
|
||||||
|
} ''
|
||||||
|
(PS4=" $ "; set -x
|
||||||
|
mkdir -p /run/cryptsetup
|
||||||
|
mkdir -p $out
|
||||||
|
cd $out
|
||||||
|
|
||||||
|
slack=32 # MiB
|
||||||
|
|
||||||
|
# Some slack space we'll append to the raw fs
|
||||||
|
# Used by `--reduce-device-size` read cryptsetup(8).
|
||||||
|
dd if=/dev/zero of=tmp.img bs=1024 count=$((slack*1024))
|
||||||
|
|
||||||
|
# Catting both to ensure it's writable, and to add some slack space at
|
||||||
|
# the end
|
||||||
|
cat ${rootfsExt4}/${rootfsExt4.label}.img tmp.img > encrypted.img
|
||||||
|
rm tmp.img
|
||||||
|
|
||||||
|
echo ${builtins.toJSON passphrase} | cryptsetup \
|
||||||
|
reencrypt \
|
||||||
|
--encrypt ./encrypted.img \
|
||||||
|
--reduce-device-size $((slack*1024*1024))
|
||||||
|
|
||||||
|
#echo YES |
|
||||||
|
cryptsetup luksUUID --uuid=${builtins.toJSON uuid} ./encrypted.img
|
||||||
|
)
|
||||||
|
''
|
||||||
|
);
|
||||||
|
in
|
||||||
|
|
||||||
|
{
|
||||||
|
boot.initrd.luks.devices = {
|
||||||
|
LUKS-MOBILE-ROOTFS = {
|
||||||
|
device = "/dev/disk/by-uuid/${uuid}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems = {
|
||||||
|
"/" = {
|
||||||
|
device = "/dev/mapper/LUKS-MOBILE-ROOTFS";
|
||||||
|
fsType = "ext4";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Instead of the (mkDefault) rootfs, provide our raw encrypted rootfs.
|
||||||
|
mobile.generatedFilesystems.rootfs = {
|
||||||
|
raw = encryptedRootfs;
|
||||||
|
};
|
||||||
|
}
|
11
examples/testing/qemu-cryptsetup/default.nix
Normal file
11
examples/testing/qemu-cryptsetup/default.nix
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
let
|
||||||
|
device = "qemu-x86_64";
|
||||||
|
system-build = import ../../../. {
|
||||||
|
inherit device;
|
||||||
|
configuration = [ { imports = [
|
||||||
|
../../hello/configuration.nix
|
||||||
|
./configuration.nix
|
||||||
|
]; } ];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
system-build.build.default
|
@ -62,6 +62,14 @@ let
|
|||||||
limitations in CI situations.
|
limitations in CI situations.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
raw = lib.mkOption {
|
||||||
|
internal = true;
|
||||||
|
type = types.nullOr types.package;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Use an output directly rather than creating it from the options.
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
config = {
|
config = {
|
||||||
};
|
};
|
||||||
@ -79,7 +87,8 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
system.build.generatedFilesystems = lib.attrsets.mapAttrs (name: {type, id, label, ...} @ attrs:
|
system.build.generatedFilesystems = lib.attrsets.mapAttrs (name: {raw, type, id, label, ...} @ attrs:
|
||||||
|
if raw != null then raw else
|
||||||
filesystemFunctions."${type}" (attrs // {
|
filesystemFunctions."${type}" (attrs // {
|
||||||
name = label;
|
name = label;
|
||||||
partitionID = id;
|
partitionID = id;
|
||||||
|
27
modules/luks.nix
Normal file
27
modules/luks.nix
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
inherit (config.boot.initrd) luks;
|
||||||
|
in
|
||||||
|
|
||||||
|
lib.mkIf (luks.devices != {} || luks.forceLuksSupportInInitrd) {
|
||||||
|
mobile.boot.stage-1 = {
|
||||||
|
bootConfig = {
|
||||||
|
luksDevices = luks.devices;
|
||||||
|
};
|
||||||
|
kernel = {
|
||||||
|
modules = [
|
||||||
|
"dm_mod" "dm_crypt" "cryptd" "input_leds"
|
||||||
|
] ++ luks.cryptoModules
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
extraUtils = [
|
||||||
|
{ package = pkgs.cryptsetup; }
|
||||||
|
# dmsetup is required for device mapper stuff to work in stage-1.
|
||||||
|
{ package = lib.getBin pkgs.lvm2; binaries = [
|
||||||
|
"lvm" "dmsetup"
|
||||||
|
];}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
@ -30,6 +30,7 @@
|
|||||||
./initrd-vendor.nix
|
./initrd-vendor.nix
|
||||||
./initrd.nix
|
./initrd.nix
|
||||||
./internal.nix
|
./internal.nix
|
||||||
|
./luks.nix
|
||||||
./mobile-device.nix
|
./mobile-device.nix
|
||||||
./nixpkgs.nix
|
./nixpkgs.nix
|
||||||
./quirks
|
./quirks
|
||||||
|
@ -16,11 +16,17 @@ let
|
|||||||
install_package = set:
|
install_package = set:
|
||||||
let
|
let
|
||||||
pkg = if set ? type && set.type == "derivation" then set else set.package;
|
pkg = if set ? type && set.type == "derivation" then set else set.package;
|
||||||
|
binaries = if set ? binaries then set.binaries else [ "*" ];
|
||||||
in
|
in
|
||||||
''
|
(concat (map (path: ''
|
||||||
for BIN in ${pkg}/{s,}bin/*; do
|
for BIN in ${pkg}/{s,}bin/${path}; do
|
||||||
copy_bin_and_libs $BIN
|
if [ -e "$BIN" ]; then
|
||||||
|
copy_bin_and_libs "$BIN"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
'') binaries ))
|
||||||
|
+
|
||||||
|
''
|
||||||
${if set ? extraCommand then set.extraCommand else ""}
|
${if set ? extraCommand then set.extraCommand else ""}
|
||||||
'';
|
'';
|
||||||
install_packages = concat(map (install_package) packages);
|
install_packages = concat(map (install_package) packages);
|
||||||
|
@ -18,8 +18,8 @@ mrbgems.mkGem {
|
|||||||
src = fetchFromGitHub {
|
src = fetchFromGitHub {
|
||||||
repo = "mruby-lvgui";
|
repo = "mruby-lvgui";
|
||||||
owner = "mobile-nixos";
|
owner = "mobile-nixos";
|
||||||
rev = "ab7cf5b1b2e318a4bf5fc973507eb842dce80214";
|
rev = "f1bb1dd9b2c5aa3d3df4fcc41ca706f426d182a8";
|
||||||
sha256 = "09h9f3xlbvxdwpmzpf7whq0gphiv68842shy03ld712fw25393jx";
|
sha256 = "0ybjkzg743d21rn3q0vi0fa9zwp3ym9zw2q5ym24wc7gxdspjcjs";
|
||||||
};
|
};
|
||||||
|
|
||||||
gemBuildInputs = [
|
gemBuildInputs = [
|
||||||
|
@ -24,13 +24,13 @@ let
|
|||||||
in
|
in
|
||||||
stdenv.mkDerivation {
|
stdenv.mkDerivation {
|
||||||
pname = "lvgui";
|
pname = "lvgui";
|
||||||
version = "2020-07-25";
|
version = "2020-11-01";
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
src = fetchFromGitHub {
|
||||||
repo = "lvgui";
|
repo = "lvgui";
|
||||||
owner = "mobile-nixos";
|
owner = "mobile-nixos";
|
||||||
rev = "0dc257d07271fad023a5e6e9ac42222d2397c5cf";
|
rev = "4f8af498a81bd669d42ce3b370fc66fe4ec681b5";
|
||||||
sha256 = "1zb7naamqfzcsfi5809c93f1ygxp4w3aiw6172bpmnk1vxdchwsh";
|
sha256 = "00rik18c3c3l4glzh2azg90cwvp56s4wnski86rsn00bxslia5ma";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Document `LVGL_ENV_SIMULATOR` in the built headers.
|
# Document `LVGL_ENV_SIMULATOR` in the built headers.
|
||||||
|
Loading…
Reference in New Issue
Block a user