// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "time" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/sessionbus" "github.com/godbus/dbus/v5" ) const screenLockBusName = "org.stormux.I38Lock1" type secureInputController interface{ SetSecureInputActive(bool) } // screenLockMonitor watches the same authenticated I38lock owner as Python. // I38lock supplies its own feedback through the existing self-voice service. type screenLockMonitor struct { logger *slog.Logger setActive func(bool) connection *dbus.Conn signals chan *dbus.Signal cancel context.CancelFunc done chan struct{} owner string active bool trustedOwner func(context.Context, string) bool } func (monitor *screenLockMonitor) Start(ctx context.Context) error { connection, err := sessionbus.Connect(monitor.logger, "screen-lock") if err != nil { return err } monitor.connection = connection monitor.signals = make(chan *dbus.Signal, 16) connection.Signal(monitor.signals) if err := connection.AddMatchSignal(screenLockMatch()...); err != nil { connection.Close() return err } monitor.trustedOwner = func(ctx context.Context, owner string) bool { var pid uint32 if err := connection.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.GetConnectionUnixProcessID", 0, owner).Store(&pid); err != nil { return false } executable, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) return err == nil && filepath.Base(executable) == "i38lock" } monitor.refresh(ctx) watchContext, cancel := context.WithCancel(ctx) monitor.cancel, monitor.done = cancel, make(chan struct{}) go func() { defer close(monitor.done) for { select { case <-watchContext.Done(): return case signal, ok := <-monitor.signals: if !ok { return } if signal != nil { monitor.refresh(watchContext) } } } }() return nil } func screenLockMatch() []dbus.MatchOption { return []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchObjectPath("/org/freedesktop/DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(0, screenLockBusName)} } func (monitor *screenLockMonitor) refresh(ctx context.Context) { queryContext, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() var owner string err := monitor.connection.BusObject().CallWithContext(queryContext, "org.freedesktop.DBus.GetNameOwner", 0, screenLockBusName).Store(&owner) if err != nil { var busError dbus.Error if !errors.As(err, &busError) || busError.Name != "org.freedesktop.DBus.Error.NameHasNoOwner" { monitor.logger.Warn("query I38lock owner", "error", err) return // Do not release secure mode after a failed bus query. } owner = "" } monitor.updateOwner(queryContext, owner) } func (monitor *screenLockMonitor) updateOwner(ctx context.Context, owner string) { if owner == monitor.owner { return } monitor.owner = owner active := owner != "" && monitor.trustedOwner(ctx, owner) if owner != "" && !active { monitor.logger.Warn("ignoring untrusted I38lock bus owner") } if active != monitor.active { monitor.active = active monitor.setActive(active) } } func (monitor *screenLockMonitor) Stop(ctx context.Context) error { if monitor.cancel == nil { return nil } monitor.cancel() select { case <-monitor.done: case <-ctx.Done(): return ctx.Err() } monitor.connection.RemoveSignal(monitor.signals) _ = monitor.connection.RemoveMatchSignal(screenLockMatch()...) return monitor.connection.Close() } func (gate *focusGate) Locked() bool { return gate != nil && gate.secure.Load() } func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputListener, commands commandListener, speech *speechWorker, display *brailleWorker) { if active { gate.ocr.Secure() } gate.mutex.Lock() defer gate.mutex.Unlock() if !gate.secure.CompareAndSwap(!active, active) { return } gate.retireTentacleOutput() for _, target := range []any{adapter, listener, commands} { if controller, ok := target.(secureInputController); ok { controller.SetSecureInputActive(active) } } if gate.commands != nil { gate.commands.SetCommandGrabs(!active && !gate.suspended.Load()) } if speech != nil { speech.SetSecureInputActive(active) } display.SetSecureInputActive(active) select { case gate.lockChanges <- struct{}{}: default: } } func (worker *speechWorker) SetSecureInputActive(active bool) { worker.mutex.Lock() defer worker.mutex.Unlock() worker.secure = active if active { worker.startupUntil = time.Time{} worker.interruptLocked() } } func (worker *brailleWorker) SetSecureInputActive(active bool) { worker.mu.Lock() worker.secure = active // Retire flash restoration and routing targets even without a display. worker.protectedFlash = false worker.flashContext = nil worker.flashGeneration++ if worker.flashTimer != nil { worker.flashTimer.Stop() worker.flashTimer = nil } if worker.routeCancel != nil { worker.routeCancel() } worker.frame, worker.baseFrame = braille.Frame{Cursor: -1}, braille.Frame{Cursor: -1} worker.internalUIFrame = nil worker.enqueueSecureFrameLocked(braille.Frame{Cursor: -1}, true) worker.mu.Unlock() worker.wakeDisplay() } func (worker *brailleWorker) EnqueueSecureMessage(text string) bool { if text == "" || len(text) > 10000 { return false } worker.mu.Lock() queued := worker.connected && worker.enqueueSecureFrameLocked(braille.Frame{Text: text, Cursor: -1}, true) worker.mu.Unlock() if queued { worker.wakeDisplay() } return queued } type lockFeedbackBraille struct{ worker *brailleWorker } func (display lockFeedbackBraille) EnqueueMessage(text string) bool { return display.worker.EnqueueSecureMessage(text) }