@domicile-desktop/system-network 0.0.0-alpha-d3ee6fbb3e64 → 0.0.0-alpha-1fcbde657613

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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  The network for a Domicile shell, from NetworkManager, iwd or wpa_supplicant:
4
4
  connectivity and the primary connection, with the Wi-Fi network's name and
5
- signal. Built on [`@domicile-desktop/sdk/system`](../chrome-sdk/README.md).
5
+ signal. The Wi-Fi device, from NetworkManager or iwd: its networks and
6
+ connection, and the requests that change them. Built on
7
+ [`@domicile-desktop/sdk/system`](../chrome-sdk/README.md).
6
8
 
7
9
  ## Usage
8
10
 
@@ -37,6 +39,37 @@ const stop = watchNetwork(system(domicile), (network) => {
37
39
  - An `Err` is a D-Bus failure, such as the service not running.
38
40
  - A bug or an unexpected reply shape throws, logged to the console.
39
41
 
42
+ ## Wi-Fi
43
+
44
+ ```ts
45
+ import { connectWifi, watchWifi } from "@domicile-desktop/system-network/wifi";
46
+
47
+ const stop = watchWifi(host, (wifi) => {
48
+ wifi.match({
49
+ Err: () => hide(),
50
+ Ok: (found) => found.match({ None: () => hide(), Some: (device) => show(device) }),
51
+ });
52
+ });
53
+ await connectWifi(host, device, network, "passphrase");
54
+ ```
55
+
56
+ - `watchWifi` watches the first Wi-Fi device of NetworkManager, else iwd, by
57
+ `NameHasOwner`. `None` is no device, or neither service. wpa_supplicant is
58
+ not controlled.
59
+ - Requests: `setWifiEnabled`, `scanWifi`, `connectWifi`, `disconnectWifi`.
60
+ Each resolves `Ok` once the service accepts it; the watch reports the
61
+ outcome.
62
+ - `connectWifi` uses a network's saved profile when it has one. A new secured
63
+ network needs a passphrase:
64
+ - NetworkManager: a new WPA-PSK profile, saved.
65
+ - iwd: `iwctl --passphrase … station <interface> connect <ssid>`, since iwd
66
+ asks an agent for it and a page cannot serve one. `iwctl` must be on the
67
+ compositor's `PATH`.
68
+ - NetworkManager reads re-run on each property change of an object read, an
69
+ access point coming or going, or a device coming or going. It does not say
70
+ when a scan runs, so `scanning` is always `false`.
71
+ - iwd does not report addresses, so `connection.ip` is `undefined`.
72
+
40
73
  See [SHELL-SYSTEM-ACCESS.md](/docs/SHELL-SYSTEM-ACCESS.md).
41
74
 
42
75
  ## Test
package/dist/follow.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import type { Result } from "@cprussin/option-result";
2
2
  import type { DbusMatch, DbusSignal, SystemError } from "@domicile-desktop/sdk/system";
3
- import type { Network, NetworkSystem } from "./network-state";
3
+ import type { NetworkSystem } from "./network-state";
4
4
  /** What one read found, and which signals can change it. */
5
- export type Reading = {
6
- network: Result<Network, SystemError>;
5
+ export type Reading<T extends NonNullable<unknown>> = {
6
+ value: Result<T, SystemError>;
7
7
  matters: (signal: DbusSignal) => boolean;
8
8
  };
9
9
  /**
10
- * Calls `onNetwork` with what `read` finds now and after each signal that
10
+ * Calls `onValue` with what `read` finds now and after each signal that
11
11
  * matters, and returns a function that stops watching.
12
12
  */
13
- export declare const followNetwork: (system: NetworkSystem, match: DbusMatch, read: () => Promise<Reading>, onNetwork: (network: Result<Network, SystemError>) => void) => (() => void);
13
+ export declare const followBus: <T extends NonNullable<unknown>>(system: NetworkSystem, match: DbusMatch, read: () => Promise<Reading<T>>, onValue: (value: Result<T, SystemError>) => void) => (() => void);
package/dist/follow.js CHANGED
@@ -1,15 +1,15 @@
1
- // The loop each backend runs: listen, read, then read again on each signal
2
- // that can change what was read.
1
+ // The loop each watch runs: listen, read, then read again on each signal that
2
+ // can change what was read.
3
3
  import { Err } from "@cprussin/option-result";
4
4
  /**
5
- * Calls `onNetwork` with what `read` finds now and after each signal that
5
+ * Calls `onValue` with what `read` finds now and after each signal that
6
6
  * matters, and returns a function that stops watching.
7
7
  */
8
- export const followNetwork = (system, match, read, onNetwork) => {
8
+ export const followBus = (system, match, read, onValue) => {
9
9
  const watch = { listening: undefined, stopped: false };
10
- const report = (network) => {
10
+ const report = (value) => {
11
11
  if (!watch.stopped) {
12
- onNetwork(network);
12
+ onValue(value);
13
13
  }
14
14
  };
15
15
  follow(system, match, read, watch, report).catch((error) => {
@@ -50,12 +50,12 @@ const follow = async (system, match, read, watch, report) => {
50
50
  const changes = async (listening, read, report) => {
51
51
  const reader = listening.items.getReader();
52
52
  const first = await read();
53
- report(first.network);
53
+ report(first.value);
54
54
  let matters = first.matters;
55
55
  for (let next = await reader.read(); !next.done; next = await reader.read()) {
56
56
  if (matters(next.value)) {
57
57
  const again = await read();
58
- report(again.network);
58
+ report(again.value);
59
59
  matters = again.matters;
60
60
  }
61
61
  }
@@ -0,0 +1,8 @@
1
+ import type { Result } from "@cprussin/option-result";
2
+ import type { SystemError } from "@domicile-desktop/sdk/system";
3
+ import type { NetworkSystem } from "./network-state";
4
+ /**
5
+ * Whether `name` has an owner on the system bus. `NameHasOwner` does not start
6
+ * a service that D-Bus can activate, as a call to the service would.
7
+ */
8
+ export declare const hasOwner: (system: NetworkSystem, name: string) => Promise<Result<boolean, SystemError>>;
@@ -0,0 +1,15 @@
1
+ import { Bus } from "@domicile-desktop/sdk/system";
2
+ import { z } from "zod";
3
+ /**
4
+ * Whether `name` has an owner on the system bus. `NameHasOwner` does not start
5
+ * a service that D-Bus can activate, as a call to the service would.
6
+ */
7
+ export const hasOwner = async (system, name) => (await system.dbusCall({
8
+ body: [name],
9
+ bus: Bus.System,
10
+ destination: "org.freedesktop.DBus",
11
+ interface: "org.freedesktop.DBus",
12
+ member: "NameHasOwner",
13
+ path: "/org/freedesktop/DBus",
14
+ signature: "s",
15
+ })).map(({ body }) => z.tuple([z.boolean()]).parse(body)[0]);
@@ -0,0 +1,24 @@
1
+ import type { Option, Result } from "@cprussin/option-result";
2
+ import type { SystemError } from "@domicile-desktop/sdk/system";
3
+ import type { NetworkSystem } from "./network-state";
4
+ import type { Wifi, WifiDevice, WifiNetwork, WifiSystem } from "./wifi-state";
5
+ /**
6
+ * Calls `onWifi` with the first station device now and after each change, and
7
+ * returns a function that stops watching. `None` is a machine without one.
8
+ *
9
+ * An `Err` is a D-Bus failure, such as iwd not running. The watch keeps
10
+ * listening after one, so a later change reads again.
11
+ */
12
+ export declare const watchIwdWifi: (system: NetworkSystem, onWifi: (wifi: Result<Option<Wifi>, SystemError>) => void) => (() => void);
13
+ /** Turn the device on or off. */
14
+ export declare const setIwdWifiEnabled: (system: NetworkSystem, wifi: WifiDevice, enabled: boolean) => Promise<Result<"done", SystemError>>;
15
+ /** Ask for a scan. Networks it finds arrive as changes. */
16
+ export declare const scanIwdWifi: (system: NetworkSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
17
+ /**
18
+ * Join `network`. A known or open network joins over D-Bus. A new secured one
19
+ * joins through `iwctl`, which answers iwd's request for the passphrase: iwd
20
+ * asks an agent for it, and a page cannot serve one.
21
+ */
22
+ export declare const connectIwdWifi: (system: WifiSystem, wifi: WifiDevice, network: WifiNetwork, passphrase: string | undefined) => Promise<Result<"done", SystemError>>;
23
+ /** Leave the current network, until asked to join one. */
24
+ export declare const disconnectIwdWifi: (system: NetworkSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
@@ -0,0 +1,204 @@
1
+ // iwd's Wi-Fi device over the system bus: its networks and connection, kept
2
+ // current, and the requests that change them. See docs/SHELL-SYSTEM-ACCESS.md.
3
+ import { Err, None, Ok, Some } from "@cprussin/option-result";
4
+ import { Bus, SystemErrorKind } from "@domicile-desktop/sdk/system";
5
+ import { z } from "zod";
6
+ import { followBus } from "./follow";
7
+ import { strengthOfDbm } from "./signal-strength";
8
+ import { variant } from "./variant";
9
+ import { WifiBackend } from "./wifi-state";
10
+ const SERVICE = "net.connman.iwd";
11
+ const DEVICE = `${SERVICE}.Device`;
12
+ const STATION = `${SERVICE}.Station`;
13
+ const NETWORK = `${SERVICE}.Network`;
14
+ const DIAGNOSTIC = `${SERVICE}.StationDiagnostic`;
15
+ /** The interfaces whose property changes alter a {@link Wifi}. */
16
+ const WATCHED = [DEVICE, STATION, NETWORK];
17
+ /**
18
+ * Calls `onWifi` with the first station device now and after each change, and
19
+ * returns a function that stops watching. `None` is a machine without one.
20
+ *
21
+ * An `Err` is a D-Bus failure, such as iwd not running. The watch keeps
22
+ * listening after one, so a later change reads again.
23
+ */
24
+ export const watchIwdWifi = (system, onWifi) => followBus(system, { bus: Bus.System, sender: SERVICE }, async () => ({ matters, value: await read(system) }), onWifi);
25
+ /** Turn the device on or off. */
26
+ export const setIwdWifiEnabled = async (system, wifi, enabled) => done(await system.dbusCall({
27
+ ...request(wifi.device, "org.freedesktop.DBus.Properties", "Set"),
28
+ body: [DEVICE, "Powered", { signature: "b", value: enabled }],
29
+ signature: "ssv",
30
+ }));
31
+ /** Ask for a scan. Networks it finds arrive as changes. */
32
+ export const scanIwdWifi = async (system, wifi) => done(await system.dbusCall(request(wifi.device, STATION, "Scan")));
33
+ /**
34
+ * Join `network`. A known or open network joins over D-Bus. A new secured one
35
+ * joins through `iwctl`, which answers iwd's request for the passphrase: iwd
36
+ * asks an agent for it, and a page cannot serve one.
37
+ */
38
+ export const connectIwdWifi = async (system, wifi, network, passphrase) => {
39
+ if (network.profile !== undefined || !network.secured) {
40
+ return done(await system.dbusCall(request(network.path, NETWORK, "Connect")));
41
+ }
42
+ else if (passphrase === undefined) {
43
+ throw new Error(`joining ${network.ssid} needs a passphrase`);
44
+ }
45
+ else {
46
+ const ran = await system.run([
47
+ "iwctl",
48
+ "--passphrase",
49
+ passphrase,
50
+ "station",
51
+ wifi.interface,
52
+ "connect",
53
+ network.ssid,
54
+ ]);
55
+ return ran.andThen(({ code, stderr, stdout }) => code === 0
56
+ ? Ok("done")
57
+ : Err({
58
+ kind: SystemErrorKind.Other,
59
+ message: `${stdout}${stderr}`.trim(),
60
+ }));
61
+ }
62
+ };
63
+ /** Leave the current network, until asked to join one. */
64
+ export const disconnectIwdWifi = async (system, wifi) => done(await system.dbusCall(request(wifi.device, STATION, "Disconnect")));
65
+ const request = (path, iface, member) => ({
66
+ bus: Bus.System,
67
+ destination: SERVICE,
68
+ interface: iface,
69
+ member,
70
+ path,
71
+ });
72
+ const done = (reply) => reply.map(() => "done");
73
+ /** Whether `signal` can change Wi-Fi: an object coming or going, or a property. */
74
+ const matters = (signal) => {
75
+ switch (signal.member) {
76
+ case "InterfacesAdded":
77
+ case "InterfacesRemoved": {
78
+ return true;
79
+ }
80
+ case "PropertiesChanged": {
81
+ const [iface] = propertiesChangedSchema.parse(signal.body);
82
+ return WATCHED.includes(iface);
83
+ }
84
+ default: {
85
+ return false;
86
+ }
87
+ }
88
+ };
89
+ const read = async (system) => (await system.dbusCall({
90
+ bus: Bus.System,
91
+ destination: SERVICE,
92
+ interface: "org.freedesktop.DBus.ObjectManager",
93
+ member: "GetManagedObjects",
94
+ path: "/",
95
+ })).andThenAsync(async ({ body }) => {
96
+ const objects = managedObjectsSchema.parse(body)[0];
97
+ const station = Object.entries(objects).flatMap(([path, held]) => {
98
+ const device = held[DEVICE];
99
+ return device?.Mode === "station" ? [{ device, held, path }] : [];
100
+ })[0];
101
+ return station === undefined
102
+ ? Ok(None())
103
+ : (await wifiOf(system, objects, station.path, station.device, station.held)).map((wifi) => Some(wifi));
104
+ });
105
+ const wifiOf = async (system, objects, path, device, held) => {
106
+ const off = {
107
+ backend: WifiBackend.Iwd,
108
+ connection: undefined,
109
+ device: path,
110
+ enabled: device.Powered,
111
+ hardwareAddress: device.Address,
112
+ interface: device.Name,
113
+ networks: [],
114
+ scanning: false,
115
+ };
116
+ const station = held[STATION];
117
+ return station === undefined
118
+ ? Ok(off)
119
+ : (await stationOf(system, objects, path, station, held[DIAGNOSTIC])).map((found) => ({ ...off, ...found }));
120
+ };
121
+ const stationOf = async (system, objects, path, station, diagnostic) => (await system.dbusCall(request(path, STATION, "GetOrderedNetworks"))).andThenAsync(async ({ body }) => {
122
+ const networks = orderedNetworksSchema
123
+ .parse(body)[0]
124
+ .map(([network, signal]) => networkOf(objects, network, signal));
125
+ const connected = networks.find(({ connected: on }) => on);
126
+ const connection = connected === undefined
127
+ ? Ok(None())
128
+ : (await diagnosticsOf(system, path, diagnostic)).map((found) => Some({
129
+ ...found,
130
+ ip: undefined,
131
+ ssid: connected.ssid,
132
+ strength: connected.strength,
133
+ }));
134
+ return connection.map((current) => ({
135
+ connection: current.match({
136
+ None: () => undefined,
137
+ Some: (found) => found,
138
+ }),
139
+ networks,
140
+ scanning: station.Scanning,
141
+ }));
142
+ });
143
+ const networkOf = (objects, path, signal) => {
144
+ const network = objects[path]?.[NETWORK];
145
+ if (network === undefined) {
146
+ throw new Error(`iwd ordered a network it does not have: ${path}`);
147
+ }
148
+ else {
149
+ return {
150
+ connected: network.Connected,
151
+ path,
152
+ profile: network.KnownNetwork,
153
+ secured: network.Type !== "open",
154
+ ssid: network.Name,
155
+ strength: strengthOfDbm(signal / 100),
156
+ };
157
+ }
158
+ };
159
+ /** The link's frequency and bitrate, when iwd offers diagnostics. */
160
+ const diagnosticsOf = async (system, path, diagnostic) => diagnostic === undefined
161
+ ? Ok({ bitrate: undefined, frequency: undefined })
162
+ : (await system.dbusCall(request(path, DIAGNOSTIC, "GetDiagnostics"))).map(({ body }) => {
163
+ const [found] = diagnosticsSchema.parse(body);
164
+ return { bitrate: found.RxBitrate / 10, frequency: found.Frequency };
165
+ });
166
+ const stationSchema = z.object({ Scanning: variant(z.boolean()) });
167
+ const managedObjectsSchema = z.tuple([
168
+ z.record(z.string(), z.object({
169
+ [DEVICE]: z
170
+ .object({
171
+ Address: variant(z.string()),
172
+ Mode: variant(z.string()),
173
+ Name: variant(z.string()),
174
+ Powered: variant(z.boolean()),
175
+ })
176
+ .optional(),
177
+ [DIAGNOSTIC]: z.object({}).optional(),
178
+ [NETWORK]: z
179
+ .object({
180
+ Connected: variant(z.boolean()),
181
+ KnownNetwork: variant(z.string()).optional(),
182
+ Name: variant(z.string()),
183
+ Type: variant(z.string()),
184
+ })
185
+ .optional(),
186
+ [STATION]: stationSchema.optional(),
187
+ })),
188
+ ]);
189
+ /** Each network's path and signal, in 100 * dBm. */
190
+ const orderedNetworksSchema = z.tuple([
191
+ z.array(z.tuple([z.string(), z.number()])),
192
+ ]);
193
+ /** `RxBitrate` is in 100 kbit/s. */
194
+ const diagnosticsSchema = z.tuple([
195
+ z.object({
196
+ Frequency: variant(z.number()),
197
+ RxBitrate: variant(z.number()),
198
+ }),
199
+ ]);
200
+ const propertiesChangedSchema = z.tuple([
201
+ z.string(),
202
+ z.record(z.string(), z.unknown()),
203
+ z.array(z.string()),
204
+ ]);
package/dist/iwd.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Err, Ok } from "@cprussin/option-result";
4
4
  import { Bus } from "@domicile-desktop/sdk/system";
5
5
  import { z } from "zod";
6
- import { followNetwork } from "./follow";
6
+ import { followBus } from "./follow";
7
7
  import { Connectivity, Link } from "./network-state";
8
8
  import { strengthOfDbm } from "./signal-strength";
9
9
  import { variant } from "./variant";
@@ -24,7 +24,7 @@ const WATCHED = ["ConnectedNetwork", "Scanning", "State"];
24
24
  * not running. The watch keeps listening after one, so a later change reads
25
25
  * again.
26
26
  */
27
- export const watchIwd = (system, onNetwork) => followNetwork(system, { bus: Bus.System, sender: SERVICE }, async () => ({ matters, network: await read(system) }), onNetwork);
27
+ export const watchIwd = (system, onNetwork) => followBus(system, { bus: Bus.System, sender: SERVICE }, async () => ({ matters, value: await read(system) }), onNetwork);
28
28
  /**
29
29
  * Whether `signal` can change the network: an object coming or going, or a
30
30
  * watched station property.
package/dist/network.js CHANGED
@@ -3,8 +3,7 @@
3
3
  // types, which live in `network-state` so the backends can import them
4
4
  // without a cycle. See docs/SHELL-SYSTEM-ACCESS.md.
5
5
  import { Err, Ok } from "@cprussin/option-result";
6
- import { Bus } from "@domicile-desktop/sdk/system";
7
- import { z } from "zod";
6
+ import { hasOwner } from "./has-owner";
8
7
  import { watchIwd } from "./iwd";
9
8
  import { watchNetworkManager } from "./networkmanager";
10
9
  import { watchWpaSupplicant } from "./wpa-supplicant";
@@ -63,16 +62,3 @@ const detect = async (system, backends) => {
63
62
  });
64
63
  }
65
64
  };
66
- /**
67
- * Whether `name` has an owner. `NameHasOwner` does not start a service that
68
- * D-Bus can activate, as a call to the service would.
69
- */
70
- const hasOwner = async (system, name) => (await system.dbusCall({
71
- body: [name],
72
- bus: Bus.System,
73
- destination: "org.freedesktop.DBus",
74
- interface: "org.freedesktop.DBus",
75
- member: "NameHasOwner",
76
- path: "/org/freedesktop/DBus",
77
- signature: "s",
78
- })).map(({ body }) => z.tuple([z.boolean()]).parse(body)[0]);
@@ -0,0 +1,27 @@
1
+ import type { Option } from "@cprussin/option-result";
2
+ import { Result } from "@cprussin/option-result";
3
+ import type { SystemError } from "@domicile-desktop/sdk/system";
4
+ import type { NetworkSystem } from "./network-state";
5
+ import type { Wifi, WifiDevice, WifiNetwork } from "./wifi-state";
6
+ /** A read's outcome: Wi-Fi, if there is a device, or the first failure. */
7
+ type Found = Result<Option<Wifi>, SystemError>;
8
+ /**
9
+ * Calls `onWifi` with the first Wi-Fi device now and after each change, and
10
+ * returns a function that stops watching. `None` is a machine without one.
11
+ *
12
+ * An `Err` is a D-Bus failure, such as NetworkManager not running. The watch
13
+ * keeps listening after one, so a later change reads again.
14
+ */
15
+ export declare const watchNetworkManagerWifi: (system: NetworkSystem, onWifi: (wifi: Found) => void) => (() => void);
16
+ /** Turn the Wi-Fi radio on or off. */
17
+ export declare const setNetworkManagerWifiEnabled: (system: NetworkSystem, _wifi: WifiDevice, enabled: boolean) => Promise<Result<"done", SystemError>>;
18
+ /** Ask for a scan. Networks it finds arrive as changes. */
19
+ export declare const scanNetworkManagerWifi: (system: NetworkSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
20
+ /**
21
+ * Join `network`: through its saved profile if it has one, or with a new
22
+ * profile, which needs `passphrase` when the network is secured.
23
+ */
24
+ export declare const connectNetworkManagerWifi: (system: NetworkSystem, wifi: WifiDevice, network: WifiNetwork, passphrase: string | undefined) => Promise<Result<"done", SystemError>>;
25
+ /** Leave the current network, until asked to join one. */
26
+ export declare const disconnectNetworkManagerWifi: (system: NetworkSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
27
+ export {};
@@ -0,0 +1,257 @@
1
+ // NetworkManager's Wi-Fi device over the system bus: its networks and
2
+ // connection, kept current, and the requests that change them. See
3
+ // docs/SHELL-SYSTEM-ACCESS.md.
4
+ import { None, Ok, Result, Some } from "@cprussin/option-result";
5
+ import { Bus } from "@domicile-desktop/sdk/system";
6
+ import { z } from "zod";
7
+ import { followBus } from "./follow";
8
+ import { variant } from "./variant";
9
+ import { WifiBackend } from "./wifi-state";
10
+ const SERVICE = "org.freedesktop.NetworkManager";
11
+ const ROOT = "/org/freedesktop/NetworkManager";
12
+ const DEVICE = `${SERVICE}.Device`;
13
+ const WIRELESS = `${SERVICE}.Device.Wireless`;
14
+ /** The object path NetworkManager uses for "none". */
15
+ const NO_OBJECT = "/";
16
+ /** `NM_DEVICE_TYPE_WIFI`. */
17
+ const WIFI_DEVICE = 2;
18
+ /**
19
+ * Calls `onWifi` with the first Wi-Fi device now and after each change, and
20
+ * returns a function that stops watching. `None` is a machine without one.
21
+ *
22
+ * An `Err` is a D-Bus failure, such as NetworkManager not running. The watch
23
+ * keeps listening after one, so a later change reads again.
24
+ */
25
+ export const watchNetworkManagerWifi = (system, onWifi) => followBus(system, { bus: Bus.System, sender: SERVICE }, () => read(system), onWifi);
26
+ /** Turn the Wi-Fi radio on or off. */
27
+ export const setNetworkManagerWifiEnabled = async (system, _wifi, enabled) => done(await system.dbusCall(request(ROOT, "org.freedesktop.DBus.Properties", "Set", "ssv", [
28
+ SERVICE,
29
+ "WirelessEnabled",
30
+ { signature: "b", value: enabled },
31
+ ])));
32
+ /** Ask for a scan. Networks it finds arrive as changes. */
33
+ export const scanNetworkManagerWifi = async (system, wifi) => done(await system.dbusCall(request(wifi.device, WIRELESS, "RequestScan", "a{sv}", [{}])));
34
+ /**
35
+ * Join `network`: through its saved profile if it has one, or with a new
36
+ * profile, which needs `passphrase` when the network is secured.
37
+ */
38
+ export const connectNetworkManagerWifi = async (system, wifi, network, passphrase) => done(await system.dbusCall(network.profile === undefined
39
+ ? request(ROOT, SERVICE, "AddAndActivateConnection", "a{sa{sv}}oo", [
40
+ newProfile(network, passphrase),
41
+ wifi.device,
42
+ network.path,
43
+ ])
44
+ : request(ROOT, SERVICE, "ActivateConnection", "ooo", [
45
+ network.profile,
46
+ wifi.device,
47
+ network.path,
48
+ ])));
49
+ /** Leave the current network, until asked to join one. */
50
+ export const disconnectNetworkManagerWifi = async (system, wifi) => done(await system.dbusCall({
51
+ bus: Bus.System,
52
+ destination: SERVICE,
53
+ interface: DEVICE,
54
+ member: "Disconnect",
55
+ path: wifi.device,
56
+ }));
57
+ const request = (path, iface, member, signature, body) => ({
58
+ body,
59
+ bus: Bus.System,
60
+ destination: SERVICE,
61
+ interface: iface,
62
+ member,
63
+ path,
64
+ signature,
65
+ });
66
+ const done = (reply) => reply.map(() => "done");
67
+ /** The settings NetworkManager fills in a profile from, past the access point. */
68
+ const newProfile = (network, passphrase) => {
69
+ if (!network.secured) {
70
+ return {};
71
+ }
72
+ else if (passphrase === undefined) {
73
+ throw new Error(`joining ${network.ssid} needs a passphrase`);
74
+ }
75
+ else {
76
+ return {
77
+ "802-11-wireless-security": {
78
+ "key-mgmt": { signature: "s", value: "wpa-psk" },
79
+ psk: { signature: "s", value: passphrase },
80
+ },
81
+ };
82
+ }
83
+ };
84
+ /**
85
+ * Read Wi-Fi. A property change on an object read for it matters, as does an
86
+ * access point or a device coming or going.
87
+ */
88
+ const read = async (system) => {
89
+ const paths = new Set([ROOT]);
90
+ const value = await readWifi(system, paths);
91
+ return { matters: (signal) => matters(paths, signal), value };
92
+ };
93
+ const matters = (paths, signal) => {
94
+ switch (signal.member) {
95
+ case "AccessPointAdded":
96
+ case "AccessPointRemoved":
97
+ case "DeviceAdded":
98
+ case "DeviceRemoved": {
99
+ return true;
100
+ }
101
+ case "PropertiesChanged": {
102
+ return (signal.interface === "org.freedesktop.DBus.Properties" &&
103
+ paths.has(signal.path));
104
+ }
105
+ default: {
106
+ return false;
107
+ }
108
+ }
109
+ };
110
+ /** Read Wi-Fi, adding each object read to `paths`. */
111
+ const readWifi = async (system, paths) => (await properties(system, ROOT, SERVICE, rootSchema)).andThenAsync(async ({ WirelessEnabled: enabled }) => (await call(system, ROOT, SERVICE, "GetDevices")).andThenAsync(async ({ body }) => {
112
+ const listed = devicesSchema.parse(body)[0];
113
+ for (const path of listed) {
114
+ paths.add(path);
115
+ }
116
+ const devices = Result.collect(await Promise.all(listed.map(async (path) => (await properties(system, path, DEVICE, deviceSchema)).map((device) => ({ device, path })))));
117
+ return devices.andThenAsync(async (read) => {
118
+ const found = read.find(({ device }) => device.DeviceType === WIFI_DEVICE);
119
+ return found === undefined
120
+ ? Ok(None())
121
+ : (await wifiOf(system, paths, found.path, found.device, enabled)).map((wifi) => Some(wifi));
122
+ });
123
+ }));
124
+ /** The Wi-Fi device at `path`. */
125
+ const wifiOf = async (system, paths, path, device, enabled) => (await properties(system, path, WIRELESS, wirelessSchema)).andThenAsync(async (radio) => {
126
+ for (const point of radio.AccessPoints) {
127
+ paths.add(point);
128
+ }
129
+ paths.add(device.Ip4Config);
130
+ const [points, profiles, ip] = await Promise.all([
131
+ accessPoints(system, radio.AccessPoints),
132
+ savedProfiles(system, device.AvailableConnections),
133
+ radio.ActiveAccessPoint === NO_OBJECT || device.Ip4Config === NO_OBJECT
134
+ ? Promise.resolve(Ok(None()))
135
+ : ipOf(system, device.Ip4Config),
136
+ ]);
137
+ return points.andThen((read) => profiles.andThen((saved) => ip.map((address) => ({
138
+ backend: WifiBackend.NetworkManager,
139
+ connection: connectionOf(read, radio, address),
140
+ device: path,
141
+ enabled,
142
+ hardwareAddress: device.HwAddress,
143
+ interface: device.Interface,
144
+ networks: networksOf(read, saved, radio.ActiveAccessPoint),
145
+ scanning: false,
146
+ }))));
147
+ });
148
+ const accessPoints = async (system, listed) => Result.collect(await Promise.all(listed.map(async (path) => (await properties(system, path, `${SERVICE}.AccessPoint`, accessPointSchema)).map((point) => ({ ...point, path })))));
149
+ /** Each saved Wi-Fi profile's path, by its network's name. */
150
+ const savedProfiles = async (system, listed) => Result.collect(await Promise.all(listed.map(async (path) => (await call(system, path, `${SERVICE}.Settings.Connection`, "GetSettings")).map(({ body }) => ({
151
+ path,
152
+ settings: settingsSchema.parse(body)[0],
153
+ }))))).map((saved) => new Map(saved.flatMap(({ path, settings }) => {
154
+ const ssid = settings["802-11-wireless"]?.ssid;
155
+ return ssid === undefined ? [] : [[ssid, path]];
156
+ })));
157
+ const ipOf = async (system, path) => (await properties(system, path, `${SERVICE}.IP4Config`, ip4Schema)).map((config) => Some({
158
+ addresses: config.AddressData.map(({ address, prefix }) => `${address}/${prefix}`),
159
+ dns: config.NameserverData.map(({ address }) => address),
160
+ gateway: config.Gateway === "" ? undefined : config.Gateway,
161
+ }));
162
+ /** The named networks, one per name: its strongest access point. */
163
+ const networksOf = (points, profiles, active) => {
164
+ const byName = new Map();
165
+ for (const point of points.filter(({ Ssid }) => Ssid !== "")) {
166
+ byName.set(point.Ssid, [...(byName.get(point.Ssid) ?? []), point]);
167
+ }
168
+ return [...byName.values()]
169
+ .map((named) => {
170
+ const [strongest] = named.toSorted((a, b) => b.Strength - a.Strength);
171
+ if (strongest === undefined) {
172
+ throw new Error("a network with no access point");
173
+ }
174
+ else {
175
+ return {
176
+ connected: named.some(({ path }) => path === active),
177
+ path: strongest.path,
178
+ profile: profiles.get(strongest.Ssid),
179
+ secured: isSecured(strongest),
180
+ ssid: strongest.Ssid,
181
+ strength: strongest.Strength / 100,
182
+ };
183
+ }
184
+ })
185
+ .toSorted((a, b) => b.strength - a.strength);
186
+ };
187
+ const connectionOf = (points, radio, ip) => {
188
+ const active = points.find(({ path }) => path === radio.ActiveAccessPoint);
189
+ return active === undefined
190
+ ? undefined
191
+ : {
192
+ bitrate: radio.Bitrate / 1000,
193
+ frequency: active.Frequency,
194
+ ip: ip.match({ None: () => undefined, Some: (address) => address }),
195
+ ssid: active.Ssid,
196
+ strength: active.Strength / 100,
197
+ };
198
+ };
199
+ /** `NM_802_11_AP_FLAGS_PRIVACY`, or any WPA or RSN flag. */
200
+ const isSecured = (point) => (point.Flags & 1) !== 0 || point.WpaFlags !== 0 || point.RsnFlags !== 0;
201
+ const call = (system, path, iface, member) => system.dbusCall({
202
+ bus: Bus.System,
203
+ destination: SERVICE,
204
+ interface: iface,
205
+ member,
206
+ path,
207
+ });
208
+ /** `Properties.GetAll` on one of NetworkManager's objects, parsed. */
209
+ const properties = async (system, path, interfaceName, schema) => (await system.dbusCall({
210
+ body: [interfaceName],
211
+ bus: Bus.System,
212
+ destination: SERVICE,
213
+ interface: "org.freedesktop.DBus.Properties",
214
+ member: "GetAll",
215
+ path,
216
+ signature: "s",
217
+ })).map(({ body }) => z.tuple([schema]).parse(body)[0]);
218
+ /** An SSID's bytes, as text. */
219
+ const ssid = z
220
+ .array(z.number())
221
+ .transform((bytes) => new TextDecoder().decode(new Uint8Array(bytes)));
222
+ const rootSchema = z.object({ WirelessEnabled: variant(z.boolean()) });
223
+ const devicesSchema = z.tuple([z.array(z.string())]);
224
+ const deviceSchema = z.object({
225
+ AvailableConnections: variant(z.array(z.string())),
226
+ DeviceType: variant(z.number()),
227
+ HwAddress: variant(z.string()),
228
+ Interface: variant(z.string()),
229
+ Ip4Config: variant(z.string()),
230
+ });
231
+ const wirelessSchema = z.object({
232
+ AccessPoints: variant(z.array(z.string())),
233
+ ActiveAccessPoint: variant(z.string()),
234
+ /** In kbit/s. */
235
+ Bitrate: variant(z.number()),
236
+ });
237
+ const accessPointSchema = z.object({
238
+ Flags: variant(z.number()),
239
+ Frequency: variant(z.number()),
240
+ RsnFlags: variant(z.number()),
241
+ Ssid: variant(ssid),
242
+ Strength: variant(z.number()),
243
+ WpaFlags: variant(z.number()),
244
+ });
245
+ const settingsSchema = z.tuple([
246
+ z.object({
247
+ "802-11-wireless": z.object({ ssid: variant(ssid).optional() }).optional(),
248
+ }),
249
+ ]);
250
+ const ip4Schema = z.object({
251
+ AddressData: variant(z.array(z.object({
252
+ address: variant(z.string()),
253
+ prefix: variant(z.number()),
254
+ }))),
255
+ Gateway: variant(z.string()),
256
+ NameserverData: variant(z.array(z.object({ address: variant(z.string()) }))),
257
+ });
@@ -3,7 +3,7 @@
3
3
  import { Err, Ok } from "@cprussin/option-result";
4
4
  import { Bus } from "@domicile-desktop/sdk/system";
5
5
  import { z } from "zod";
6
- import { followNetwork } from "./follow";
6
+ import { followBus } from "./follow";
7
7
  import { Connectivity, Link } from "./network-state";
8
8
  import { variant } from "./variant";
9
9
  const SERVICE = "org.freedesktop.NetworkManager";
@@ -17,7 +17,7 @@ const NO_OBJECT = "/";
17
17
  * An `Err` is a D-Bus failure, such as NetworkManager not running. The watch
18
18
  * keeps listening after one, so a later change reads again.
19
19
  */
20
- export const watchNetworkManager = (system, onNetwork) => followNetwork(system, {
20
+ export const watchNetworkManager = (system, onNetwork) => followBus(system, {
21
21
  bus: Bus.System,
22
22
  interface: "org.freedesktop.DBus.Properties",
23
23
  member: "PropertiesChanged",
@@ -29,14 +29,14 @@ const read = async (system) => {
29
29
  return root.match({
30
30
  Err: (error) => Promise.resolve({
31
31
  matters: ({ path }) => path === ROOT,
32
- network: Err(error),
32
+ value: Err(error),
33
33
  }),
34
34
  Ok: async ({ Connectivity: connectivity, PrimaryConnection: primary }) => {
35
35
  const link = await linkOf(system, primary);
36
36
  const paths = new Set([ROOT, ...link.paths]);
37
37
  return {
38
38
  matters: ({ path }) => paths.has(path),
39
- network: link.result.map((found) => ({
39
+ value: link.result.map((found) => ({
40
40
  connectivity,
41
41
  link: found,
42
42
  })),
@@ -0,0 +1,54 @@
1
+ import type { System } from "@domicile-desktop/sdk/system";
2
+ /** The service that manages the Wi-Fi device. */
3
+ export declare enum WifiBackend {
4
+ NetworkManager = 0,
5
+ Iwd = 1
6
+ }
7
+ /** A network in range. */
8
+ export type WifiNetwork = {
9
+ /** The service's object for it: an access point (NetworkManager) or a network (iwd). */
10
+ path: string;
11
+ ssid: string;
12
+ /** 0 through 1. */
13
+ strength: number;
14
+ secured: boolean;
15
+ /** The saved profile: a connection (NetworkManager) or a known network (iwd). */
16
+ profile: string | undefined;
17
+ connected: boolean;
18
+ };
19
+ /** The addresses the service configured. */
20
+ export type Ip = {
21
+ /** Each with its prefix, as `192.168.1.23/24`. */
22
+ addresses: string[];
23
+ gateway: string | undefined;
24
+ dns: string[];
25
+ };
26
+ export type WifiConnection = {
27
+ ssid: string;
28
+ /** 0 through 1. */
29
+ strength: number;
30
+ /** In MHz. */
31
+ frequency: number | undefined;
32
+ /** In Mbit/s. */
33
+ bitrate: number | undefined;
34
+ /** `undefined` from iwd, which does not report addresses over D-Bus. */
35
+ ip: Ip | undefined;
36
+ };
37
+ export type Wifi = {
38
+ backend: WifiBackend;
39
+ /** The device's object, which requests address. */
40
+ device: string;
41
+ interface: string;
42
+ hardwareAddress: string;
43
+ /** Whether the radio is on. */
44
+ enabled: boolean;
45
+ /** Whether a scan is running. Always `false` from NetworkManager, which does not say. */
46
+ scanning: boolean;
47
+ /** One per name, strongest first. Hidden networks are left out. */
48
+ networks: WifiNetwork[];
49
+ connection: WifiConnection | undefined;
50
+ };
51
+ /** What a request needs of the device. */
52
+ export type WifiDevice = Pick<Wifi, "backend" | "device" | "interface">;
53
+ /** The calls this library makes for Wi-Fi. */
54
+ export type WifiSystem = Pick<System, "dbusCall" | "dbusMatch" | "run">;
@@ -0,0 +1,8 @@
1
+ // The Wi-Fi device as every backend reports it: its networks and its
2
+ // connection. See docs/SHELL-SYSTEM-ACCESS.md.
3
+ /** The service that manages the Wi-Fi device. */
4
+ export var WifiBackend;
5
+ (function (WifiBackend) {
6
+ WifiBackend[WifiBackend["NetworkManager"] = 0] = "NetworkManager";
7
+ WifiBackend[WifiBackend["Iwd"] = 1] = "Iwd";
8
+ })(WifiBackend || (WifiBackend = {}));
package/dist/wifi.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Option, Result } from "@cprussin/option-result";
2
+ import type { SystemError } from "@domicile-desktop/sdk/system";
3
+ import type { Wifi, WifiDevice, WifiNetwork, WifiSystem } from "./wifi-state";
4
+ export type { Ip, Wifi, WifiConnection, WifiDevice, WifiNetwork, WifiSystem, } from "./wifi-state";
5
+ export { WifiBackend } from "./wifi-state";
6
+ /**
7
+ * Calls `onWifi` with the Wi-Fi device now and after each change, and returns
8
+ * a function that stops watching.
9
+ *
10
+ * Watches the first of NetworkManager and iwd on the system bus when called.
11
+ * `None` is no Wi-Fi device, or neither service: wpa_supplicant alone is not
12
+ * controlled. An `Err` is a D-Bus failure.
13
+ */
14
+ export declare const watchWifi: (system: WifiSystem, onWifi: (wifi: Result<Option<Wifi>, SystemError>) => void) => (() => void);
15
+ /** Turn the radio on or off. */
16
+ export declare const setWifiEnabled: (system: WifiSystem, wifi: WifiDevice, enabled: boolean) => Promise<Result<"done", SystemError>>;
17
+ /** Ask for a scan. Networks it finds arrive through the watch. */
18
+ export declare const scanWifi: (system: WifiSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
19
+ /**
20
+ * Join `network`. A secured network with no saved profile needs `passphrase`;
21
+ * the call throws without one.
22
+ */
23
+ export declare const connectWifi: (system: WifiSystem, wifi: WifiDevice, network: WifiNetwork, passphrase: string | undefined) => Promise<Result<"done", SystemError>>;
24
+ /** Leave the current network. */
25
+ export declare const disconnectWifi: (system: WifiSystem, wifi: WifiDevice) => Promise<Result<"done", SystemError>>;
package/dist/wifi.js ADDED
@@ -0,0 +1,116 @@
1
+ // The Wi-Fi device from whichever service manages it, NetworkManager or iwd,
2
+ // and the requests that change it. It also forwards the Wi-Fi types, which
3
+ // live in `wifi-state` so the backends can import them without a cycle. See
4
+ // docs/SHELL-SYSTEM-ACCESS.md.
5
+ import { Err, None, Ok, Some } from "@cprussin/option-result";
6
+ import { hasOwner } from "./has-owner";
7
+ import { connectIwdWifi, disconnectIwdWifi, scanIwdWifi, setIwdWifiEnabled, watchIwdWifi, } from "./iwd-wifi";
8
+ import { connectNetworkManagerWifi, disconnectNetworkManagerWifi, scanNetworkManagerWifi, setNetworkManagerWifiEnabled, watchNetworkManagerWifi, } from "./networkmanager-wifi";
9
+ import { WifiBackend } from "./wifi-state";
10
+ export { WifiBackend } from "./wifi-state";
11
+ /** Each backend's bus name, in the order they are tried. */
12
+ const BACKENDS = [
13
+ ["org.freedesktop.NetworkManager", WifiBackend.NetworkManager],
14
+ ["net.connman.iwd", WifiBackend.Iwd],
15
+ ];
16
+ /**
17
+ * Calls `onWifi` with the Wi-Fi device now and after each change, and returns
18
+ * a function that stops watching.
19
+ *
20
+ * Watches the first of NetworkManager and iwd on the system bus when called.
21
+ * `None` is no Wi-Fi device, or neither service: wpa_supplicant alone is not
22
+ * controlled. An `Err` is a D-Bus failure.
23
+ */
24
+ export const watchWifi = (system, onWifi) => {
25
+ const watch = { stop: undefined, stopped: false };
26
+ detect(system, BACKENDS)
27
+ .then((found) => {
28
+ if (!watch.stopped) {
29
+ found.match({
30
+ Err: (error) => {
31
+ onWifi(Err(error));
32
+ },
33
+ Ok: (backend) => {
34
+ backend.match({
35
+ None: () => {
36
+ onWifi(Ok(None()));
37
+ },
38
+ Some: (chosen) => {
39
+ watch.stop = watchBackend(chosen)(system, onWifi);
40
+ },
41
+ });
42
+ },
43
+ });
44
+ }
45
+ })
46
+ .catch((error) => {
47
+ // biome-ignore lint/suspicious/noConsole: surfacing a background failure
48
+ console.error("Failed to find a Wi-Fi service", error);
49
+ });
50
+ return () => {
51
+ watch.stopped = true;
52
+ watch.stop?.();
53
+ };
54
+ };
55
+ /** Turn the radio on or off. */
56
+ export const setWifiEnabled = (system, wifi, enabled) => {
57
+ switch (wifi.backend) {
58
+ case WifiBackend.NetworkManager:
59
+ return setNetworkManagerWifiEnabled(system, wifi, enabled);
60
+ case WifiBackend.Iwd:
61
+ return setIwdWifiEnabled(system, wifi, enabled);
62
+ }
63
+ };
64
+ /** Ask for a scan. Networks it finds arrive through the watch. */
65
+ export const scanWifi = (system, wifi) => {
66
+ switch (wifi.backend) {
67
+ case WifiBackend.NetworkManager:
68
+ return scanNetworkManagerWifi(system, wifi);
69
+ case WifiBackend.Iwd:
70
+ return scanIwdWifi(system, wifi);
71
+ }
72
+ };
73
+ /**
74
+ * Join `network`. A secured network with no saved profile needs `passphrase`;
75
+ * the call throws without one.
76
+ */
77
+ export const connectWifi = (system, wifi, network, passphrase) => {
78
+ switch (wifi.backend) {
79
+ case WifiBackend.NetworkManager:
80
+ return connectNetworkManagerWifi(system, wifi, network, passphrase);
81
+ case WifiBackend.Iwd:
82
+ return connectIwdWifi(system, wifi, network, passphrase);
83
+ }
84
+ };
85
+ /** Leave the current network. */
86
+ export const disconnectWifi = (system, wifi) => {
87
+ switch (wifi.backend) {
88
+ case WifiBackend.NetworkManager:
89
+ return disconnectNetworkManagerWifi(system, wifi);
90
+ case WifiBackend.Iwd:
91
+ return disconnectIwdWifi(system, wifi);
92
+ }
93
+ };
94
+ const watchBackend = (backend) => {
95
+ switch (backend) {
96
+ case WifiBackend.NetworkManager:
97
+ return watchNetworkManagerWifi;
98
+ case WifiBackend.Iwd:
99
+ return watchIwdWifi;
100
+ }
101
+ };
102
+ /** The first backend whose service has an owner on the bus, if any. */
103
+ const detect = async (system, backends) => {
104
+ const [first, ...rest] = backends;
105
+ if (first === undefined) {
106
+ return Ok(None());
107
+ }
108
+ else {
109
+ const [name, backend] = first;
110
+ const owned = await hasOwner(system, name);
111
+ return owned.match({
112
+ Err: (error) => Promise.resolve(Err(error)),
113
+ Ok: (has) => has ? Promise.resolve(Ok(Some(backend))) : detect(system, rest),
114
+ });
115
+ }
116
+ };
@@ -3,7 +3,7 @@
3
3
  import { Err, Ok, Result } from "@cprussin/option-result";
4
4
  import { Bus } from "@domicile-desktop/sdk/system";
5
5
  import { z } from "zod";
6
- import { followNetwork } from "./follow";
6
+ import { followBus } from "./follow";
7
7
  import { Connectivity, Link } from "./network-state";
8
8
  import { strengthOfDbm } from "./signal-strength";
9
9
  import { variant } from "./variant";
@@ -19,7 +19,7 @@ const ROOT = "/fi/w1/wpa_supplicant1";
19
19
  * such as wpa_supplicant not on the bus. The watch keeps listening after one,
20
20
  * so a later change reads again.
21
21
  */
22
- export const watchWpaSupplicant = (system, onNetwork) => followNetwork(system, { bus: Bus.System, sender: SERVICE }, () => read(system), onNetwork);
22
+ export const watchWpaSupplicant = (system, onNetwork) => followBus(system, { bus: Bus.System, sender: SERVICE }, () => read(system), onNetwork);
23
23
  /**
24
24
  * Read the network. An interface coming or going matters, as does a property
25
25
  * change on an object read for it.
@@ -28,7 +28,7 @@ const read = async (system) => {
28
28
  const found = await readPaths(system);
29
29
  return {
30
30
  matters: (signal) => matters(found.paths, signal),
31
- network: found.network.map((link) => ({
31
+ value: found.network.map((link) => ({
32
32
  connectivity: Connectivity.Unknown,
33
33
  link,
34
34
  })),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dependencies": {
3
3
  "@cprussin/option-result": "^2.0.1",
4
- "@domicile-desktop/sdk": "0.0.0-alpha-d3ee6fbb3e64",
4
+ "@domicile-desktop/sdk": "0.0.0-alpha-1fcbde657613",
5
5
  "zod": "^4.5.4"
6
6
  },
7
7
  "description": "The network for a Domicile shell, from NetworkManager, iwd or wpa_supplicant: connectivity and the primary connection, with its Wi-Fi network and signal, kept current.",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/networkmanager.d.ts",
24
24
  "default": "./dist/networkmanager.js"
25
25
  },
26
+ "./wifi": {
27
+ "types": "./dist/wifi.d.ts",
28
+ "default": "./dist/wifi.js"
29
+ },
26
30
  "./wpa-supplicant": {
27
31
  "types": "./dist/wpa-supplicant.d.ts",
28
32
  "default": "./dist/wpa-supplicant.js"
@@ -48,5 +52,5 @@
48
52
  "test:unit": "bun test"
49
53
  },
50
54
  "type": "module",
51
- "version": "0.0.0-alpha-d3ee6fbb3e64"
55
+ "version": "0.0.0-alpha-1fcbde657613"
52
56
  }