@domicile-desktop/component-library 0.0.0-alpha-ddde2bfbbaad → 0.0.0-alpha-e1457b57e6b2

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/package.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-ddde2bfbbaad",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-ddde2bfbbaad",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-e1457b57e6b2",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-e1457b57e6b2",
7
7
  "@phosphor-icons/react": "^2.1.10"
8
8
  },
9
9
  "description": "React components and the Panda CSS design system that Domicile's shells are built from.",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "exports": {
32
32
  "./Accordion": "./src/Accordion/Accordion.tsx",
33
+ "./AppChooser": "./src/AppChooser/AppChooser.tsx",
33
34
  "./Autocomplete": "./src/Autocomplete/Autocomplete.tsx",
34
35
  "./Avatar": "./src/Avatar/Avatar.tsx",
35
36
  "./Button": "./src/Button/Button.tsx",
@@ -102,5 +103,5 @@
102
103
  "test:unit": "bun test"
103
104
  },
104
105
  "type": "module",
105
- "version": "0.0.0-alpha-ddde2bfbbaad"
106
+ "version": "0.0.0-alpha-e1457b57e6b2"
106
107
  }
@@ -0,0 +1,186 @@
1
+ import type { System } from "@domicile-desktop/sdk/system";
2
+ import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
3
+ import { useId, useState } from "react";
4
+ import { css } from "../../styled-system/css";
5
+ import { flex, hstack } from "../../styled-system/patterns";
6
+ import { Button } from "../Button/Button";
7
+ import { keepInView, stepOf, steppedTo } from "../list-walk";
8
+ import { ModalDialog } from "../ModalDialog/ModalDialog";
9
+ import { preselected } from "./app-choices";
10
+ import { useDescribedChoices } from "./useDescribedChoices";
11
+
12
+ const ICON_SIZE = 24;
13
+
14
+ type Props = {
15
+ /** Desktop file IDs without `.desktop`, in the order offered. */
16
+ choices: readonly string[];
17
+ /** The type opened, whose defaults start picked; none when `undefined`. */
18
+ contentType: string | undefined;
19
+ /** A line under the title, such as who asks. */
20
+ description?: string | undefined;
21
+ /** The application chosen last time, which starts picked. */
22
+ lastChoice?: string | undefined;
23
+ onCancel: () => void;
24
+ /** Called with the ID of the application to open with. */
25
+ onChoose: (id: string) => void;
26
+ screen: string | undefined;
27
+ /** Where the applications' desktop entries are read. */
28
+ system: System;
29
+ title: string;
30
+ };
31
+
32
+ /**
33
+ * Pick an application to open a file or URI with. The last choice, else the
34
+ * type's default, else the first offered, starts picked. Dismissing it
35
+ * cancels.
36
+ */
37
+ export const AppChooser = ({
38
+ choices,
39
+ contentType,
40
+ description,
41
+ lastChoice,
42
+ onCancel,
43
+ onChoose,
44
+ screen,
45
+ system,
46
+ title,
47
+ }: Props) => {
48
+ const listId = useId();
49
+ const described = useDescribedChoices(system, choices, contentType);
50
+ const [picked, setPicked] = useState<string | undefined>();
51
+ const apps = described?.apps ?? [];
52
+ const first =
53
+ described === undefined
54
+ ? undefined
55
+ : preselected(choices, lastChoice, described.defaults);
56
+ // A pick the caller has since taken away no longer counts.
57
+ const chosen =
58
+ picked !== undefined && choices.includes(picked) ? picked : first;
59
+ const at = apps.findIndex(({ id }) => id === chosen);
60
+ const choose = (id: string | undefined) => {
61
+ if (id !== undefined) {
62
+ onChoose(id);
63
+ }
64
+ };
65
+ return (
66
+ <ModalDialog
67
+ closeButton={false}
68
+ footer={
69
+ <>
70
+ <Button onClick={onCancel} variant="outline">
71
+ Cancel
72
+ </Button>
73
+ <Button
74
+ disabled={chosen === undefined}
75
+ onClick={() => {
76
+ choose(chosen);
77
+ }}
78
+ >
79
+ Open
80
+ </Button>
81
+ </>
82
+ }
83
+ onOpenChange={(open) => {
84
+ if (!open) {
85
+ onCancel();
86
+ }
87
+ }}
88
+ open
89
+ screen={screen}
90
+ title={title}
91
+ >
92
+ {description !== undefined && (
93
+ <p className={descriptionStyles}>{description}</p>
94
+ )}
95
+ <div
96
+ aria-activedescendant={at === -1 ? undefined : optionId(listId, at)}
97
+ aria-label="Applications"
98
+ className={listStyles}
99
+ onKeyDown={(event) => {
100
+ const step = stepOf(event);
101
+ const next = apps[steppedTo(at, step ?? 0, apps.length)];
102
+ if (event.key === "Enter") {
103
+ event.preventDefault();
104
+ choose(chosen);
105
+ } else if (step !== undefined && next !== undefined) {
106
+ event.preventDefault();
107
+ setPicked(next.id);
108
+ }
109
+ }}
110
+ role="listbox"
111
+ tabIndex={0}
112
+ >
113
+ {apps.map((app, index) => (
114
+ // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles the keyboard via `aria-activedescendant`
115
+ <div
116
+ aria-selected={app.id === chosen}
117
+ className={optionStyles}
118
+ id={optionId(listId, index)}
119
+ key={app.id}
120
+ onClick={() => {
121
+ setPicked(app.id);
122
+ }}
123
+ onDoubleClick={() => {
124
+ choose(app.id);
125
+ }}
126
+ ref={app.id === chosen ? keepInView : undefined}
127
+ role="option"
128
+ tabIndex={-1}
129
+ >
130
+ {app.icon === undefined ? (
131
+ <AppWindowIcon size={ICON_SIZE} />
132
+ ) : (
133
+ <img
134
+ alt=""
135
+ className={iconStyles}
136
+ height={ICON_SIZE}
137
+ src={app.icon}
138
+ width={ICON_SIZE}
139
+ />
140
+ )}
141
+ <span className={nameStyles}>{app.name}</span>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ </ModalDialog>
146
+ );
147
+ };
148
+
149
+ const descriptionStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
150
+
151
+ const listStyles = flex({
152
+ direction: "column",
153
+ gap: 0.5,
154
+ marginBlockStart: 3,
155
+ maxBlockSize: 80,
156
+ overflowY: "auto",
157
+ });
158
+
159
+ // As the file picker's rows.
160
+ const optionStyles = hstack({
161
+ "&[aria-selected=true]": {
162
+ backgroundImage:
163
+ "linear-gradient(to right, color-mix(in oklab, {colors.accent} 30%, transparent), color-mix(in oklab, {colors.accent} 8%, transparent))",
164
+ },
165
+ borderRadius: "md",
166
+ color: "foreground",
167
+ cursor: "pointer",
168
+ gap: 3,
169
+ paddingBlock: 1.5,
170
+ paddingInline: 2,
171
+ transition: "background-color {durations.fast} {easings.out}",
172
+ userSelect: "none",
173
+ });
174
+
175
+ const iconStyles = css({ flexShrink: 0, objectFit: "contain" });
176
+
177
+ const nameStyles = css({
178
+ fontSize: "sm",
179
+ fontWeight: "medium",
180
+ overflow: "hidden",
181
+ textOverflow: "ellipsis",
182
+ whiteSpace: "nowrap",
183
+ });
184
+
185
+ const optionId = (listId: string, index: number): string =>
186
+ `${listId}-${String(index)}`;
@@ -1,57 +1,86 @@
1
1
  import type { AccountBody, PortalAnswer } from "@domicile-desktop/sdk/portal";
2
2
  import { PortalAnswer as Answer } from "@domicile-desktop/sdk/portal";
3
+ import type { System } from "@domicile-desktop/sdk/system";
3
4
  import { css } from "../../styled-system/css";
5
+ import { hstack } from "../../styled-system/patterns";
4
6
  import { Button } from "../Button/Button";
5
7
  import { ModalDialog } from "../ModalDialog/ModalDialog";
8
+ import { usePictureUrl } from "../usePictureUrl/usePictureUrl";
6
9
 
7
10
  type Props = {
8
11
  answer: (answer: PortalAnswer) => void;
9
12
  /** Who asks, as the dialog names them. */
10
13
  asker: string;
11
14
  body: AccountBody;
15
+ /** Reads the user's picture. */
16
+ files: Pick<System, "readFile">;
12
17
  screen: string | undefined;
13
18
  };
14
19
 
15
20
  /**
16
- * Whether an application may have the user's name and picture. Dismissing it
17
- * keeps them.
21
+ * Whether an application may have the user's name and picture, showing both.
22
+ * Dismissing it keeps them.
18
23
  */
19
- export const AccountDialog = ({ answer, asker, body, screen }: Props) => (
20
- <ModalDialog
21
- closeButton={false}
22
- footer={
23
- <>
24
- <Button
25
- onClick={() => {
26
- answer(Answer.Canceled());
27
- }}
28
- variant="outline"
29
- >
30
- Don't share
31
- </Button>
32
- <Button
33
- onClick={() => {
34
- answer(Answer.Access());
35
- }}
36
- >
37
- Share
38
- </Button>
39
- </>
40
- }
41
- onOpenChange={(open) => {
42
- if (!open) {
43
- answer(Answer.Canceled());
24
+ export const AccountDialog = ({
25
+ answer,
26
+ asker,
27
+ body,
28
+ files,
29
+ screen,
30
+ }: Props) => {
31
+ const picture = usePictureUrl(files, body.image);
32
+ return (
33
+ <ModalDialog
34
+ closeButton={false}
35
+ footer={
36
+ <>
37
+ <Button
38
+ onClick={() => {
39
+ answer(Answer.Canceled());
40
+ }}
41
+ variant="outline"
42
+ >
43
+ Don't share
44
+ </Button>
45
+ <Button
46
+ onClick={() => {
47
+ answer(Answer.Access());
48
+ }}
49
+ >
50
+ Share
51
+ </Button>
52
+ </>
44
53
  }
45
- }}
46
- open
47
- screen={screen}
48
- title="Share your name and picture?"
49
- >
50
- <p className={askerStyles}>{`${asker} asks`}</p>
51
- {body.reason !== undefined && <p className={textStyles}>{body.reason}</p>}
52
- </ModalDialog>
53
- );
54
+ onOpenChange={(open) => {
55
+ if (!open) {
56
+ answer(Answer.Canceled());
57
+ }
58
+ }}
59
+ open
60
+ screen={screen}
61
+ title="Share your name and picture?"
62
+ >
63
+ <p className={askerStyles}>{`${asker} asks`}</p>
64
+ {body.reason !== undefined && <p className={textStyles}>{body.reason}</p>}
65
+ <div className={userStyles}>
66
+ {picture !== undefined && (
67
+ <img alt={body.name} className={pictureStyles} src={picture} />
68
+ )}
69
+ <span className={textStyles}>{body.name}</span>
70
+ </div>
71
+ </ModalDialog>
72
+ );
73
+ };
54
74
 
55
75
  const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
56
76
 
57
77
  const textStyles = css({ color: "foreground", margin: 0 });
78
+
79
+ const userStyles = hstack({ gap: 3, marginBlockStart: 3 });
80
+
81
+ const pictureStyles = css({
82
+ blockSize: 10,
83
+ borderRadius: "full",
84
+ inlineSize: 10,
85
+ objectFit: "cover",
86
+ });
@@ -4,17 +4,7 @@ import type {
4
4
  } from "@domicile-desktop/sdk/portal";
5
5
  import { PortalAnswer as Answer } from "@domicile-desktop/sdk/portal";
6
6
  import type { System } from "@domicile-desktop/sdk/system";
7
- import { AppWindowIcon } from "@phosphor-icons/react/dist/ssr/AppWindow";
8
- import { useId, useState } from "react";
9
- import { css } from "../../styled-system/css";
10
- import { flex, hstack } from "../../styled-system/patterns";
11
- import { Button } from "../Button/Button";
12
- import { keepInView, stepOf, steppedTo } from "../list-walk";
13
- import { ModalDialog } from "../ModalDialog/ModalDialog";
14
- import { preselected } from "./app-choices";
15
- import { useDescribedChoices } from "./useDescribedChoices";
16
-
17
- const ICON_SIZE = 24;
7
+ import { AppChooser } from "../AppChooser/AppChooser";
18
8
 
19
9
  type Props = {
20
10
  answer: (answer: PortalAnswer) => void;
@@ -26,162 +16,33 @@ type Props = {
26
16
  system: System;
27
17
  };
28
18
 
29
- /**
30
- * Pick an application to open a file or URI with. The last choice, else the
31
- * type's default, starts picked. Dismissing it cancels.
32
- */
19
+ /** The AppChooser portal's {@link AppChooser}. Dismissing it cancels. */
33
20
  export const AppChooserDialog = ({
34
21
  answer,
35
22
  asker,
36
23
  body,
37
24
  screen,
38
25
  system,
39
- }: Props) => {
40
- const listId = useId();
41
- const described = useDescribedChoices(system, body.choices, body.contentType);
42
- const [picked, setPicked] = useState<string | undefined>();
43
- const apps = described?.apps ?? [];
44
- const first =
45
- described === undefined
46
- ? undefined
47
- : preselected(body.choices, body.lastChoice, described.defaults);
48
- // A pick the application has since taken away no longer counts.
49
- const chosen =
50
- picked !== undefined && body.choices.includes(picked) ? picked : first;
51
- const at = apps.findIndex(({ id }) => id === chosen);
52
- const choose = (id: string | undefined) => {
53
- if (id !== undefined) {
26
+ }: Props) => (
27
+ <AppChooser
28
+ choices={body.choices}
29
+ contentType={body.contentType}
30
+ description={`${asker} asks`}
31
+ lastChoice={body.lastChoice}
32
+ onCancel={() => {
33
+ answer(Answer.Canceled());
34
+ }}
35
+ onChoose={(id) => {
54
36
  answer(Answer.AppChooser(id));
55
- }
56
- };
57
- return (
58
- <ModalDialog
59
- closeButton={false}
60
- footer={
61
- <>
62
- <Button
63
- onClick={() => {
64
- answer(Answer.Canceled());
65
- }}
66
- variant="outline"
67
- >
68
- Cancel
69
- </Button>
70
- <Button
71
- disabled={chosen === undefined}
72
- onClick={() => {
73
- choose(chosen);
74
- }}
75
- >
76
- Open
77
- </Button>
78
- </>
79
- }
80
- onOpenChange={(open) => {
81
- if (!open) {
82
- answer(Answer.Canceled());
83
- }
84
- }}
85
- open
86
- screen={screen}
87
- title={titleOf(body)}
88
- >
89
- <p className={askerStyles}>{`${asker} asks`}</p>
90
- <div
91
- aria-activedescendant={at === -1 ? undefined : optionId(listId, at)}
92
- aria-label="Applications"
93
- className={listStyles}
94
- onKeyDown={(event) => {
95
- const step = stepOf(event);
96
- const next = apps[steppedTo(at, step ?? 0, apps.length)];
97
- if (event.key === "Enter") {
98
- event.preventDefault();
99
- choose(chosen);
100
- } else if (step !== undefined && next !== undefined) {
101
- event.preventDefault();
102
- setPicked(next.id);
103
- }
104
- }}
105
- role="listbox"
106
- tabIndex={0}
107
- >
108
- {apps.map((app, index) => (
109
- // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles the keyboard via `aria-activedescendant`
110
- <div
111
- aria-selected={app.id === chosen}
112
- className={optionStyles}
113
- id={optionId(listId, index)}
114
- key={app.id}
115
- onClick={() => {
116
- setPicked(app.id);
117
- }}
118
- onDoubleClick={() => {
119
- choose(app.id);
120
- }}
121
- ref={app.id === chosen ? keepInView : undefined}
122
- role="option"
123
- tabIndex={-1}
124
- >
125
- {app.icon === undefined ? (
126
- <AppWindowIcon size={ICON_SIZE} />
127
- ) : (
128
- <img
129
- alt=""
130
- className={iconStyles}
131
- height={ICON_SIZE}
132
- src={app.icon}
133
- width={ICON_SIZE}
134
- />
135
- )}
136
- <span className={nameStyles}>{app.name}</span>
137
- </div>
138
- ))}
139
- </div>
140
- </ModalDialog>
141
- );
142
- };
143
-
144
- const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
145
-
146
- const listStyles = flex({
147
- direction: "column",
148
- gap: 0.5,
149
- marginBlockStart: 3,
150
- maxBlockSize: 80,
151
- overflowY: "auto",
152
- });
153
-
154
- // As the file picker's rows.
155
- const optionStyles = hstack({
156
- "&[aria-selected=true]": {
157
- backgroundImage:
158
- "linear-gradient(to right, color-mix(in oklab, {colors.accent} 30%, transparent), color-mix(in oklab, {colors.accent} 8%, transparent))",
159
- },
160
- borderRadius: "md",
161
- color: "foreground",
162
- cursor: "pointer",
163
- gap: 3,
164
- paddingBlock: 1.5,
165
- paddingInline: 2,
166
- transition: "background-color {durations.fast} {easings.out}",
167
- userSelect: "none",
168
- });
169
-
170
- const iconStyles = css({ flexShrink: 0, objectFit: "contain" });
171
-
172
- const nameStyles = css({
173
- fontSize: "sm",
174
- fontWeight: "medium",
175
- overflow: "hidden",
176
- textOverflow: "ellipsis",
177
- whiteSpace: "nowrap",
178
- });
37
+ }}
38
+ screen={screen}
39
+ system={system}
40
+ title={titleOf(body)}
41
+ />
42
+ );
179
43
 
180
44
  /** What the dialog opens, as its title names it. */
181
45
  const titleOf = ({ filename, uri }: AppChooserBody): string => {
182
46
  const opened = filename ?? uri;
183
47
  return opened === undefined ? "Open with" : `Open ${opened} with`;
184
48
  };
185
-
186
- const optionId = (listId: string, index: number): string =>
187
- `${listId}-${String(index)}`;
@@ -4,6 +4,7 @@ import type { Capturing, PortalRequest } from "@domicile-desktop/sdk/portal";
4
4
  import {
5
5
  answerPortalRequest,
6
6
  CapturingKind,
7
+ CastSourceKind,
7
8
  PortalAnswer,
8
9
  PortalKind,
9
10
  stopCapturing,
@@ -14,6 +15,7 @@ import type { System, SystemHost } from "@domicile-desktop/sdk/system";
14
15
  import { system } from "@domicile-desktop/sdk/system";
15
16
  import { useCallback, useEffect, useMemo, useState } from "react";
16
17
  import { listDirectory } from "../FilePicker/list-directory";
18
+ import type { App } from "../useApps/useApps";
17
19
  import { useApps } from "../useApps/useApps";
18
20
  import { AccessDialog } from "./AccessDialog";
19
21
  import { AccountDialog } from "./AccountDialog";
@@ -82,7 +84,7 @@ export const PortalDialogs = ({
82
84
  );
83
85
  const [sessions, setSessions] = useState<readonly Capturing[]>([]);
84
86
  const apps = useApps(files, [
85
- ...requests.map((request) => request.appId),
87
+ ...requests.flatMap(appIds),
86
88
  ...sessions.map((session) => session.appId),
87
89
  ]);
88
90
 
@@ -120,6 +122,7 @@ export const PortalDialogs = ({
120
122
  answer={(answer) => {
121
123
  answerPortalRequest(host, shown.id, answer);
122
124
  }}
125
+ apps={apps}
123
126
  asker={apps(shown.appId).name}
124
127
  key={shown.id}
125
128
  list={list}
@@ -136,6 +139,7 @@ export const PortalDialogs = ({
136
139
  /** The dialog for `request`'s kind. */
137
140
  const Dialog = ({
138
141
  answer,
142
+ apps,
139
143
  asker,
140
144
  list,
141
145
  request,
@@ -144,6 +148,7 @@ const Dialog = ({
144
148
  system: files,
145
149
  }: {
146
150
  answer: (answer: PortalAnswer) => void;
151
+ apps: (appId: string) => App;
147
152
  asker: string;
148
153
  list: (path: string) => Promise<readonly string[]>;
149
154
  request: PortalRequest;
@@ -204,6 +209,7 @@ const Dialog = ({
204
209
  answer={answer}
205
210
  asker={asker}
206
211
  body={request.body}
212
+ files={files}
207
213
  screen={screen}
208
214
  />
209
215
  );
@@ -249,6 +255,7 @@ const Dialog = ({
249
255
  return (
250
256
  <ScreenCastDialog
251
257
  answer={answer}
258
+ apps={apps}
252
259
  asker={asker}
253
260
  body={request.body}
254
261
  screen={screen}
@@ -300,6 +307,17 @@ const screenFor = (
300
307
  return parents ?? screen;
301
308
  };
302
309
 
310
+ /** The applications `request` names: who asks, and windows it may record. */
311
+ const appIds = (request: PortalRequest): string[] =>
312
+ request.kind === PortalKind.ScreenCast
313
+ ? [
314
+ request.appId,
315
+ ...request.body.sources.flatMap((source) =>
316
+ source.kind === CastSourceKind.Window ? [source.appId] : [],
317
+ ),
318
+ ]
319
+ : [request.appId];
320
+
303
321
  /** Whether `request` is a question this draws a dialog for. */
304
322
  const isAsked = (request: PortalRequest): boolean => {
305
323
  switch (request.kind) {
@@ -17,10 +17,13 @@ import { css } from "../../styled-system/css";
17
17
  import { Button } from "../Button/Button";
18
18
  import { ModalDialog } from "../ModalDialog/ModalDialog";
19
19
  import { Tabs } from "../Tabs/Tabs";
20
+ import type { App } from "../useApps/useApps";
20
21
  import { RegionPicker } from "./RegionPicker";
21
22
 
22
23
  type Props = {
23
24
  answer: (answer: PortalAnswer) => void;
25
+ /** Names a window's application by its Wayland app id. */
26
+ apps: (appId: string) => App;
24
27
  /** Who asks, as the dialog names them. */
25
28
  asker: string;
26
29
  body: ScreenCastBody;
@@ -33,7 +36,13 @@ type Props = {
33
36
  * asks for one. Picks one, or several when the application asks for more.
34
37
  * Dismissing it cancels.
35
38
  */
36
- export const ScreenCastDialog = ({ answer, asker, body, screen }: Props) => {
39
+ export const ScreenCastDialog = ({
40
+ answer,
41
+ apps,
42
+ asker,
43
+ body,
44
+ screen,
45
+ }: Props) => {
37
46
  const [picked, setPicked] = useState<readonly string[]>([]);
38
47
  const [drawing, setDrawing] = useState(false);
39
48
  const windows = body.sources.filter(
@@ -72,8 +81,8 @@ export const ScreenCastDialog = ({ answer, asker, body, screen }: Props) => {
72
81
  key={sourceKey(source)}
73
82
  value={sourceKey(source)}
74
83
  >
75
- <SourceIcon source={source} />
76
- <span>{sourceName(source)}</span>
84
+ <SourceIcon app={appOf(apps, source)} source={source} />
85
+ <span>{sourceName(source, appOf(apps, source))}</span>
77
86
  </Toggle>
78
87
  ))}
79
88
  </ToggleGroup>
@@ -152,13 +161,19 @@ export const ScreenCastDialog = ({ answer, asker, body, screen }: Props) => {
152
161
  };
153
162
 
154
163
  /** A window's application icon, a window outline without one, or a screen. */
155
- const SourceIcon = ({ source }: { source: CastSource }) => {
164
+ const SourceIcon = ({
165
+ app,
166
+ source,
167
+ }: {
168
+ app: App | undefined;
169
+ source: CastSource;
170
+ }) => {
156
171
  switch (source.kind) {
157
172
  case CastSourceKind.Window:
158
- return source.icon === undefined ? (
173
+ return app?.icon === undefined ? (
159
174
  <AppWindowIcon aria-hidden className={iconStyles} />
160
175
  ) : (
161
- <img alt="" className={iconStyles} src={source.icon} />
176
+ <img alt="" className={iconStyles} src={app.icon} />
162
177
  );
163
178
  case CastSourceKind.Monitor:
164
179
  case CastSourceKind.Region:
@@ -226,14 +241,21 @@ const sourceKey = (source: CastSource): string => {
226
241
  }
227
242
  };
228
243
 
229
- /** How the picker names a source. */
230
- const sourceName = (source: CastSource): string => {
244
+ /** A window's application, or `undefined` for a screen or a window with no app id. */
245
+ const appOf = (
246
+ apps: (appId: string) => App,
247
+ source: CastSource,
248
+ ): App | undefined =>
249
+ source.kind === CastSourceKind.Window && source.appId !== ""
250
+ ? apps(source.appId)
251
+ : undefined;
252
+
253
+ /** How the picker names a source, a window by its `app` too. */
254
+ const sourceName = (source: CastSource, app: App | undefined): string => {
231
255
  switch (source.kind) {
232
256
  case CastSourceKind.Window: {
233
257
  const title = source.title === "" ? "Untitled window" : source.title;
234
- return source.appName === undefined
235
- ? title
236
- : `${source.appName}: ${title}`;
258
+ return app === undefined ? title : `${app.name}: ${title}`;
237
259
  }
238
260
  case CastSourceKind.Monitor:
239
261
  return source.description === ""