<message>Modify the first-boot.sh script to include an additional step for managing screen brightness during the provisioning process. Update user-data.bootstrap to improve DNS configuration by ensuring NetworkManager manages /etc/resolv.conf correctly, and remove obsolete scripts related to systemd-resolved. Enhance documentation to reflect these changes and clarify the setup process for users, improving overall network boot functionality and user experience.
303 lines
10 KiB
Python
303 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Brightness control overlay — always-on-top icon in bottom-right; click to show controls.
|
||
Uses Wayland layer-shell OVERLAY so it stays above Chromium fullscreen/kiosk.
|
||
Requires: brightness-api.service running (listens 127.0.0.1:8765).
|
||
"""
|
||
import logging
|
||
import sys
|
||
import urllib.request
|
||
import urllib.error
|
||
|
||
LOG_TAG = "brightness-overlay"
|
||
logging.basicConfig(
|
||
stream=sys.stderr,
|
||
level=logging.INFO,
|
||
format=f"{LOG_TAG}: %(message)s",
|
||
)
|
||
log = logging.getLogger(LOG_TAG)
|
||
|
||
try:
|
||
import gi
|
||
gi.require_version("Gdk", "3.0")
|
||
gi.require_version("Gtk", "3.0")
|
||
from gi.repository import Gdk, Gtk, GLib
|
||
except Exception as e:
|
||
log.error("need PyGObject Gtk: %s", e)
|
||
sys.exit(1)
|
||
|
||
HAS_LAYER_SHELL = False
|
||
try:
|
||
gi.require_version("GtkLayerShell", "0.1")
|
||
from gi.repository import GtkLayerShell
|
||
HAS_LAYER_SHELL = True
|
||
except Exception:
|
||
log.warning("GtkLayerShell not available, using regular window")
|
||
|
||
API_URL = "http://127.0.0.1:8765/brightness"
|
||
API_STATE_URL = "http://127.0.0.1:8765/state"
|
||
|
||
# Sizes
|
||
ICON_SIZE = 44
|
||
EXPANDED_WIDTH = 300
|
||
PANEL_HEIGHT = 48
|
||
|
||
|
||
def get_current_state():
|
||
"""Return (level 1–5, is_auto). On error return (None, True) so Auto appears active."""
|
||
try:
|
||
with urllib.request.urlopen(API_STATE_URL, timeout=1) as r:
|
||
text = r.read().decode()
|
||
level = None
|
||
override = "0"
|
||
for line in text.strip().splitlines():
|
||
line = line.strip()
|
||
if line.startswith("level="):
|
||
level = line.split("=", 1)[1].strip()
|
||
elif line.startswith("override="):
|
||
override = line.split("=", 1)[1].strip()
|
||
if level and level.isdigit() and 1 <= int(level) <= 5:
|
||
return int(level), override == "0"
|
||
return (None, True)
|
||
except Exception:
|
||
return (None, True)
|
||
|
||
|
||
def set_brightness(level):
|
||
try:
|
||
req = urllib.request.Request(
|
||
API_URL,
|
||
data=f"level={level}".encode(),
|
||
method="POST",
|
||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=2) as r:
|
||
log.info("brightness %s: %s", level, r.read().decode().strip())
|
||
except urllib.error.URLError as e:
|
||
log.warning("API call failed (is brightness-api.service running?): %s", e)
|
||
except Exception as e:
|
||
log.warning("set_brightness failed: %s", e)
|
||
|
||
|
||
def update_active_buttons(level_buttons, auto_btn, current_level, is_auto):
|
||
"""Set .active style on the button that matches current state."""
|
||
for i, btn in enumerate(level_buttons):
|
||
ctx = btn.get_style_context()
|
||
if not is_auto and current_level == i + 1:
|
||
ctx.add_class("active")
|
||
else:
|
||
ctx.remove_class("active")
|
||
if auto_btn:
|
||
ctx = auto_btn.get_style_context()
|
||
if is_auto:
|
||
ctx.add_class("active")
|
||
else:
|
||
ctx.remove_class("active")
|
||
|
||
|
||
def make_window_layer_shell(win):
|
||
GtkLayerShell.init_for_window(win)
|
||
GtkLayerShell.set_layer(win, GtkLayerShell.Layer.OVERLAY)
|
||
# Anchor only to bottom and right so the surface sits in the bottom-right corner
|
||
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.TOP, False)
|
||
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.LEFT, False)
|
||
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.BOTTOM, True)
|
||
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.RIGHT, True)
|
||
GtkLayerShell.set_keyboard_mode(win, GtkLayerShell.KeyboardMode.NONE)
|
||
GtkLayerShell.set_margin(win, GtkLayerShell.Edge.BOTTOM, 12)
|
||
GtkLayerShell.set_margin(win, GtkLayerShell.Edge.RIGHT, 12)
|
||
if hasattr(GtkLayerShell, "set_namespace"):
|
||
GtkLayerShell.set_namespace(win, "brightness-overlay")
|
||
if hasattr(GtkLayerShell, "set_exclusive_zone"):
|
||
GtkLayerShell.set_exclusive_zone(win, 0)
|
||
log.info("layer-shell OVERLAY anchored bottom-right (always on top)")
|
||
|
||
|
||
def position_fallback_bottom_right(win, width=None, height=None):
|
||
"""Position window in bottom-right (X11: only works with toplevel, not POPUP)."""
|
||
if width is None:
|
||
width = ICON_SIZE
|
||
if height is None:
|
||
height = ICON_SIZE
|
||
display = Gdk.Display.get_default()
|
||
if not display:
|
||
return
|
||
n = display.get_n_monitors()
|
||
monitor = display.get_monitor(0) if n else None
|
||
if not monitor:
|
||
return
|
||
geom = monitor.get_geometry()
|
||
x = geom.x + geom.width - width - 12
|
||
y = geom.y + geom.height - height - 12
|
||
win.move(x, y)
|
||
|
||
|
||
def make_window_fallback(win):
|
||
# Toplevel (not POPUP) so we can position and have no decorations on X11
|
||
win.set_keep_above(True)
|
||
win.set_decorated(False)
|
||
win.set_type_hint(Gdk.WindowTypeHint.UTILITY) # tool-style window, often undecorated
|
||
# Position after map so WM doesn't override
|
||
win.connect("map-event", lambda w, e: position_fallback_bottom_right(w))
|
||
log.info("X11 fallback: keep-above, undecorated, bottom-right")
|
||
|
||
|
||
def main():
|
||
# Normal toplevel: on X11 we can position it and set_decorated(False) removes title bar.
|
||
# (POPUP without parent cannot be positioned on X11 and gets "temporary window" / centering.)
|
||
win = Gtk.Window()
|
||
win.set_decorated(False)
|
||
win.set_resizable(False)
|
||
win.set_default_size(ICON_SIZE, ICON_SIZE)
|
||
win.set_skip_taskbar_hint(True)
|
||
win.set_skip_pager_hint(True)
|
||
# Transparent window: need RGBA visual and app-paintable
|
||
screen = Gdk.Screen.get_default()
|
||
if screen and screen.get_rgba_visual():
|
||
win.set_visual(screen.get_rgba_visual())
|
||
win.set_app_paintable(True)
|
||
|
||
# CSS: transparent window and overlay; only subtle hover so icon stays visible
|
||
style = """
|
||
GtkWindow, window {
|
||
background-color: transparent;
|
||
background-image: none;
|
||
}
|
||
.brightness-icon-btn {
|
||
background-color: transparent;
|
||
border-radius: 8px;
|
||
padding: 4px;
|
||
min-width: 36px;
|
||
min-height: 36px;
|
||
}
|
||
.brightness-icon-btn:hover {
|
||
background-color: rgba(0, 0, 0, 0.25);
|
||
}
|
||
.brightness-panel {
|
||
background-color: transparent;
|
||
border-radius: 8px;
|
||
padding: 6px;
|
||
}
|
||
.brightness-panel.expanded {
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
}
|
||
.brightness-panel button {
|
||
min-width: 36px;
|
||
font-weight: bold;
|
||
}
|
||
.brightness-panel button.level-btn.active {
|
||
background-color: rgba(255, 255, 255, 0.35);
|
||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||
}
|
||
"""
|
||
css = Gtk.CssProvider()
|
||
css.load_from_data(style.encode())
|
||
Gtk.StyleContext.add_provider_for_screen(
|
||
Gdk.Screen.get_default(),
|
||
css,
|
||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||
)
|
||
|
||
# Main horizontal box: [revealer with controls] [icon button]
|
||
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||
|
||
# Left part: revealer with Brightness + 1–5 + Auto
|
||
strip_box = Gtk.Box(spacing=6)
|
||
strip_box.set_margin_start(8)
|
||
strip_box.set_margin_top(6)
|
||
strip_box.set_margin_bottom(6)
|
||
strip_box.set_margin_end(4)
|
||
strip_box.get_style_context().add_class("brightness-panel")
|
||
|
||
strip_box.pack_start(Gtk.Label(label="Brightness "), False, False, 0)
|
||
level_buttons = []
|
||
for i in range(1, 6):
|
||
btn = Gtk.Button(label=str(i))
|
||
btn.get_style_context().add_class("level-btn")
|
||
btn.set_can_focus(False)
|
||
strip_box.pack_start(btn, False, False, 0)
|
||
level_buttons.append(btn)
|
||
auto_btn = Gtk.Button(label="Auto")
|
||
auto_btn.get_style_context().add_class("level-btn")
|
||
auto_btn.set_can_focus(False)
|
||
strip_box.pack_start(auto_btn, False, False, 0)
|
||
|
||
def refresh_active_state():
|
||
level, is_auto = get_current_state()
|
||
update_active_buttons(level_buttons, auto_btn, level or 3, is_auto)
|
||
|
||
def on_level_click(btn, level):
|
||
set_brightness(level)
|
||
# Optimistic update so active state shows immediately
|
||
if level == "auto":
|
||
update_active_buttons(level_buttons, auto_btn, 3, True)
|
||
else:
|
||
update_active_buttons(level_buttons, auto_btn, int(level), False)
|
||
# Sync from API after a short delay (daemon updates state file periodically)
|
||
def delayed_refresh():
|
||
refresh_active_state()
|
||
return False
|
||
GLib.timeout_add(500, delayed_refresh)
|
||
|
||
for i, btn in enumerate(level_buttons):
|
||
btn.connect("clicked", on_level_click, str(i + 1))
|
||
auto_btn.connect("clicked", on_level_click, "auto")
|
||
|
||
revealer = Gtk.Revealer()
|
||
revealer.set_reveal_child(False)
|
||
revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_LEFT)
|
||
revealer.set_transition_duration(150)
|
||
revealer.add(strip_box)
|
||
|
||
main_box.pack_start(revealer, False, False, 0)
|
||
|
||
# Right part: always-visible icon button (click toggles panel)
|
||
try:
|
||
icon_img = Gtk.Image.new_from_icon_name("display-brightness-symbolic", Gtk.IconSize.BUTTON)
|
||
except Exception:
|
||
icon_img = Gtk.Label(label="☀")
|
||
icon_btn = Gtk.Button()
|
||
icon_btn.get_style_context().add_class("brightness-icon-btn")
|
||
icon_btn.set_can_focus(False)
|
||
icon_btn.add(icon_img)
|
||
icon_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||
|
||
expanded = [False] # use list so closure can mutate
|
||
use_fallback = not (HAS_LAYER_SHELL and GtkLayerShell.is_supported())
|
||
|
||
def on_icon_clicked(btn):
|
||
expanded[0] = not expanded[0]
|
||
revealer.set_reveal_child(expanded[0])
|
||
ctx = strip_box.get_style_context()
|
||
if expanded[0]:
|
||
ctx.add_class("expanded")
|
||
GLib.idle_add(refresh_active_state)
|
||
win.resize(EXPANDED_WIDTH, PANEL_HEIGHT)
|
||
if use_fallback:
|
||
position_fallback_bottom_right(win, EXPANDED_WIDTH, PANEL_HEIGHT)
|
||
else:
|
||
ctx.remove_class("expanded")
|
||
win.resize(ICON_SIZE, ICON_SIZE)
|
||
if use_fallback:
|
||
position_fallback_bottom_right(win, ICON_SIZE, ICON_SIZE)
|
||
|
||
icon_btn.connect("clicked", on_icon_clicked)
|
||
main_box.pack_end(icon_btn, False, False, 0)
|
||
|
||
win.add(main_box)
|
||
|
||
if HAS_LAYER_SHELL and GtkLayerShell.is_supported():
|
||
make_window_layer_shell(win)
|
||
else:
|
||
make_window_fallback(win)
|
||
|
||
win.connect("destroy", Gtk.main_quit)
|
||
win.show_all()
|
||
GLib.idle_add(refresh_active_state)
|
||
log.info("brightness icon visible (click to show controls)")
|
||
Gtk.main()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|