@domicile-desktop/system-backlight 0.0.0-alpha-a330bf5f3aae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Connor Prussin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @domicile-desktop/system-backlight
2
+
3
+ Screen brightness for a Domicile shell, on the system calls of
4
+ [`@domicile-desktop/sdk/system`](/packages/chrome-sdk/src/system.ts). See
5
+ [SHELL-SYSTEM-ACCESS.md](/docs/SHELL-SYSTEM-ACCESS.md).
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { system } from "@domicile-desktop/sdk/system";
11
+ import { brightnessSetter } from "@domicile-desktop/system-backlight/set-brightness";
12
+ import { watchBrightness } from "@domicile-desktop/system-backlight/watch-brightness";
13
+
14
+ const host = system(domicile);
15
+ const watch = await watchBrightness(host, (level) => show(level));
16
+ const set = brightnessSetter(host);
17
+ await set(0.5);
18
+ ```
19
+
20
+ - `readBacklight` (`./read-backlight`): the preferred device under
21
+ `/sys/class/backlight`: firmware, then platform, then raw, in systemd's
22
+ order; ties by name. `None` without one.
23
+ - `watchBrightness`: the level (0 to 1) now and on each change of a whole
24
+ percent. Re-reads on each `udevadm monitor` uevent and every two minutes.
25
+ - `brightnessSetter`: sets a level through logind's
26
+ `org.freedesktop.login1.Session.SetBrightness`. Never sets zero, which turns
27
+ most panels off. Sends one level at a time; a level superseded while waiting
28
+ resolves `SetOutcome.Superseded`.
29
+ - Every call resolves a `Result<…, SystemError>`. A level that is not a number
30
+ throws.
31
+
32
+ ## Requirements
33
+
34
+ - `udevadm` on the compositor's `PATH` (systemd).
35
+ - A logind session.
36
+ - Watching and setting work while the desktop is locked, so a lock screen can
37
+ adjust the brightness ([LOCK.md](/docs/LOCK.md)).
38
+
39
+ ## Testing
40
+
41
+ `bun run turbo test --filter @domicile-desktop/system-backlight`. Tests answer
42
+ the system calls from files recorded on a ThinkPad (`src/fake-system.ts`).
@@ -0,0 +1,8 @@
1
+ import type { Backlight } from "./read-backlight";
2
+ /**
3
+ * The raw value for `level`, clamped to 0..1 and rounded.
4
+ *
5
+ * Never zero: most panels turn off at zero, leaving no visible slider to raise
6
+ * it again. Throws on a level that is not finite, which is a caller's bug.
7
+ */
8
+ export declare const rawFor: ({ max }: Backlight, level: number) => number;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The raw value for `level`, clamped to 0..1 and rounded.
3
+ *
4
+ * Never zero: most panels turn off at zero, leaving no visible slider to raise
5
+ * it again. Throws on a level that is not finite, which is a caller's bug.
6
+ */
7
+ export const rawFor = ({ max }, level) => {
8
+ if (Number.isFinite(level)) {
9
+ return Math.max(1, Math.round(Math.min(1, Math.max(0, level)) * max));
10
+ }
11
+ else {
12
+ throw new Error(`a brightness must be a number, not ${level}`);
13
+ }
14
+ };
@@ -0,0 +1,21 @@
1
+ import type { Option, Result } from "@cprussin/option-result";
2
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
3
+ /** The calls {@link readBacklight} makes. */
4
+ export type SysfsHost = Pick<System, "readDir" | "readTextFile">;
5
+ /** One backlight device's current and maximum raw brightness. */
6
+ export type Backlight = {
7
+ /** The name under `/sys/class/backlight`, which logind takes. */
8
+ device: string;
9
+ raw: number;
10
+ max: number;
11
+ };
12
+ /**
13
+ * The backlight to show and set, or `None` if there is none.
14
+ *
15
+ * Picks the preferred type, breaking ties by name so the choice is stable.
16
+ * Skips a device whose files are missing or not numbers, or whose maximum is
17
+ * zero.
18
+ */
19
+ export declare const readBacklight: (host: SysfsHost) => Promise<Result<Option<Backlight>, SystemError>>;
20
+ /** Brightness from 0 to 1. */
21
+ export declare const levelOf: ({ max, raw }: Backlight) => number;
@@ -0,0 +1,64 @@
1
+ // Reads the screen's backlight from `/sys/class/backlight`.
2
+ import { Err, None, Ok, Some } from "@cprussin/option-result";
3
+ import { SystemErrorKind } from "@domicile-desktop/sdk/system";
4
+ import { z } from "zod";
5
+ /** Sysfs directory listing every backlight. */
6
+ const BACKLIGHT = "/sys/class/backlight";
7
+ /**
8
+ * Backlight types, preferred first, in systemd's order. A firmware interface
9
+ * knows the panel's curve; a raw one drives the same panel without it.
10
+ */
11
+ const KINDS = ["firmware", "platform", "raw"];
12
+ /** The files read from each device, in the order `readOne` takes them. */
13
+ const FILES = ["type", "max_brightness", "brightness"];
14
+ /**
15
+ * The backlight to show and set, or `None` if there is none.
16
+ *
17
+ * Picks the preferred type, breaking ties by name so the choice is stable.
18
+ * Skips a device whose files are missing or not numbers, or whose maximum is
19
+ * zero.
20
+ */
21
+ export const readBacklight = async (host) => (await host.readDir(BACKLIGHT)).match({
22
+ Err: async (error) => error.kind === SystemErrorKind.NotFound ? Ok(None()) : Err(error),
23
+ Ok: async (entries) => collected(await Promise.all(entries
24
+ .map(({ name }) => name)
25
+ .toSorted()
26
+ .map((device) => readOne(host, device)))).map((devices) => firstSome(KINDS.map((kind) => firstSome(devices.map((device) => device.andThen((read) => read.kind === kind ? Some(read.backlight) : None())))))),
27
+ });
28
+ /** Brightness from 0 to 1. */
29
+ export const levelOf = ({ max, raw }) => raw / max;
30
+ const readOne = async (host, device) => collected(await Promise.all(FILES.map((file) => readFile(host, device, file)))).map(([kind, max, raw]) => {
31
+ const parsed = deviceSchema.safeParse({
32
+ kind: present(kind),
33
+ max: present(max),
34
+ raw: present(raw),
35
+ });
36
+ return parsed.success
37
+ ? Some({
38
+ backlight: {
39
+ device,
40
+ max: parsed.data.max,
41
+ raw: Math.min(parsed.data.raw, parsed.data.max),
42
+ },
43
+ kind: parsed.data.kind,
44
+ })
45
+ : None();
46
+ });
47
+ /** A file of `device`, trimmed, or `None` if it is missing. */
48
+ const readFile = async (host, device, file) => (await host.readTextFile(`${BACKLIGHT}/${device}/${file}`)).match({
49
+ Err: (error) => error.kind === SystemErrorKind.NotFound ? Ok(None()) : Err(error),
50
+ Ok: (text) => Ok(Some(text.trim())),
51
+ });
52
+ const count = z
53
+ .string()
54
+ .regex(/^\d+$/)
55
+ .transform((digits) => Number.parseInt(digits, 10));
56
+ const deviceSchema = z.object({
57
+ kind: z.enum(KINDS),
58
+ max: count.refine((max) => max > 0),
59
+ raw: count,
60
+ });
61
+ /** Every value, or the first error. */
62
+ const collected = (results) => results.reduce((all, next) => all.andThen((values) => next.map((value) => [...values, value])), Ok([]));
63
+ const present = (text) => text?.match({ None: () => undefined, Some: (value) => value });
64
+ const firstSome = (options) => options.reduce((found, next) => found.or(next), None());
@@ -0,0 +1,26 @@
1
+ import type { Result } from "@cprussin/option-result";
2
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
3
+ import type { SysfsHost } from "./read-backlight";
4
+ export declare enum SetOutcome {
5
+ /** logind set it. */
6
+ Set = 0,
7
+ /** A newer level was asked for before this one was sent. */
8
+ Superseded = 1,
9
+ NoBacklight = 2
10
+ }
11
+ /** The calls {@link brightnessSetter} makes. */
12
+ export type SetterHost = SysfsHost & Pick<System, "dbusCall">;
13
+ /** Sets the backlight to a level from 0 to 1, and says how it went. */
14
+ export type SetBrightness = (level: number) => Promise<Result<SetOutcome, SystemError>>;
15
+ /**
16
+ * A function that sets the preferred backlight to a level from 0 to 1.
17
+ *
18
+ * - Reads the device again for each level, since the raw scale is the
19
+ * device's and it may have gone away.
20
+ * - Never sets zero; see `rawFor`.
21
+ * - Sends one level at a time. A dragged slider asks many times while logind
22
+ * answers once, so only the newest level waiting is sent and the others
23
+ * resolve {@link SetOutcome.Superseded}.
24
+ * - A level that is not a number rejects.
25
+ */
26
+ export declare const brightnessSetter: (host: SetterHost) => SetBrightness;
@@ -0,0 +1,76 @@
1
+ // Sets the screen's backlight through logind.
2
+ //
3
+ // `/sys/class/backlight/*/brightness` is writable only by root. logind's
4
+ // `Session.SetBrightness` lets the session's owner set it without a udev rule
5
+ // or a setuid helper.
6
+ import { Ok } from "@cprussin/option-result";
7
+ import { Bus } from "@domicile-desktop/sdk/system";
8
+ import { z } from "zod";
9
+ import { rawFor } from "./raw-for";
10
+ import { readBacklight } from "./read-backlight";
11
+ export var SetOutcome;
12
+ (function (SetOutcome) {
13
+ /** logind set it. */
14
+ SetOutcome[SetOutcome["Set"] = 0] = "Set";
15
+ /** A newer level was asked for before this one was sent. */
16
+ SetOutcome[SetOutcome["Superseded"] = 1] = "Superseded";
17
+ SetOutcome[SetOutcome["NoBacklight"] = 2] = "NoBacklight";
18
+ })(SetOutcome || (SetOutcome = {}));
19
+ /**
20
+ * A function that sets the preferred backlight to a level from 0 to 1.
21
+ *
22
+ * - Reads the device again for each level, since the raw scale is the
23
+ * device's and it may have gone away.
24
+ * - Never sets zero; see `rawFor`.
25
+ * - Sends one level at a time. A dragged slider asks many times while logind
26
+ * answers once, so only the newest level waiting is sent and the others
27
+ * resolve {@link SetOutcome.Superseded}.
28
+ * - A level that is not a number rejects.
29
+ */
30
+ export const brightnessSetter = (host) => {
31
+ const queue = { pending: undefined, sending: false };
32
+ return (level) => {
33
+ if (queue.sending) {
34
+ queue.pending?.settle(Ok(SetOutcome.Superseded));
35
+ return new Promise((settle, fail) => {
36
+ queue.pending = { fail, level, settle };
37
+ });
38
+ }
39
+ else {
40
+ queue.sending = true;
41
+ return sendFrom(host, queue, level);
42
+ }
43
+ };
44
+ };
45
+ /** Send `level`, then whatever waits behind it. */
46
+ const sendFrom = async (host, queue, level) => {
47
+ try {
48
+ return await send(host, level);
49
+ }
50
+ finally {
51
+ const next = queue.pending;
52
+ queue.pending = undefined;
53
+ if (next === undefined) {
54
+ queue.sending = false;
55
+ }
56
+ else {
57
+ sendFrom(host, queue, next.level).then(next.settle, next.fail);
58
+ }
59
+ }
60
+ };
61
+ const send = async (host, level) => (await readBacklight(host)).andThenAsync(async (read) => read.match({
62
+ None: async () => Ok(SetOutcome.NoBacklight),
63
+ Some: async (backlight) => (await host.dbusCall({
64
+ body: ["backlight", backlight.device, rawFor(backlight, level)],
65
+ bus: Bus.System,
66
+ destination: "org.freedesktop.login1",
67
+ interface: "org.freedesktop.login1.Session",
68
+ member: "SetBrightness",
69
+ path: "/org/freedesktop/login1/session/auto",
70
+ signature: "ssu",
71
+ })).map(({ body }) => {
72
+ returnsNothing.parse(body);
73
+ return SetOutcome.Set;
74
+ }),
75
+ }));
76
+ const returnsNothing = z.tuple([]);
@@ -0,0 +1,24 @@
1
+ import type { Result } from "@cprussin/option-result";
2
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
3
+ import type { SysfsHost } from "./read-backlight";
4
+ /** The calls {@link watchBrightness} makes. */
5
+ export type WatchHost = SysfsHost & Pick<System, "spawn">;
6
+ export type BrightnessWatch = {
7
+ stop: () => void;
8
+ /** `Ok` after {@link BrightnessWatch.stop}, `Err` if the watch broke. */
9
+ ended: Promise<Result<"stopped", SystemError>>;
10
+ };
11
+ /**
12
+ * Calls `onLevel` with the backlight's level from 0 to 1, now and after each
13
+ * change.
14
+ *
15
+ * - Reports only a move of a whole percent, since the slider shows no finer.
16
+ * - Never called on a machine without a backlight. A backlight that goes away
17
+ * and comes back is reported again.
18
+ * - Runs `udevadm` from the compositor's `PATH`. Works while the desktop is
19
+ * locked.
20
+ */
21
+ export declare const watchBrightness: (host: WatchHost, onLevel: (level: number) => void, every?: typeof everyInterval) => Promise<Result<BrightnessWatch, SystemError>>;
22
+ /** Calls `tick` every `ms`, until the returned function is called. */
23
+ declare const everyInterval: (ms: number, tick: () => void) => (() => void);
24
+ export {};
@@ -0,0 +1,117 @@
1
+ // Watches the screen's backlight.
2
+ //
3
+ // inotify sees no change to a file under `/sys`, so the watch runs
4
+ // `udevadm monitor` and reads the backlight again on each kernel uevent.
5
+ import { Err, Ok } from "@cprussin/option-result";
6
+ import { SystemErrorKind } from "@domicile-desktop/sdk/system";
7
+ import { levelOf, readBacklight } from "./read-backlight";
8
+ /** Prints a line for each backlight uevent. */
9
+ const UDEVADM = [
10
+ "udevadm",
11
+ "monitor",
12
+ "--kernel",
13
+ "--subsystem-match=backlight",
14
+ ];
15
+ /** How often to read again without a uevent: firmware keys may send none. */
16
+ const BACKSTOP_MS = 120_000;
17
+ /**
18
+ * Calls `onLevel` with the backlight's level from 0 to 1, now and after each
19
+ * change.
20
+ *
21
+ * - Reports only a move of a whole percent, since the slider shows no finer.
22
+ * - Never called on a machine without a backlight. A backlight that goes away
23
+ * and comes back is reported again.
24
+ * - Runs `udevadm` from the compositor's `PATH`. Works while the desktop is
25
+ * locked.
26
+ */
27
+ export const watchBrightness = async (host, onLevel, every = everyInterval) => (await host.spawn(UDEVADM)).map((udevadm) => {
28
+ const watch = {
29
+ failure: undefined,
30
+ reading: Promise.resolve(),
31
+ shown: undefined,
32
+ stopped: false,
33
+ };
34
+ const reread = () => {
35
+ watch.reading = watch.reading.then(() => readOnce(host, watch, udevadm, onLevel));
36
+ };
37
+ reread();
38
+ const stopClock = every(BACKSTOP_MS, reread);
39
+ return {
40
+ ended: ended(udevadm, watch, reread, stopClock),
41
+ stop: () => {
42
+ watch.stopped = true;
43
+ udevadm.kill();
44
+ },
45
+ };
46
+ });
47
+ /** Calls `tick` every `ms`, until the returned function is called. */
48
+ const everyInterval = (ms, tick) => {
49
+ const id = setInterval(tick, ms);
50
+ return () => {
51
+ clearInterval(id);
52
+ };
53
+ };
54
+ const readOnce = async (host, watch, udevadm, onLevel) => {
55
+ const read = await readBacklight(host);
56
+ if (!watch.stopped && watch.failure === undefined) {
57
+ read.match({
58
+ Err: (error) => {
59
+ watch.failure = error;
60
+ udevadm.kill();
61
+ },
62
+ Ok: (backlight) => {
63
+ const level = backlight.map(levelOf);
64
+ const shown = level.match({
65
+ None: () => undefined,
66
+ Some: (now) => Math.round(now * 100),
67
+ });
68
+ if (shown !== watch.shown) {
69
+ watch.shown = shown;
70
+ level.match({ None: () => undefined, Some: onLevel });
71
+ }
72
+ },
73
+ });
74
+ }
75
+ };
76
+ /** Reads `udevadm`'s lines until it exits, then says why it did. */
77
+ const ended = async (udevadm, watch, reread, stopClock) => {
78
+ await eachLine(udevadm.stdout, (line) => {
79
+ if (line.startsWith("KERNEL[")) {
80
+ reread();
81
+ }
82
+ });
83
+ const exit = await udevadm.exited;
84
+ stopClock();
85
+ await watch.reading;
86
+ if (watch.failure !== undefined) {
87
+ return Err(watch.failure);
88
+ }
89
+ else if (watch.stopped) {
90
+ return Ok("stopped");
91
+ }
92
+ else {
93
+ return exit.andThen((exited) => Err(unexpectedExit(exited)));
94
+ }
95
+ };
96
+ const eachLine = async (stream, onLine) => {
97
+ const reader = stream.getReader();
98
+ const decoder = new TextDecoder();
99
+ let unfinished = "";
100
+ for (;;) {
101
+ const { done, value } = await reader.read();
102
+ if (done) {
103
+ break;
104
+ }
105
+ const lines = (unfinished + decoder.decode(value, { stream: true })).split("\n");
106
+ unfinished = lines.pop() ?? "";
107
+ for (const line of lines) {
108
+ onLine(line);
109
+ }
110
+ }
111
+ };
112
+ const unexpectedExit = ({ code, signal }) => ({
113
+ kind: SystemErrorKind.Other,
114
+ message: code === undefined
115
+ ? `udevadm monitor was killed by signal ${signal}`
116
+ : `udevadm monitor exited with code ${code}`,
117
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "dependencies": {
3
+ "@cprussin/option-result": "^2.0.1",
4
+ "@domicile-desktop/sdk": "0.0.0-alpha-a330bf5f3aae",
5
+ "zod": "^4.5.4"
6
+ },
7
+ "description": "Screen brightness for a Domicile shell: reads /sys/class/backlight, watches it and sets it through logind, on @domicile-desktop/sdk/system.",
8
+ "devDependencies": {
9
+ "@cprussin/tsconfig": "^5.0.0",
10
+ "@types/bun": "^1.4.2",
11
+ "typescript": "^7.0.2"
12
+ },
13
+ "exports": {
14
+ "./read-backlight": {
15
+ "types": "./dist/read-backlight.d.ts",
16
+ "default": "./dist/read-backlight.js"
17
+ },
18
+ "./set-brightness": {
19
+ "types": "./dist/set-brightness.d.ts",
20
+ "default": "./dist/set-brightness.js"
21
+ },
22
+ "./watch-brightness": {
23
+ "types": "./dist/watch-brightness.d.ts",
24
+ "default": "./dist/watch-brightness.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist/**"
29
+ ],
30
+ "license": "MIT",
31
+ "name": "@domicile-desktop/system-backlight",
32
+ "private": false,
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "directory": "packages/system-backlight",
38
+ "type": "git",
39
+ "url": "git+https://github.com/cprussin/domicile.git"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "test:types": "tsc --noEmit",
44
+ "test:unit": "bun test"
45
+ },
46
+ "type": "module",
47
+ "version": "0.0.0-alpha-a330bf5f3aae"
48
+ }