mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-28 05:57:47 -04:00
* feat(display): per-output ICC color profiles and color temperature Rebuild of #3388 on current master: the previous branch was cut from an older base and reverted unrelated fixes in the gamma stack and the display settings UI. ICC profiles: - core/internal/icc: ICC v2/v4 parser (TRC, LUT8/16 and parametric curves, vcgt, description/version/color space) plus gamma ramp generation, with synthetic coverage and optional vendor-profile tests (ICC_TEST_DIR). - wayland manager: load iccProfiles from the compositor's DMS config dir, build the ramp from the profile when one is set (falling back to the temperature ramp when it is missing or unparsable), and re-apply on output hotplug and after resume. - ICC ramps apply while the night light schedule is disabled; outputs without a profile keep an identity ramp. - desc/mluc offsets are validated before slicing, so a malformed profile returns an error instead of panicking the daemon. Per-output temperature: - outputTemps in the same config; SetOutputTemp accepts 1000-10000K and the Display Config slider exposes 3000-10000K, which matters for displays calibrated at a higher white point (e.g. a 7000K profile). Gamma control lifecycle: - needsControls() now keeps the controls alive for configured ICC profiles and per-output temperatures, so toggling the night light off no longer destroys the controls and drops the ramps. - Re-apply paths clear the per-output dedup state instead of adding a manager-level guard, which would have suppressed contrast-only writes and the post-resume forced resend (#1235), and left a recreated control without a ramp. - Monitor sleep/disconnect still re-establishes controls when an output comes back. IPC / CLI / UI: - wayland.icc.{getStatus,apply,remove,listOutputs,setTemp,getTemps} - dms icc list | info <file> | apply <output> <file> | remove <output> | status - Display Config output card gains a Color Profile row (browse/apply/remove with description, version, color space and active state) and a per-output color temperature slider, backed by Services/ICCService.qml. Config path: - DMSConfigDir() follows the compositor layout (niri/dms, hypr/dms, mango/dms) via the shared compositor detection and utils.XDGConfigHome(), replacing the hardcoded niri path in both the manager and `dms icc`. Tests: TZ=UTC go test ./... (60 packages ok), including red/green coverage for the icc parser bounds checks and needsControls(). * feat(icc): add `dms icc set-temp` and show per-output temps in `status` The per-output temperature override was reachable from the Display Config slider and from `wayland.icc.setTemp` over IPC, but not from the CLI. - `dms icc set-temp <output> <kelvin>` (alias `setTemp`) sets a per-output override, 1000-10000K, and 0 clears it so the output follows the night light schedule again. Range checking matches the daemon so an invalid value fails in the CLI instead of over IPC. - `dms icc status` gains a Temp column (the override in K, or `schedule`), so an override can be verified without opening the settings UI. Verified against a running daemon: `dms icc status` reports 7000K for the three overridden outputs and `schedule` for the remaining one. * feat(icc): treat the per-output temperature as the profile's reference white point The per-output temperature was documented as the white point a profile was produced at, but an output with a profile ignored it entirely: the ramp was generated from the profile and the temperature only applied to outputs without one (or as the fallback when a profile failed to parse). A display calibrated at 7000K therefore got the profile ramp, and enabling the night light had no effect on it at all. - `ProfileRampWithTemp` composes the profile ramp with the ratio between the target temperature ramp and the reference ramp, so the temperature is the white point the profile describes and the night light shifts relative to it. - `applyGamma` passes the night light temperature as the target, and `noTempTarget` when the schedule is disabled, which leaves profiled outputs at their reference white point and drives plain outputs with the neutral ramp. - The re-apply sentinel stays 0, so it cannot collide with an applied temperature. Covered by TestProfileRampWithTemp (reference == target is a no-op, warmer and cooler targets move the expected channels). Also: `dms icc set-temp` help text describes the reference-white-point semantics. * fix(icc): load the configured profiles even when controls already exist Restoring the upstream "output returned, re-establishing controls" branch made the registry handler establish the gamma controls before the startup post runs, and that post returned early on `m.controlsInitialized`, so the configured ICC profiles and per-output temperatures were never attached: every output reported "(none)" and the night light ran with defaults until the user re-applied a profile by hand. - `initializeControlsAndICC` (the startup post) loads the configuration first and only creates the controls when they are missing. - The loading is split into `loadConfiguredICC` / `applyConfiguredICCForOutput` so it can also run per output. - A hotplugged output now gets its configured profile and temperature as soon as its name is known (name handler) or when its control is created, which also makes the "re-applies on hotplug" claim in the description true. - After attaching, the output's dedup state is cleared so the ramp is written. Covered by TestManager_LoadConfiguredICCWhenControlsAlreadyExist and TestManager_AttachConfiguredICCForNamedOutput. * fix(icc): make the per-output temperature an actual target The per-output value was treated as the white point a profile was produced at, which meant a display with a profile never changed when the user set a temperature: the profile ramp was applied as measured and the value only mattered when a night light target existed. Per-display temperatures were therefore unusable on profiled outputs while the night light was disabled, which is the normal state for users who want each monitor at its own value. - `effectiveTempTarget` decides per output: a per-output override wins over the night light schedule, the schedule applies when there is no override, and `noTempTarget` means neither. `0` is "no override", not 0K. - `applyGamma` composes the override on top of the profile ramp, treating the profile as measured at 6500K (D65), and drives outputs without a profile with the same target so both paths agree. - `dms icc set-temp` help text and the Display Config slider description go back to describing an independent per-output temperature. Covered by TestEffectiveTempTarget (override wins, schedule fallback, none) and the updated TestProfileRampWithTemp (7000K cools, 5000K warms relative to the 6500K reference). * feat(icc): show the profile details in Display Config The profile row only showed the description, so there was no way to tell which profile a display actually has applied (whitepoint, curve type, file provenance) without leaving the settings page. - `ICCStatus` carries the descriptive metadata: class, tone-curve kind (plus gamma or table size), vcgt channels/entries, white point chromaticity with a derived CCT and standard illuminant name, file size and mtime. - `internal/icc` gains `WhitePointXY`, `WhitePointCCT` (McCamy), `WhitePointName` (D50/D65) and `TRCKind`, all covered by tests. - Display Config's Color Profile row gets an info button that opens `ICCProfileInfoModal` with those fields and the profile path, alongside the existing browse/remove actions. - `dms icc info` reports the white point as name/xy/CCT instead of raw XYZ. * fix(icc): keep the daemon, the state snapshot and the temp slider honest A malformed profile could still take the daemon down, the ICC getters ran off the wayland actor goroutine, the pushed state hid a removal, and the per-output temperature slider dropped wheel changes. - `internal/icc`: the tag table was sized with `uint32` arithmetic, so `tagCount*12` wrapped (`0x15555556*12 == 8`) and a 140-byte profile passed the bounds check, allocated ~4.3 GB and then panicked. `ParseFile` runs on the wayland actor goroutine, which has no `recover()`, so a bad profile persisted in `wayland.json` took the daemon down at startup. The count is now compared against the bytes that are actually left before anything is sized with it. - The manager publishes an ICC snapshot (`iccStatus`/`iccTemps`) whenever the actor changes an output's profile or temperature, and `GetICCStatus`/ `GetOutputTemps` serve that snapshot instead of ranging `m.outputs` and reading fields the actor owns. That removes the data race reported by `go test -race` for `dms icc status` running against a hotplug, and keeps the per-profile `os.Stat` off the night light animation path, where it ran on every `updateStateFromSchedule` (including each 25 K step). - `State.ICCProfiles`/`OutputTemps` drop `omitempty`, so removing the last profile pushes `{}` instead of omitting the key: the QML truthiness guard then clears `ICCService.status` instead of leaving the removed profile on screen with a green "active" dot. - `removeOutputByRegistryName` (extracted from the registry remove handler) deletes the `outputNames`/`outputRegNames` entries along with the output, so a disconnected monitor stops being listed by `dms icc status`/`listOutputs`, and a rebound `wl_output` reusing the released object ID cannot attach the previous monitor's profile from the stale name. - The per-output temperature slider sets `wheelEnabled: false` like the other drag-finished sliders: `DankSlider`'s wheel handler assigns `value` and emits `sliderValueChanged` without `sliderDragFinished`, so scrolling dropped the change, froze the label on the scrolled value and destroyed the value binding. Covered by TestParseBytesTagTableBounds (allocates 4,299,161,600 bytes at parser.go:138 and dies with an unrecovered fatal error on the previous commit), TestManager_ICCStatusServesPublishedSnapshot, TestManager_RemoveOutputByRegistryNamePrunesNames and TestManager_ICCStatusConcurrentPublishAndRead; a temporary copy of the getter test against the previous commit reports DATA RACE and passes here. * fix(icc): keep the attached profile when a gamma control is re-created `setupOutputControls` stored a fresh `outputState` for every output it touched, and `createControls` calls it whenever the controls are rebuilt, so recreating a control dropped the output's `iccPath`/`iccProfile`/`outputTemp` — configured state that does not belong to the control. The path is dormant today because `destroyControls` only runs when nothing is configured (`needsControls()` covers `ICCProfiles`/`OutputTemps`), but the snapshot published after `createControls` would then report whatever the rebuilt state holds instead of the profile the display is configured with. `controlStateFor` reuses the state of a known output and resets only what belongs to the new control: the ramp bookkeeping and the failure budget (rampSize, failed, retryCount, lastFailTime, lastTemp, lastGamma, lastContrast). A control that has not reported gamma_size yet has to receive its ramp again, while the profile and the per-output temperature survive. An output seen for the first time still starts from a clean state. Covered by TestManager_ControlStateReuseKeepsAttachedICC. * fix(icc): keep the config a real snapshot and make the per-output temperature apply Third review round. - `Config` is copied by value all over the manager, but `ICCProfiles` and `OutputTemps` are maps, so every copy aliased the ones the manager writes under `configMutex`. `updateStateFromSchedule` stored such a copy in `m.state` and it was marshalled on the connection writer goroutine while `ApplyICC` stored a profile from the IPC goroutine (`fatal error: concurrent map iteration and map write`, which no `recover()` catches); `SaveConfig` marshalled its copy after the unlock, and `loadConfiguredICC`/`attachConfiguredICC` indexed the maps after releasing the lock. Every copy that outlives the lock is cloned now (`cloneConfig`/`configSnapshot`), so a value copy is a snapshot again. - `SetOutputTemp` never created the gamma controls. On a default install (night light off, gamma 1.0, contrast 1.0, no profile, no override) they do not exist, so the `Range` over `m.outputs` matched nothing: the value was written to `wayland.json`, no ramp was applied, and the settings slider snapped back to "Default" until a daemon restart. It now persists first, creates the controls through `ensureOutputControls` (shared with `ApplyICC`), and pushes the state from the actor, and the published temperatures report the configured value even before an output is known. - A TRC is not a video card gamma ramp: `rTRC`/`gTRC`/`bTRC` describe the display's own transfer function, so a gamma-2.2 profile was written as `t^(1/2.2)` — mid gray 0.5 became 47909/65535 — washing the display out instead of correcting it. `ApplyICC` refuses a profile without `vcgt`, the attach path skips one with a warning, and `GenerateGammaRamp` returns an error instead of falling back to the TRC or to an identity ramp. TRC stays profile metadata (`dms icc info`, the settings dialog). - `icc: applied ICC ramp` was logged at Info on every 25 K animation step; it is Debug now, which is where a per-step message belongs. - `I18n.tr("No information available")` in the profile info modal passes its translator context, like every other string this PR adds. Covered by TestManager_ConfigSnapshotOwnsItsMaps, TestManager_ConfigSnapshotConcurrentMarshal (the previous commit dies with `fatal error: concurrent map iteration and map write`), TestManager_SetOutputTempWithoutControlsPublishesValue (the previous commit reports 0 for the value it just stored), TestGenerateGammaRampRequiresVCGT, and the updated TestProfileRampWithTemp, whose fixture now carries an identity vcgt table instead of relying on an empty profile. * fix(icc): push the state when only the ICC part changed Found while sweeping the same class of problem: `stateChanged` compared only the schedule fields, so a change that touches nothing else — applying or removing a profile, `dms icc set-temp` — was treated as "no change" and the notifier never pushed it. The settings page then only caught up because the UI path calls `requestStatus()` afterwards, and the CLI path did not catch up at all, which is the symptom the omitempty fix was meant to remove. `iccStateChanged` compares the ICC profile status and the per-output temperatures, so those changes reach the subscribers on their own. Covered by the new TestStateChanged cases (profile applied, profile removed, profile description changed, output temp changed, unchanged ICC state); the four change cases report `stateChanged() = false` on the previous commit. * fix(icc): keep the night light state, prune names without a control, use a 0 sentinel Fifth review round. - `SaveICCConfig` replaces `SaveConfig` on the three ICC paths (apply, remove, set-temp). Writing the daemon's whole config persisted the night light fields it happens to hold in memory: with the night light on, applying a profile stored `"Enabled": true`, and since the shell only ever sends `wayland.gamma.setEnabled` when the mode is on, turning it off afterwards (an in-memory change on the daemon side, no write) left that `true` on disk — after log out/in the daemon came up driving the schedule while the UI showed night mode off, with no way to resync short of a toggle. The ICC save now reads the file and rewrites only `iccProfiles`/`outputTemps`, so the fields the shell owns stay as they are. - `removeOutputByRegistryName` resolved the output from `m.outputs`, which only has entries once the gamma controls exist. On a default install (night light off, no profile, no override) no control is ever created, so a disconnected monitor kept its `outputNames`/`outputRegNames` entries for the life of the daemon: `dms icc status`/`listOutputs` listed monitors that were gone, and both maps grew with every dock/undock cycle. The object ID is resolved from `m.outputRegNames` (populated for every `wl_output`), the name entries are dropped before the output state is looked up, and a state that is known without a registry name still falls back to matching its own. - The Display Config temperature row used `7000` as its fallback for an output with no override. `publishICCState` only copies entries with `temp != 0`, so the value is absent rather than `0` for an unconfigured output: the card claimed "7000K" while the daemon drove the night light or neutral, and a user who deliberately set 7000K got the "no override" colouring because the highlight tested `!== 7000`. The sentinel is `0` now, which is what the `Default` label branches and the "is overridden" tests were written against. Covered by TestSaveICCConfigKeepsNightLightFields (the previous commit brings the night light back on after a restart), TestManager_RemoveOutputByRegistryNameWithoutControl (the previous commit keeps an unplugged monitor listed) and the existing TestManager_RemoveOutputByRegistryNamePrunesNames. * fix(icc): validate the CLI arguments and the IPC parameters of the ICC commands Sweep of the two surfaces the review has not looked at yet, so the failures are found here instead of in the next round. - `dms icc apply` copied the profile into the config directory before asking the daemon, and only the *parse* was checked locally. A profile the daemon refuses (no `vcgt` table, non-RGB color space) was therefore left in the profile directory for `dms icc list` to keep offering, and an IPC failure left the copy behind as well. The command now checks the same rules the daemon enforces before copying anything, and removes the copy it just made when the request fails (a file that was already there is left alone). - `dms icc list` marks a profile without a `vcgt` table, so the rule that makes `apply` refuse it is visible where the profiles are listed. - `ApplyICC`/`RemoveICC`/`SetOutputTemp` reach the manager over IPC with whatever the caller sent: an empty (or whitespace-padded) output name used to be persisted as an entry in `wayland.json` that no `wl_output` can ever match, and a relative profile path was stored to be resolved again on the next start, from a different working directory. Both are rejected now, before anything is written. Verified with the built CLI against the previous binary: with a profile that has no vcgt table, `dms icc apply` now exits before touching the config directory (`residual=[]`) where the previous one copied the file and went on to the IPC (`residual=[nogamma.icc]`); with a valid profile and no daemon reachable, the previous one leaves `vcgt.icc` behind and this one does not. Covered by TestManager_RejectsInvalidICCArguments. * fix(icc): apply the user gamma and contrast on profiled outputs * fix(icc): recreate failed controls without the night light and allow clearing a per-output temp * test(icc): cover the recreated control and the cleared temperature override Follow-up ond55d3d1f, which fixed both points without tests: - TestManager_RecreateOutputControlFollowsNeedsControls pins the predicate: with the night light off and nothing configured there is nothing to recreate, and with a configured profile or a per-output temperature the control has to be recreated. Both configured cases return nil on9226251c. - TestManager_SetOutputTempZeroClearsTheOverride pins what the reset button in Display Config does end to end: the stored value and the published entry both disappear, so the row reads "Default" and the output follows the schedule. Also keeps the reset button's slot instead of revealing it on demand. The temperature row shares its column with the slider below it, so the height change between "Default" (no button) and an override moved the slider 14 px under the pointer while it was being dragged; the button is now always laid out and only its opacity and enabled state follow the override, which also lets the label width drop the conditional. * fix(icc): replicate a one-channel vcgt and keep the temp slider in sync * fix(icc): release idle gamma controls and push the output list * fix(icc): dedupe ramps on the effective target * fix(icc): never overwrite a same-named profile in the config dir --------- Co-authored-by: bbedward <[email protected]>
1762 lines
48 KiB
Go
1762 lines
48 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"runtime/debug"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
|
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/notifyactions"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
|
"github.com/AvengeMedia/dankgo/ipc"
|
|
"github.com/AvengeMedia/dankgo/syncmap"
|
|
)
|
|
|
|
const APIVersion = 34
|
|
|
|
var CLIVersion = "dev"
|
|
|
|
type Capabilities struct {
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
|
|
type ServerInfo struct {
|
|
APIVersion int `json:"apiVersion"`
|
|
CLIVersion string `json:"cliVersion,omitempty"`
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
|
|
type ServiceEvent struct {
|
|
Service string `json:"service"`
|
|
Data any `json:"data"`
|
|
}
|
|
|
|
var networkManager *network.Manager
|
|
var loginctlManager *loginctl.Manager
|
|
var freedesktopManager *freedesktop.Manager
|
|
var waylandManager *wayland.Manager
|
|
var bluezManager *bluez.Manager
|
|
var appPickerManager *apppicker.Manager
|
|
var cupsManager *cups.Manager
|
|
var tailscaleManager *tailscale.Manager
|
|
var brightnessManager *brightness.Manager
|
|
var wlrOutputManager *wlroutput.Manager
|
|
var evdevManager *evdev.Manager
|
|
var clipboardManager *clipboard.Manager
|
|
var dbusManager *serverDbus.Manager
|
|
var wlContext *wlcontext.SharedContext
|
|
var themeModeManager *thememode.Manager
|
|
var wallpaperManager *wallpaper.Manager
|
|
var trayRecoveryManager *trayrecovery.Manager
|
|
var locationManager *location.Manager
|
|
var sysUpdateManager *sysupdate.Manager
|
|
var notifyActionsManager *notifyactions.Manager
|
|
var geoClientInstance geolocation.Client
|
|
|
|
const dbusClientID = "dms-dbus-client"
|
|
|
|
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
|
var cupsMu sync.Mutex
|
|
var cupsSubscriberCount int
|
|
var cupsEverAvailable bool
|
|
|
|
var appPaths = utils.App()
|
|
|
|
func GetSocketPath() string {
|
|
return appPaths.SocketPath()
|
|
}
|
|
|
|
func InitializeNetworkManager() error {
|
|
manager, err := network.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize network manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
networkManager = manager
|
|
|
|
log.Info("Network manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeLoginctlManager() error {
|
|
manager, err := loginctl.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize loginctl manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
loginctlManager = manager
|
|
|
|
log.Info("Loginctl manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeFreedeskManager() error {
|
|
manager, err := freedesktop.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize freedesktop manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
freedesktopManager = manager
|
|
matugen.SetColorSchemeEchoHook(manager.ExpectColorSchemeEcho)
|
|
|
|
log.Info("Freedesktop manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeWaylandManager() error {
|
|
log.Info("Attempting to initialize Wayland gamma control...")
|
|
|
|
if wlContext == nil {
|
|
ctx, err := wlcontext.New()
|
|
if err != nil {
|
|
log.Errorf("Failed to create shared Wayland context: %v", err)
|
|
return err
|
|
}
|
|
wlContext = ctx
|
|
}
|
|
|
|
config := wayland.LoadConfig()
|
|
manager, err := wayland.NewManager(wlContext.Display(), config)
|
|
if err != nil {
|
|
log.Errorf("Failed to initialize wayland manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
waylandManager = manager
|
|
|
|
log.Info("Wayland gamma control initialized successfully")
|
|
return nil
|
|
}
|
|
|
|
func InitializeBluezManager() error {
|
|
manager, err := bluez.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize bluez manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
bluezManager = manager
|
|
|
|
log.Info("Bluez manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeAppPickerManager() error {
|
|
manager := apppicker.NewManager()
|
|
appPickerManager = manager
|
|
log.Info("AppPicker manager initialized")
|
|
return nil
|
|
}
|
|
|
|
// Reports whether the cups capability was newly gained, not whether a manager was created.
|
|
func initializeCupsManagerLocked() (bool, error) {
|
|
if cupsManager != nil {
|
|
return false, nil
|
|
}
|
|
manager, err := cups.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize cups manager: %v", err)
|
|
return false, err
|
|
}
|
|
cupsManager = manager
|
|
log.Info("CUPS manager initialized")
|
|
gained := !cupsEverAvailable
|
|
cupsEverAvailable = true
|
|
return gained, nil
|
|
}
|
|
|
|
func ensureCupsManager() (*cups.Manager, error) {
|
|
cupsMu.Lock()
|
|
gained, err := initializeCupsManagerLocked()
|
|
mgr := cupsManager
|
|
cupsMu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if gained {
|
|
notifyCapabilityChange()
|
|
}
|
|
return mgr, nil
|
|
}
|
|
|
|
// Sticky: the manager is torn down when nobody is subscribed, but CUPS itself stays available.
|
|
func cupsAvailable() bool {
|
|
cupsMu.Lock()
|
|
defer cupsMu.Unlock()
|
|
return cupsEverAvailable
|
|
}
|
|
|
|
func releaseCupsSubscriber() {
|
|
cupsMu.Lock()
|
|
if cupsSubscriberCount > 0 {
|
|
cupsSubscriberCount--
|
|
}
|
|
var mgr *cups.Manager
|
|
if cupsSubscriberCount == 0 && cupsManager != nil {
|
|
mgr = cupsManager
|
|
cupsManager = nil
|
|
}
|
|
cupsMu.Unlock()
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
|
mgr.Close()
|
|
}
|
|
|
|
func InitializeBrightnessManager() error {
|
|
manager, err := brightness.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize brightness manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
brightnessManager = manager
|
|
|
|
log.Info("Brightness manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeWlrOutputManager() error {
|
|
log.Info("Attempting to initialize WlrOutput management...")
|
|
|
|
if wlContext == nil {
|
|
ctx, err := wlcontext.New()
|
|
if err != nil {
|
|
log.Errorf("Failed to create shared Wayland context: %v", err)
|
|
return err
|
|
}
|
|
wlContext = ctx
|
|
}
|
|
|
|
manager, err := wlroutput.NewManager(wlContext.Display())
|
|
if err != nil {
|
|
log.Debug("Failed to initialize wlroutput manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
wlrOutputManager = manager
|
|
|
|
log.Info("WlrOutput management initialized successfully")
|
|
return nil
|
|
}
|
|
|
|
func InitializeEvdevManager() error {
|
|
manager, err := evdev.InitializeManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize evdev manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
evdevManager = manager
|
|
|
|
log.Info("Evdev manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeClipboardManager() error {
|
|
log.Info("Attempting to initialize clipboard manager...")
|
|
|
|
if wlContext == nil {
|
|
ctx, err := wlcontext.New()
|
|
if err != nil {
|
|
log.Errorf("Failed to create shared Wayland context: %v", err)
|
|
return err
|
|
}
|
|
wlContext = ctx
|
|
}
|
|
|
|
config := clipboard.LoadConfig()
|
|
manager, err := clipboard.NewManager(wlContext, config)
|
|
if err != nil {
|
|
log.Errorf("Failed to initialize clipboard manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
clipboardManager = manager
|
|
|
|
log.Info("Clipboard manager initialized successfully")
|
|
return nil
|
|
}
|
|
|
|
func InitializeDbusManager() error {
|
|
manager, err := serverDbus.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize dbus manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
dbusManager = manager
|
|
|
|
log.Info("DBus manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeThemeModeManager() error {
|
|
manager := thememode.NewManager()
|
|
themeModeManager = manager
|
|
|
|
log.Info("Theme mode automation manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeWallpaperManager() error {
|
|
wallpaperManager = wallpaper.NewManager()
|
|
|
|
log.Info("Wallpaper rotation scheduler initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeTrayRecoveryManager() error {
|
|
manager, err := trayrecovery.NewManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trayRecoveryManager = manager
|
|
manager.WatchWatcherOwner()
|
|
|
|
log.Info("TrayRecovery manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeLocationManager(geoClient geolocation.Client) error {
|
|
manager, err := location.NewManager(geoClient)
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize location manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
locationManager = manager
|
|
|
|
log.Info("Location manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeNotifyActionsManager() error {
|
|
manager, err := notifyactions.NewManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
notifyActionsManager = manager
|
|
log.Info("Notification action manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func InitializeSysUpdateManager() error {
|
|
manager, err := sysupdate.NewManager()
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize sysupdate manager: %v", err)
|
|
return err
|
|
}
|
|
|
|
sysUpdateManager = manager
|
|
|
|
log.Info("Sysupdate manager initialized")
|
|
return nil
|
|
}
|
|
|
|
func routeHandler(_ context.Context, conn *models.Conn, req ipc.Request, _ *ipc.Subscriber) {
|
|
routeRequestRecovered(conn, models.Request(req))
|
|
}
|
|
|
|
func subscribeHandler(_ context.Context, conn *models.Conn, req ipc.Request, _ *ipc.Subscriber) {
|
|
switch req.Method {
|
|
case "subscribe":
|
|
routeRequestRecovered(conn, models.Request(req))
|
|
default:
|
|
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
|
}
|
|
}
|
|
|
|
// routeRequestRecovered keeps a panicking handler from taking down the whole daemon
|
|
func routeRequestRecovered(conn *models.Conn, req models.Request) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Errorf("RouteRequest panic recovered: method=%s panic=%v\n%s", req.Method, r, debug.Stack())
|
|
models.RespondError(conn, req.ID, "internal server error")
|
|
}
|
|
}()
|
|
RouteRequest(conn, req)
|
|
}
|
|
|
|
func getCapabilities() Capabilities {
|
|
caps := []string{"plugins", "dgop"}
|
|
|
|
if networkManager != nil {
|
|
caps = append(caps, "network")
|
|
}
|
|
|
|
if loginctlManager != nil {
|
|
caps = append(caps, "loginctl")
|
|
}
|
|
|
|
if freedesktopManager != nil {
|
|
caps = append(caps, "freedesktop")
|
|
}
|
|
|
|
if waylandManager != nil {
|
|
caps = append(caps, "gamma")
|
|
}
|
|
|
|
if bluezManager != nil {
|
|
caps = append(caps, "bluetooth")
|
|
}
|
|
|
|
if appPickerManager != nil {
|
|
caps = append(caps, "browser")
|
|
}
|
|
|
|
if cupsAvailable() {
|
|
caps = append(caps, "cups")
|
|
}
|
|
|
|
if tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
|
caps = append(caps, "tailscale")
|
|
}
|
|
|
|
if brightnessManager != nil {
|
|
caps = append(caps, "brightness")
|
|
}
|
|
|
|
if wlrOutputManager != nil {
|
|
caps = append(caps, "wlroutput")
|
|
}
|
|
|
|
if evdevManager != nil {
|
|
caps = append(caps, "evdev")
|
|
}
|
|
|
|
if clipboardManager != nil {
|
|
caps = append(caps, "clipboard")
|
|
}
|
|
|
|
if themeModeManager != nil {
|
|
caps = append(caps, "theme.auto")
|
|
}
|
|
|
|
if wallpaperManager != nil {
|
|
caps = append(caps, "wallpaper")
|
|
}
|
|
|
|
if locationManager != nil {
|
|
caps = append(caps, "location")
|
|
}
|
|
|
|
if dbusManager != nil {
|
|
caps = append(caps, "dbus")
|
|
}
|
|
|
|
if sysUpdateManager != nil {
|
|
caps = append(caps, "sysupdate")
|
|
}
|
|
|
|
return Capabilities{Capabilities: caps}
|
|
}
|
|
|
|
func getServerInfo() ServerInfo {
|
|
return ServerInfo{
|
|
APIVersion: APIVersion,
|
|
CLIVersion: CLIVersion,
|
|
Capabilities: getCapabilities().Capabilities,
|
|
}
|
|
}
|
|
|
|
func notifyCapabilityChange() {
|
|
info := getServerInfo()
|
|
capabilitySubscribers.Range(func(key string, ch chan ServerInfo) bool {
|
|
select {
|
|
case ch <- info:
|
|
default:
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func handleSubscribe(conn *models.Conn, req models.Request) {
|
|
clientID := fmt.Sprintf("meta-client-%p", conn)
|
|
|
|
dbusClient := dbusClientID
|
|
if id, ok := models.Get[string](req, "clientId"); ok && id != "" {
|
|
dbusClient = id
|
|
}
|
|
|
|
var services []string
|
|
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
|
|
for _, s := range servicesParam {
|
|
if str, ok := s.(string); ok {
|
|
services = append(services, str)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(services) == 0 {
|
|
services = []string{"all"}
|
|
}
|
|
|
|
subscribeAll := slices.Contains(services, "all")
|
|
|
|
var wg sync.WaitGroup
|
|
eventChan := make(chan ServiceEvent, 256)
|
|
stopChan := make(chan struct{})
|
|
|
|
capChan := make(chan ServerInfo, 64)
|
|
capabilitySubscribers.Store(clientID+"-capabilities", capChan)
|
|
|
|
wg.Go(func() {
|
|
defer capabilitySubscribers.Delete(clientID + "-capabilities")
|
|
|
|
for {
|
|
select {
|
|
case info, ok := <-capChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "server", Data: info}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
shouldSubscribe := func(service string) bool {
|
|
if subscribeAll {
|
|
return true
|
|
}
|
|
return slices.Contains(services, service)
|
|
}
|
|
|
|
if shouldSubscribe("network") && networkManager != nil {
|
|
wg.Add(1)
|
|
netChan := networkManager.Subscribe(clientID + "-network")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer networkManager.Unsubscribe(clientID + "-network")
|
|
|
|
initialState := networkManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "network", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-netChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "network", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("network.credentials") && networkManager != nil {
|
|
wg.Add(1)
|
|
credChan := networkManager.SubscribeCredentials(clientID + "-credentials")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer networkManager.UnsubscribeCredentials(clientID + "-credentials")
|
|
|
|
for {
|
|
select {
|
|
case prompt, ok := <-credChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "network.credentials", Data: prompt}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("loginctl") && loginctlManager != nil {
|
|
wg.Add(1)
|
|
loginChan := loginctlManager.Subscribe(clientID + "-loginctl")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer loginctlManager.Unsubscribe(clientID + "-loginctl")
|
|
|
|
initialState := loginctlManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "loginctl", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-loginChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "loginctl", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("freedesktop") && freedesktopManager != nil {
|
|
wg.Add(1)
|
|
freedesktopChan := freedesktopManager.Subscribe(clientID + "-freedesktop")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer freedesktopManager.Unsubscribe(clientID + "-freedesktop")
|
|
|
|
initialState := freedesktopManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "freedesktop", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-freedesktopChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "freedesktop", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("freedesktop.screensaver") && freedesktopManager != nil {
|
|
wg.Add(1)
|
|
screensaverChan := freedesktopManager.SubscribeScreensaver(clientID + "-screensaver")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer freedesktopManager.UnsubscribeScreensaver(clientID + "-screensaver")
|
|
|
|
initialState := freedesktopManager.GetScreensaverState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-screensaverChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "freedesktop.screensaver", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("gamma") && waylandManager != nil {
|
|
wg.Add(1)
|
|
waylandChan := waylandManager.Subscribe(clientID + "-gamma")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer waylandManager.Unsubscribe(clientID + "-gamma")
|
|
|
|
initialState := waylandManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "gamma", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-waylandChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "gamma", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("theme.auto") && themeModeManager != nil {
|
|
wg.Add(1)
|
|
themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer themeModeManager.Unsubscribe(clientID + "-theme-auto")
|
|
|
|
initialState := themeModeManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-themeAutoChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("wallpaper") && wallpaperManager != nil {
|
|
wg.Add(1)
|
|
wallpaperChan := wallpaperManager.Subscribe(clientID + "-wallpaper")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer wallpaperManager.Unsubscribe(clientID + "-wallpaper")
|
|
|
|
initialState := wallpaperManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "wallpaper", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-wallpaperChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "wallpaper", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("bluetooth") && bluezManager != nil {
|
|
wg.Add(1)
|
|
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer bluezManager.Unsubscribe(clientID + "-bluetooth")
|
|
|
|
initialState := bluezManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "bluetooth", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-bluezChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "bluetooth", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("bluetooth.pairing") && bluezManager != nil {
|
|
wg.Add(1)
|
|
pairingChan := bluezManager.SubscribePairing(clientID + "-pairing")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer bluezManager.UnsubscribePairing(clientID + "-pairing")
|
|
|
|
for {
|
|
select {
|
|
case prompt, ok := <-pairingChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "bluetooth.pairing", Data: prompt}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("browser") && appPickerManager != nil {
|
|
wg.Add(1)
|
|
appPickerChan := appPickerManager.Subscribe(clientID + "-browser")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer appPickerManager.Unsubscribe(clientID + "-browser")
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-appPickerChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "browser.open_requested", Data: event}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("cups") {
|
|
cupsMu.Lock()
|
|
cupsSubscriberCount++
|
|
gained, err := initializeCupsManagerLocked()
|
|
mgr := cupsManager
|
|
cupsMu.Unlock()
|
|
|
|
if err != nil {
|
|
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
|
} else if gained {
|
|
notifyCapabilityChange()
|
|
}
|
|
|
|
if mgr == nil {
|
|
releaseCupsSubscriber()
|
|
} else {
|
|
wg.Add(1)
|
|
cupsChan := mgr.Subscribe(clientID + "-cups")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() {
|
|
mgr.Unsubscribe(clientID + "-cups")
|
|
releaseCupsSubscriber()
|
|
}()
|
|
|
|
initialState := mgr.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-cupsChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "cups", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
if shouldSubscribe("tailscale") && tailscaleManager != nil && tailscaleManager.IsAvailable() {
|
|
wg.Add(1)
|
|
tailscaleChan := tailscaleManager.Subscribe(clientID + "-tailscale")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer tailscaleManager.Unsubscribe(clientID + "-tailscale")
|
|
|
|
initialState := tailscaleManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-tailscaleChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "tailscale", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("brightness") && brightnessManager != nil {
|
|
wg.Add(2)
|
|
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
|
|
brightnessUpdateChan := brightnessManager.SubscribeUpdates(clientID + "-brightness-updates")
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
defer brightnessManager.Unsubscribe(clientID + "-brightness-state")
|
|
|
|
initialState := brightnessManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "brightness", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-brightnessStateChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "brightness", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
defer brightnessManager.UnsubscribeUpdates(clientID + "-brightness-updates")
|
|
|
|
for {
|
|
select {
|
|
case update, ok := <-brightnessUpdateChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "brightness.update", Data: update}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("wlroutput") && wlrOutputManager != nil {
|
|
wg.Add(1)
|
|
wlrOutputChan := wlrOutputManager.Subscribe(clientID + "-wlroutput")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer wlrOutputManager.Unsubscribe(clientID + "-wlroutput")
|
|
|
|
initialState := wlrOutputManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "wlroutput", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-wlrOutputChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "wlroutput", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("evdev") && evdevManager != nil {
|
|
wg.Add(1)
|
|
evdevChan := evdevManager.Subscribe(clientID + "-evdev")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer evdevManager.Unsubscribe(clientID + "-evdev")
|
|
|
|
initialState := evdevManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "evdev", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-evdevChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "evdev", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("clipboard") && clipboardManager != nil {
|
|
wg.Add(1)
|
|
clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer clipboardManager.Unsubscribe(clientID + "-clipboard")
|
|
|
|
initialState := clipboardManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-clipboardChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "clipboard", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("location") && locationManager != nil {
|
|
wg.Add(1)
|
|
locationChan := locationManager.Subscribe(clientID + "-location")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer locationManager.Unsubscribe(clientID + "-location")
|
|
|
|
initialState := locationManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "location", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-locationChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "location", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
|
wg.Add(1)
|
|
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
|
go func() {
|
|
defer wg.Done()
|
|
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
|
|
|
|
initialState := sysUpdateManager.GetState()
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case state, ok := <-sysupdateChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if shouldSubscribe("dbus") && dbusManager != nil {
|
|
wg.Add(1)
|
|
dbusChan := dbusManager.SubscribeSignals(dbusClient)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer dbusManager.UnsubscribeSignals(dbusClient)
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-dbusChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case eventChan <- ServiceEvent{Service: "dbus", Data: event}:
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
case <-stopChan:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(eventChan)
|
|
}()
|
|
|
|
info := getServerInfo()
|
|
if err := conn.WriteResponse(models.Response[ServiceEvent]{
|
|
ID: req.ID,
|
|
Result: &ServiceEvent{Service: "server", Data: info},
|
|
}); err != nil {
|
|
close(stopChan)
|
|
return
|
|
}
|
|
|
|
for event := range eventChan {
|
|
if err := conn.WriteResponse(models.Response[ServiceEvent]{
|
|
ID: req.ID,
|
|
Result: &event,
|
|
}); err != nil {
|
|
close(stopChan)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func cleanupManagers() {
|
|
if networkManager != nil {
|
|
networkManager.Close()
|
|
}
|
|
if loginctlManager != nil {
|
|
loginctlManager.Close()
|
|
}
|
|
if freedesktopManager != nil {
|
|
freedesktopManager.Close()
|
|
}
|
|
if waylandManager != nil {
|
|
waylandManager.Close()
|
|
}
|
|
if bluezManager != nil {
|
|
bluezManager.Close()
|
|
}
|
|
if appPickerManager != nil {
|
|
appPickerManager.Close()
|
|
}
|
|
cupsMu.Lock()
|
|
if cupsManager != nil {
|
|
cupsManager.Close()
|
|
cupsManager = nil
|
|
}
|
|
cupsMu.Unlock()
|
|
if brightnessManager != nil {
|
|
brightnessManager.Close()
|
|
}
|
|
if wlrOutputManager != nil {
|
|
wlrOutputManager.Close()
|
|
}
|
|
if evdevManager != nil {
|
|
evdevManager.Close()
|
|
}
|
|
if clipboardManager != nil {
|
|
clipboardManager.Close()
|
|
}
|
|
if dbusManager != nil {
|
|
dbusManager.Close()
|
|
}
|
|
if notifyActionsManager != nil {
|
|
notifyActionsManager.Close()
|
|
}
|
|
if themeModeManager != nil {
|
|
themeModeManager.Close()
|
|
}
|
|
if wallpaperManager != nil {
|
|
wallpaperManager.Close()
|
|
}
|
|
if trayRecoveryManager != nil {
|
|
trayRecoveryManager.Close()
|
|
}
|
|
if wlContext != nil {
|
|
wlContext.Close()
|
|
}
|
|
if locationManager != nil {
|
|
locationManager.Close()
|
|
}
|
|
if sysUpdateManager != nil {
|
|
sysUpdateManager.Close()
|
|
}
|
|
if geoClientInstance != nil {
|
|
geoClientInstance.Close()
|
|
}
|
|
if tailscaleManager != nil {
|
|
tailscaleManager.Close()
|
|
}
|
|
}
|
|
|
|
type Server struct {
|
|
ipc *ipc.Server
|
|
}
|
|
|
|
func New() *Server {
|
|
return &Server{ipc: ipc.NewServer(ipc.Config{
|
|
AppName: appPaths.Name,
|
|
APIVersion: APIVersion,
|
|
CapabilitiesFunc: func() []string { return getCapabilities().Capabilities },
|
|
MaxLineSize: 64 * 1024 * 1024, // large clipboard payloads
|
|
SubscribeHandler: subscribeHandler,
|
|
}, routeHandler)}
|
|
}
|
|
|
|
func (s *Server) Listen() error { return s.ipc.Listen() }
|
|
|
|
func (s *Server) SocketPath() string { return s.ipc.SocketPath() }
|
|
|
|
func (s *Server) Close() {
|
|
s.ipc.Close()
|
|
}
|
|
|
|
func Start(printDocs bool) error {
|
|
s := New()
|
|
if err := s.Listen(); err != nil {
|
|
return err
|
|
}
|
|
return s.Serve(printDocs)
|
|
}
|
|
|
|
func (s *Server) Serve(printDocs bool) error {
|
|
defer s.ipc.Close()
|
|
defer cleanupManagers()
|
|
|
|
tuneRuntime()
|
|
startPprof()
|
|
|
|
// Tailscale manager always starts — reconnects internally via WatchIPNBus.
|
|
// The capability is only advertised once tailscaled is reachable; the
|
|
// callback wakes capability subscribers so QML clients see it transition.
|
|
tailscaleManager = tailscale.NewManager("")
|
|
tailscaleManager.SetAvailabilityCallback(func(bool) {
|
|
notifyCapabilityChange()
|
|
})
|
|
|
|
log.Infof("DMS API Server listening on: %s", s.ipc.SocketPath())
|
|
log.Infof("API Version: %d", APIVersion)
|
|
log.Info("Protocol: JSON over Unix socket")
|
|
log.Info("Request format: {\"id\": <any>, \"method\": \"...\", \"params\": {...}}")
|
|
log.Info("Response format: {\"id\": <any>, \"result\": {...}} or {\"id\": <any>, \"error\": \"...\"}")
|
|
log.Info("")
|
|
if printDocs {
|
|
log.Info("Available methods:")
|
|
log.Info(" ping - Test connection")
|
|
log.Info(" getServerInfo - Get server info (API version and capabilities)")
|
|
log.Info(" subscribe - Subscribe to multiple services (params: services [default: all])")
|
|
log.Info("Plugins:")
|
|
log.Info(" plugins.list - List all plugins")
|
|
log.Info(" plugins.listInstalled - List installed plugins")
|
|
log.Info(" plugins.install - Install plugin (params: name)")
|
|
log.Info(" plugins.uninstall - Uninstall plugin (params: name)")
|
|
log.Info(" plugins.update - Update plugin (params: name)")
|
|
log.Info(" plugins.search - Search plugins (params: query, category?, compositor?, capability?)")
|
|
log.Info("Network:")
|
|
log.Info(" network.getState - Get current network state")
|
|
log.Info(" network.wifi.scan - Scan for WiFi networks (params: device?)")
|
|
log.Info(" network.wifi.networks - Get WiFi network list")
|
|
log.Info(" network.wifi.connect - Connect to WiFi (params: ssid, password?, username?, device?, eapMethod?, phase2Auth?, caCertPath?, clientCertPath?, privateKeyPath?, useSystemCACerts?)")
|
|
log.Info(" network.wifi.disconnect - Disconnect WiFi (params: device?)")
|
|
log.Info(" network.wifi.forget - Forget network (params: ssid)")
|
|
log.Info(" network.wifi.toggle - Toggle WiFi radio")
|
|
log.Info(" network.wifi.enable - Enable WiFi")
|
|
log.Info(" network.wifi.disable - Disable WiFi")
|
|
log.Info(" network.wifi.setAutoconnect - Set network autoconnect (params: ssid, autoconnect)")
|
|
log.Info(" network.ethernet.connect - Connect Ethernet")
|
|
log.Info(" network.ethernet.connect.config - Connect Ethernet to a specific configuration")
|
|
log.Info(" network.ethernet.disconnect - Disconnect Ethernet")
|
|
log.Info(" network.cellular.connect - Connect Cellular")
|
|
log.Info(" network.cellular.connect.config - Connect Cellular to a specific configuration (params: uuid)")
|
|
log.Info(" network.cellular.disconnect - Disconnect Cellular (params: device?)")
|
|
log.Info(" network.cellular.toggle - Toggle Cellular radio")
|
|
log.Info(" network.cellular.enable - Enable Cellular")
|
|
log.Info(" network.cellular.disable - Disable Cellular")
|
|
log.Info(" network.vpn.profiles - List VPN profiles")
|
|
log.Info(" network.vpn.active - List active VPN connections")
|
|
log.Info(" network.vpn.connect - Connect VPN (params: uuidOrName|name|uuid, singleActive?)")
|
|
log.Info(" network.vpn.disconnect - Disconnect VPN (params: uuidOrName|name|uuid)")
|
|
log.Info(" network.vpn.disconnectAll - Disconnect all VPNs")
|
|
log.Info(" network.vpn.clearCredentials - Clear saved VPN credentials (params: uuidOrName|name|uuid)")
|
|
log.Info(" network.vpn.plugins - List available VPN plugins")
|
|
log.Info(" network.vpn.import - Import VPN from file (params: file|path, name?)")
|
|
log.Info(" network.vpn.getConfig - Get VPN configuration (params: uuid|name|uuidOrName)")
|
|
log.Info(" network.vpn.updateConfig - Update VPN configuration (params: uuid, name?, autoconnect?, data?)")
|
|
log.Info(" network.vpn.delete - Delete VPN connection (params: uuid|name|uuidOrName)")
|
|
log.Info(" network.preference.set - Set preference (params: preference [auto|wifi|ethernet|cellular])")
|
|
log.Info(" network.info - Get network info (params: ssid)")
|
|
log.Info(" network.credentials.submit - Submit credentials for prompt (params: token, secrets, save?)")
|
|
log.Info(" network.credentials.cancel - Cancel credential prompt (params: token)")
|
|
log.Info(" network.subscribe - Subscribe to network state changes (streaming)")
|
|
log.Info("Loginctl:")
|
|
log.Info(" loginctl.getState - Get current session state")
|
|
log.Info(" loginctl.lock - Lock session")
|
|
log.Info(" loginctl.unlock - Unlock session")
|
|
log.Info(" loginctl.activate - Activate session")
|
|
log.Info(" loginctl.setIdleHint - Set idle hint (params: idle)")
|
|
log.Info(" loginctl.setLockBeforeSuspend - Set lock before suspend (params: enabled)")
|
|
log.Info(" loginctl.setSleepInhibitorEnabled - Enable/disable sleep inhibitor (params: enabled)")
|
|
log.Info(" loginctl.lockerReady - Signal locker UI is ready (releases sleep inhibitor)")
|
|
log.Info(" loginctl.terminate - Terminate session")
|
|
log.Info(" loginctl.subscribe - Subscribe to session state changes (streaming)")
|
|
log.Info("Freedesktop:")
|
|
log.Info(" freedesktop.getState - Get accounts & settings state")
|
|
log.Info(" freedesktop.accounts.setIconFile - Set profile icon (params: path)")
|
|
log.Info(" freedesktop.accounts.setRealName - Set real name (params: name)")
|
|
log.Info(" freedesktop.accounts.setEmail - Set email (params: email)")
|
|
log.Info(" freedesktop.accounts.setLanguage - Set language (params: language)")
|
|
log.Info(" freedesktop.accounts.setLocation - Set location (params: location)")
|
|
log.Info(" freedesktop.accounts.getUserIconFile - Get user icon (params: username)")
|
|
log.Info(" freedesktop.settings.getColorScheme - Get color scheme")
|
|
log.Info(" freedesktop.settings.setIconTheme - Set icon theme (params: iconTheme)")
|
|
log.Info("Wayland:")
|
|
log.Info(" wayland.gamma.getState - Get current gamma control state")
|
|
log.Info(" wayland.gamma.setTemperature - Set temperature range (params: low, high)")
|
|
log.Info(" wayland.gamma.setLocation - Set location (params: latitude, longitude)")
|
|
log.Info(" wayland.gamma.setManualTimes - Set manual times (params: sunrise, sunset, durationMinutes)")
|
|
log.Info(" wayland.gamma.setGamma - Set gamma and contrast (params: gamma, contrast)")
|
|
log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
|
|
log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)")
|
|
log.Info("Theme automation:")
|
|
log.Info(" theme.auto.getState - Get current theme automation state")
|
|
log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)")
|
|
log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])")
|
|
log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)")
|
|
log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)")
|
|
log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)")
|
|
log.Info(" theme.auto.trigger - Trigger immediate re-evaluation")
|
|
log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)")
|
|
log.Info("Bluetooth:")
|
|
log.Info(" bluetooth.getState - Get current bluetooth state")
|
|
log.Info(" bluetooth.startDiscovery - Start device discovery (params: adapter?)")
|
|
log.Info(" bluetooth.stopDiscovery - Stop device discovery (params: adapter?)")
|
|
log.Info(" bluetooth.setPowered - Set adapter power state (params: powered, adapter?)")
|
|
log.Info(" bluetooth.togglePowered - Toggle adapter power state from its live value (params: adapter?)")
|
|
log.Info(" bluetooth.pair - Pair with device (params: device)")
|
|
log.Info(" bluetooth.connect - Connect to device (params: device)")
|
|
log.Info(" bluetooth.disconnect - Disconnect from device (params: device)")
|
|
log.Info(" bluetooth.remove - Remove/unpair device (params: device)")
|
|
log.Info(" bluetooth.trust - Trust device (params: device)")
|
|
log.Info(" bluetooth.untrust - Untrust device (params: device)")
|
|
log.Info(" bluetooth.pairing.submit - Submit pairing response (params: token, secrets?, accept?)")
|
|
log.Info(" bluetooth.pairing.cancel - Cancel pairing prompt (params: token)")
|
|
log.Info(" bluetooth.subscribe - Subscribe to bluetooth state changes (streaming)")
|
|
log.Info("CUPS:")
|
|
log.Info(" cups.getPrinters - Get printers list")
|
|
log.Info(" cups.getJobs - Get non-completed jobs list (params: printerName)")
|
|
log.Info(" cups.pausePrinter - Pause printer (params: printerName)")
|
|
log.Info(" cups.resumePrinter - Resume printer (params: printerName)")
|
|
log.Info(" cups.cancelJob - Cancel job (params: printerName, jobID)")
|
|
log.Info(" cups.purgeJobs - Cancel all jobs (params: printerName)")
|
|
log.Info("Brightness:")
|
|
log.Info(" brightness.getState - Get current brightness state for all devices")
|
|
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
|
|
log.Info(" brightness.increment - Increment device brightness (params: device, step?)")
|
|
log.Info(" brightness.decrement - Decrement device brightness (params: device, step?)")
|
|
log.Info(" brightness.rescan - Rescan for brightness devices (e.g., after plugging in monitor)")
|
|
log.Info(" brightness.subscribe - Subscribe to brightness state changes (streaming)")
|
|
log.Info(" Subscription events:")
|
|
log.Info(" - brightness : Full device list (on rescan, DDC discovery, device changes)")
|
|
log.Info(" - brightness.update: Single device update (on brightness change for efficiency)")
|
|
log.Info("WlrOutput:")
|
|
log.Info(" wlroutput.getState - Get current output configuration state")
|
|
log.Info(" wlroutput.applyConfiguration - Apply output configuration (params: heads)")
|
|
log.Info(" wlroutput.testConfiguration - Test output configuration without applying (params: heads)")
|
|
log.Info(" wlroutput.subscribe - Subscribe to output state changes (streaming)")
|
|
log.Info(" Head configuration params:")
|
|
log.Info(" - name : Output name (required)")
|
|
log.Info(" - enabled : Enable/disable output (required)")
|
|
log.Info(" - modeId : Mode ID from available modes (optional)")
|
|
log.Info(" - customMode : Custom mode {width, height, refresh} (optional)")
|
|
log.Info(" - position : Position {x, y} (optional)")
|
|
log.Info(" - transform : Transform value (optional)")
|
|
log.Info(" - scale : Scale value (optional)")
|
|
log.Info(" - adaptiveSync : Adaptive sync state (optional)")
|
|
log.Info("Evdev:")
|
|
log.Info(" evdev.getState - Get current evdev state (caps lock)")
|
|
log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)")
|
|
log.Info("Clipboard:")
|
|
log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)")
|
|
log.Info(" clipboard.getHistory - Get clipboard history with previews")
|
|
log.Info(" clipboard.getEntry - Get full entry by ID (params: id)")
|
|
log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)")
|
|
log.Info(" clipboard.deleteEntries - Delete entries by ID, skipping pinned ones (params: ids)")
|
|
log.Info(" clipboard.clearHistory - Clear all clipboard history")
|
|
log.Info(" clipboard.copy - Copy text to clipboard (params: text)")
|
|
log.Info(" clipboard.paste - Get current clipboard text")
|
|
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
|
|
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
|
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
|
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
|
log.Info("Notify:")
|
|
log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)")
|
|
log.Info("Location:")
|
|
log.Info(" location.getState - Get current location state")
|
|
log.Info(" location.subscribe - Subscribe to location changes (streaming)")
|
|
log.Info("")
|
|
}
|
|
log.Info("Initializing managers...")
|
|
log.Info("")
|
|
|
|
go func() {
|
|
switch err := InitializeNetworkManager(); {
|
|
case err == nil:
|
|
notifyCapabilityChange()
|
|
return
|
|
case errors.Is(err, network.ErrNoNetworkBackend):
|
|
log.Warn("No supported network backend present; skipping retries")
|
|
return
|
|
default:
|
|
log.Warnf("Network manager unavailable, will retry: %v", err)
|
|
}
|
|
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range 10 {
|
|
<-ticker.C
|
|
if networkManager != nil {
|
|
return
|
|
}
|
|
switch err := InitializeNetworkManager(); {
|
|
case err == nil:
|
|
log.Info("Network manager initialized")
|
|
notifyCapabilityChange()
|
|
return
|
|
case errors.Is(err, network.ErrNoNetworkBackend):
|
|
log.Warn("No supported network backend present; stopping retries")
|
|
return
|
|
}
|
|
}
|
|
log.Warn("Network manager still unavailable after retries; giving up")
|
|
}()
|
|
|
|
loginctlReady := make(chan struct{})
|
|
freedesktopReady := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(loginctlReady)
|
|
if err := InitializeLoginctlManager(); err != nil {
|
|
log.Warnf("Loginctl manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer close(freedesktopReady)
|
|
if err := InitializeFreedeskManager(); err != nil {
|
|
log.Warnf("Freedesktop manager unavailable: %v", err)
|
|
} else if freedesktopManager != nil {
|
|
freedesktopManager.NotifySubscribers()
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
// Bridge loginctl lock state to the freedesktop/gnome screensaver
|
|
// ActiveChanged signal so apps like Bitwarden can detect screen lock.
|
|
go func() {
|
|
<-loginctlReady
|
|
<-freedesktopReady
|
|
|
|
if loginctlManager == nil || freedesktopManager == nil {
|
|
return
|
|
}
|
|
|
|
ch := loginctlManager.Subscribe("dms-lock-bridge")
|
|
defer loginctlManager.Unsubscribe("dms-lock-bridge")
|
|
|
|
initial := loginctlManager.GetState()
|
|
lastLocked := initial.Locked
|
|
freedesktopManager.SetScreenLockActive(lastLocked)
|
|
|
|
for state := range ch {
|
|
if state.Locked != lastLocked {
|
|
lastLocked = state.Locked
|
|
freedesktopManager.SetScreenLockActive(lastLocked)
|
|
}
|
|
}
|
|
}()
|
|
|
|
if err := InitializeWaylandManager(); err != nil {
|
|
log.Warnf("Wayland manager unavailable: %v", err)
|
|
}
|
|
|
|
if err := InitializeThemeModeManager(); err != nil {
|
|
log.Warnf("Theme mode manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
go func() {
|
|
<-loginctlReady
|
|
if loginctlManager == nil {
|
|
return
|
|
}
|
|
themeModeManager.WatchLoginctl(loginctlManager)
|
|
}()
|
|
}
|
|
|
|
if err := InitializeWallpaperManager(); err != nil {
|
|
log.Warnf("Wallpaper scheduler unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
go func() {
|
|
<-loginctlReady
|
|
if loginctlManager == nil {
|
|
return
|
|
}
|
|
wallpaperManager.WatchLoginctl(loginctlManager)
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
<-loginctlReady
|
|
if loginctlManager == nil {
|
|
return
|
|
}
|
|
if err := InitializeTrayRecoveryManager(); err != nil {
|
|
log.Warnf("TrayRecovery manager unavailable: %v", err)
|
|
} else {
|
|
trayRecoveryManager.WatchLoginctl(loginctlManager)
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
geoClient := geolocation.NewClient()
|
|
geoClientInstance = geoClient
|
|
|
|
if waylandManager != nil {
|
|
waylandManager.SetGeoClient(geoClient)
|
|
}
|
|
if themeModeManager != nil {
|
|
themeModeManager.SetGeoClient(geoClient)
|
|
}
|
|
|
|
if err := InitializeLocationManager(geoClient); err != nil {
|
|
log.Warnf("Location manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
for {
|
|
err := InitializeBluezManager()
|
|
if err == nil {
|
|
notifyCapabilityChange()
|
|
return
|
|
}
|
|
log.Warnf("Bluez manager unavailable: %v", err)
|
|
if !errors.Is(err, bluez.ErrNoAdapter) {
|
|
return
|
|
}
|
|
if err := bluez.WaitForAdapter(); err != nil {
|
|
log.Warnf("Bluetooth adapter watch failed: %v", err)
|
|
return
|
|
}
|
|
log.Info("Bluetooth adapter appeared, initializing bluez manager")
|
|
}
|
|
}()
|
|
|
|
if err := InitializeAppPickerManager(); err != nil {
|
|
log.Debugf("AppPicker manager unavailable: %v", err)
|
|
}
|
|
|
|
if err := InitializeWlrOutputManager(); err != nil {
|
|
log.Debugf("WlrOutput manager unavailable: %v", err)
|
|
}
|
|
|
|
fatalErrChan := make(chan error, 1)
|
|
if wlrOutputManager != nil {
|
|
go func() {
|
|
err := <-wlrOutputManager.FatalError()
|
|
fatalErrChan <- fmt.Errorf("WlrOutput fatal error: %w", err)
|
|
}()
|
|
}
|
|
if wlContext != nil {
|
|
go func() {
|
|
err := <-wlContext.FatalError()
|
|
fatalErrChan <- fmt.Errorf("wayland context fatal error: %w", err)
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
if err := InitializeBrightnessManager(); err != nil {
|
|
log.Warnf("Brightness manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
if err := InitializeEvdevManager(); err != nil {
|
|
log.Debugf("Evdev manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
if err := InitializeClipboardManager(); err != nil {
|
|
log.Warnf("Clipboard manager unavailable: %v", err)
|
|
}
|
|
if wlContext != nil {
|
|
wlContext.Start()
|
|
log.Info("Wayland event dispatcher started")
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
if err := InitializeDbusManager(); err != nil {
|
|
log.Warnf("DBus manager unavailable: %v", err)
|
|
} else {
|
|
notifyCapabilityChange()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
if err := InitializeNotifyActionsManager(); err != nil {
|
|
log.Warnf("Notification action manager unavailable: %v", err)
|
|
}
|
|
}()
|
|
|
|
if err := InitializeSysUpdateManager(); err != nil {
|
|
log.Warnf("Sysupdate manager unavailable: %v", err)
|
|
}
|
|
|
|
log.Info("")
|
|
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
serveErrChan := make(chan error, 1)
|
|
go func() {
|
|
serveErrChan <- s.ipc.Serve(ctx)
|
|
}()
|
|
|
|
select {
|
|
case err := <-serveErrChan:
|
|
return err
|
|
case err := <-fatalErrChan:
|
|
return err
|
|
}
|
|
}
|