@howaboua/pi-pet 0.1.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/SECURITY.md +24 -0
  5. package/authoring/PET-GUIDE.md +65 -0
  6. package/authoring/hatch/HATCH-GUIDE.md +136 -0
  7. package/authoring/hatch/LICENSE.txt +201 -0
  8. package/authoring/hatch/NOTICE.md +5 -0
  9. package/authoring/hatch/pyproject.toml +8 -0
  10. package/authoring/hatch/references/animation-rows.md +29 -0
  11. package/authoring/hatch/references/final-assembly.md +67 -0
  12. package/authoring/hatch/references/look-directions.md +35 -0
  13. package/authoring/hatch/references/pet-contract.md +40 -0
  14. package/authoring/hatch/references/qa-rubric.md +34 -0
  15. package/authoring/hatch/references/reviewer-protocol.md +72 -0
  16. package/authoring/hatch/references/standard-assembly.md +21 -0
  17. package/authoring/hatch/references/visual-generation.md +59 -0
  18. package/authoring/hatch/scripts/approve_cardinals.py +110 -0
  19. package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
  20. package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
  21. package/authoring/hatch/scripts/compose_atlas.py +171 -0
  22. package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
  23. package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
  24. package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
  25. package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
  26. package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
  27. package/authoring/hatch/scripts/inspect_frames.py +278 -0
  28. package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
  29. package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
  30. package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
  31. package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
  32. package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
  33. package/authoring/hatch/scripts/render_animation_previews.py +80 -0
  34. package/authoring/hatch/scripts/validate_atlas.py +299 -0
  35. package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
  36. package/authoring/hatch/uv.lock +99 -0
  37. package/authoring/references/import-existing-pet.md +47 -0
  38. package/authoring/references/pet-format.md +88 -0
  39. package/changelog.ts +327 -0
  40. package/deploy/pi-pet-hyprland.conf +10 -0
  41. package/desktop/build.mjs +194 -0
  42. package/desktop/package-lock.json +887 -0
  43. package/desktop/package.json +16 -0
  44. package/dist/authoring/prepare.mjs +3 -0
  45. package/dist/authoring/rebuild.mjs +4 -0
  46. package/dist/authoring/validate.mjs +2 -0
  47. package/dist/desktop/main.mjs +5 -0
  48. package/dist/desktop/preload.cjs +1 -0
  49. package/dist/web/app.js +1 -0
  50. package/dist/web/catalog.json +1 -0
  51. package/dist/web/index.html +100 -0
  52. package/dist/web/manifest.webmanifest +16 -0
  53. package/dist/web/pet-icon.svg +6 -0
  54. package/dist/web/spritesheet.webp +0 -0
  55. package/dist/web/styles.css +592 -0
  56. package/docs/architecture.md +41 -0
  57. package/docs/pi-pet.png +0 -0
  58. package/docs/research.md +17 -0
  59. package/extensions/desktop-command.ts +249 -0
  60. package/extensions/desktop-config.ts +152 -0
  61. package/extensions/desktop-launcher.ts +380 -0
  62. package/extensions/gippity.ts +24 -0
  63. package/extensions/index.ts +110 -0
  64. package/extensions/remote-app.ts +61 -0
  65. package/package.json +101 -0
  66. package/pets/clawa/PET.md +27 -0
  67. package/pets/clawa/pet.json +7 -0
  68. package/pets/clawa/spritesheet.webp +0 -0
  69. package/pets/clawa/validation.json +808 -0
  70. package/src/desktop/attention.ts +87 -0
  71. package/src/desktop/bridge.ts +31 -0
  72. package/src/desktop/config.ts +145 -0
  73. package/src/desktop/cursor-provider.ts +150 -0
  74. package/src/desktop/main.ts +459 -0
  75. package/src/desktop/preload.ts +19 -0
  76. package/src/pet-image-size.ts +72 -0
  77. package/src/pet-loader.ts +278 -0
  78. package/src/pet-storage.ts +240 -0
  79. package/src/protocol/index.ts +81 -0
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ type AttentionMode = "normal" | "quiet";
6
+ export type PetSize = "small" | "medium" | "large";
7
+
8
+ export interface AttentionPreferences {
9
+ schemaVersion: 1;
10
+ mode: AttentionMode;
11
+ petSize: PetSize;
12
+ snoozedUntil: string | null;
13
+ }
14
+
15
+ const ATTENTION_KEYS = new Set(["schemaVersion", "mode", "petSize", "snoozedUntil"]);
16
+ const MAX_ATTENTION_BYTES = 4_096;
17
+ const MAX_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
18
+
19
+ export function defaultAttentionPreferences(): AttentionPreferences {
20
+ return { schemaVersion: 1, mode: "normal", petSize: "medium", snoozedUntil: null };
21
+ }
22
+
23
+ function parsePetSize(value: unknown): PetSize {
24
+ const petSize = value ?? "medium";
25
+ if (petSize !== "small" && petSize !== "medium" && petSize !== "large") {
26
+ throw new Error("Desktop petSize must be small, medium, or large.");
27
+ }
28
+ return petSize;
29
+ }
30
+
31
+ export function parseAttentionPreferences(value: unknown): AttentionPreferences {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
33
+ throw new Error("Desktop attention preferences must be an object.");
34
+ }
35
+ const input = value as Record<string, unknown>;
36
+ const unknown = Object.keys(input).find((key) => !ATTENTION_KEYS.has(key));
37
+ if (unknown) throw new Error(`Desktop attention preferences have unknown field: ${unknown}.`);
38
+ if (input["schemaVersion"] !== 1) throw new Error("Unsupported desktop attention schemaVersion.");
39
+ if (input["mode"] !== "normal" && input["mode"] !== "quiet") {
40
+ throw new Error("Desktop attention mode must be normal or quiet.");
41
+ }
42
+ const petSize = parsePetSize(input["petSize"]);
43
+ if (input["snoozedUntil"] !== null && typeof input["snoozedUntil"] !== "string") {
44
+ throw new Error("Desktop snoozedUntil must be an ISO timestamp or null.");
45
+ }
46
+ if (typeof input["snoozedUntil"] === "string") {
47
+ const snoozedUntil = Date.parse(input["snoozedUntil"]);
48
+ if (!Number.isFinite(snoozedUntil)) throw new Error("Desktop snoozedUntil must be a valid ISO timestamp.");
49
+ if (snoozedUntil - Date.now() > MAX_SNOOZE_MS) {
50
+ throw new Error("Desktop snoozedUntil cannot be more than seven days away.");
51
+ }
52
+ }
53
+ return { schemaVersion: 1, mode: input["mode"], petSize, snoozedUntil: input["snoozedUntil"] };
54
+ }
55
+
56
+ export async function loadAttentionPreferences(path: string): Promise<AttentionPreferences> {
57
+ try {
58
+ const raw = await readFile(path, "utf8");
59
+ if (Buffer.byteLength(raw) > MAX_ATTENTION_BYTES) throw new Error("Attention preferences are too large.");
60
+ return parseAttentionPreferences(JSON.parse(raw) as unknown);
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultAttentionPreferences();
63
+ throw new Error(`Invalid desktop attention preferences at ${path}: ${(error as Error).message}`);
64
+ }
65
+ }
66
+
67
+ export async function saveAttentionPreferences(path: string, value: AttentionPreferences): Promise<void> {
68
+ const preferences = parseAttentionPreferences(value);
69
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
70
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
71
+ try {
72
+ await writeFile(temporary, `${JSON.stringify(preferences)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
73
+ await rename(temporary, path);
74
+ } catch (error) {
75
+ await unlink(temporary).catch(() => undefined);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ export function snoozeUntilTomorrow(now = new Date()): Date {
81
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8);
82
+ }
83
+
84
+ export function remainingSnoozeMs(preferences: AttentionPreferences, now = Date.now()): number {
85
+ if (!preferences.snoozedUntil) return 0;
86
+ return Math.max(0, Date.parse(preferences.snoozedUntil) - now);
87
+ }
@@ -0,0 +1,31 @@
1
+ export const DESKTOP_CURSOR_CHANNEL = "pi-pet:cursor-position";
2
+
3
+ export interface DesktopCursorPosition {
4
+ x: number;
5
+ y: number;
6
+ }
7
+
8
+ export interface PiPetDesktopBridge {
9
+ onCursorPosition(listener: (position: DesktopCursorPosition | null) => void): () => void;
10
+ }
11
+
12
+ export function parseDesktopCursorPosition(value: unknown): DesktopCursorPosition | null {
13
+ if (value === null) return null;
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ throw new Error("Cursor position must be an object.");
16
+ const input = value as Record<string, unknown>;
17
+ if (Object.keys(input).some((key) => key !== "x" && key !== "y")) {
18
+ throw new Error("Cursor position has an unknown field.");
19
+ }
20
+ if (
21
+ typeof input["x"] !== "number" ||
22
+ !Number.isFinite(input["x"]) ||
23
+ Math.abs(input["x"]) > 100_000 ||
24
+ typeof input["y"] !== "number" ||
25
+ !Number.isFinite(input["y"]) ||
26
+ Math.abs(input["y"]) > 100_000
27
+ ) {
28
+ throw new Error("Cursor position is invalid.");
29
+ }
30
+ return { x: input["x"], y: input["y"] };
31
+ }
@@ -0,0 +1,145 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { type FileHandle, open } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+
6
+ export interface DesktopConfig {
7
+ schemaVersion: 1;
8
+ gippityUrl: string;
9
+ }
10
+
11
+ const CONFIG_KEYS = new Set(["schemaVersion", "gippityUrl"]);
12
+ const MAX_CONFIG_BYTES = 16_384;
13
+ const DEVICE_NAME_PATTERN = /^(?!-)[a-zA-Z0-9_.@:-]{1,255}$/;
14
+ const RESERVED_DEVICE_NAMES = new Set(["__proto__", "constructor", "prototype"]);
15
+
16
+ export const MAX_PET_DEVICES = 8;
17
+
18
+ export function parseDeviceName(value: unknown): string {
19
+ if (typeof value !== "string") throw new Error("Pi Pet device name must be a string.");
20
+ const name = value.trim();
21
+ if (!DEVICE_NAME_PATTERN.test(name) || RESERVED_DEVICE_NAMES.has(name)) {
22
+ throw new Error("Pi Pet device name must be local or one SSH host or alias without command options.");
23
+ }
24
+ return name;
25
+ }
26
+
27
+ export function parseSshTarget(value: unknown): string {
28
+ const target = parseDeviceName(value);
29
+ if (target === "local") throw new Error("The reserved local device does not use SSH.");
30
+ return target;
31
+ }
32
+
33
+ function desktopConfigPath(
34
+ env: NodeJS.ProcessEnv = process.env,
35
+ platform: NodeJS.Platform = process.platform,
36
+ home = homedir(),
37
+ ): string {
38
+ if (env["PI_PET_DESKTOP_CONFIG"]) return resolve(env["PI_PET_DESKTOP_CONFIG"]);
39
+ if (platform === "win32") {
40
+ return join(env["APPDATA"] || join(home, "AppData", "Roaming"), "PiPetDesktop", "config.json");
41
+ }
42
+ if (platform === "darwin") {
43
+ return join(home, "Library", "Application Support", "Pi Pet Desktop", "config.json");
44
+ }
45
+ return join(env["XDG_CONFIG_HOME"] || join(home, ".config"), "pi-pet-desktop", "config.json");
46
+ }
47
+
48
+ export function desktopStateDirectory(
49
+ env: NodeJS.ProcessEnv = process.env,
50
+ platform: NodeJS.Platform = process.platform,
51
+ home = homedir(),
52
+ ): string {
53
+ if (platform === "win32") {
54
+ return join(env["LOCALAPPDATA"] || join(home, "AppData", "Local"), "PiPetDesktop", "state");
55
+ }
56
+ if (platform === "darwin") {
57
+ return join(home, "Library", "Application Support", "Pi Pet Desktop", "state");
58
+ }
59
+ return join(env["XDG_STATE_HOME"] || join(home, ".local", "state"), "pi-pet-desktop");
60
+ }
61
+
62
+ function parseGippityUrl(value: unknown): string {
63
+ if (typeof value !== "string" || value.length > 2_048) {
64
+ throw new Error("gippityUrl must be a bounded HTTPS URL.");
65
+ }
66
+ let url: URL;
67
+ try {
68
+ url = new URL(value);
69
+ } catch {
70
+ throw new Error("gippityUrl must be a valid HTTPS URL.");
71
+ }
72
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
73
+ throw new Error("gippityUrl must be an HTTPS origin without credentials, query, or fragment.");
74
+ }
75
+ if (url.pathname !== "/") throw new Error("gippityUrl must not contain a path.");
76
+ return url.origin;
77
+ }
78
+
79
+ export function parseDesktopConfig(value: unknown): DesktopConfig {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
81
+ throw new Error("Pi Pet desktop config must be an object.");
82
+ }
83
+ const input = value as Record<string, unknown>;
84
+ const unknown = Object.keys(input).find((key) => !CONFIG_KEYS.has(key));
85
+ if (unknown) throw new Error(`Pi Pet desktop config has unknown field: ${unknown}.`);
86
+ if (input["schemaVersion"] !== 1) throw new Error("Unsupported Pi Pet desktop config schemaVersion.");
87
+ return { schemaVersion: 1, gippityUrl: parseGippityUrl(input["gippityUrl"]) };
88
+ }
89
+
90
+ function configFromEnvironment(env: NodeJS.ProcessEnv): DesktopConfig | undefined {
91
+ const gippityUrl = env["PI_PET_GIPPITY_URL"];
92
+ return gippityUrl ? parseDesktopConfig({ schemaVersion: 1, gippityUrl }) : undefined;
93
+ }
94
+
95
+ async function openDesktopConfig(path: string): Promise<FileHandle> {
96
+ try {
97
+ return await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
100
+ throw new Error(`Pi Pet desktop is not configured: ${path}`);
101
+ }
102
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") {
103
+ throw new Error(`Pi Pet desktop config must not be a symbolic link: ${path}`);
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ async function validateConfigFile(handle: FileHandle, path: string): Promise<void> {
110
+ const info = await handle.stat();
111
+ if (!info.isFile() || info.size > MAX_CONFIG_BYTES) {
112
+ throw new Error(`Pi Pet desktop config must be a regular file no larger than ${MAX_CONFIG_BYTES} bytes: ${path}`);
113
+ }
114
+ if (process.platform === "win32") return;
115
+ if ((info.mode & 0o077) !== 0) throw new Error(`Pi Pet desktop config must have mode 0600: ${path}`);
116
+ if (process.getuid && info.uid !== process.getuid()) {
117
+ throw new Error(`Pi Pet desktop config must be owned by the current user: ${path}`);
118
+ }
119
+ }
120
+
121
+ export async function loadDesktopConfig(
122
+ path = desktopConfigPath(),
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): Promise<DesktopConfig> {
125
+ const environmentConfig = configFromEnvironment(env);
126
+ if (environmentConfig) return environmentConfig;
127
+ const handle = await openDesktopConfig(path);
128
+ try {
129
+ await validateConfigFile(handle, path);
130
+ const raw = await handle.readFile("utf8");
131
+ return parseDesktopConfig(JSON.parse(raw) as unknown);
132
+ } catch (error) {
133
+ throw new Error(`Invalid Pi Pet desktop config at ${path}: ${(error as Error).message}`);
134
+ } finally {
135
+ await handle.close();
136
+ }
137
+ }
138
+
139
+ export function desktopDisplayUrl(config: DesktopConfig, attention: "normal" | "quiet" = "normal"): string {
140
+ const url = new URL(config.gippityUrl);
141
+ url.pathname = "/_gippity/apps/pi-pet/";
142
+ url.searchParams.set("shell", "desktop");
143
+ if (attention === "quiet") url.searchParams.set("attention", "quiet");
144
+ return url.toString();
145
+ }
@@ -0,0 +1,150 @@
1
+ import { createConnection } from "node:net";
2
+ import { join } from "node:path";
3
+ import { type DesktopCursorPosition, parseDesktopCursorPosition } from "./bridge.ts";
4
+
5
+ const HYPRLAND_SIGNATURE_PATTERN = /^[a-zA-Z0-9._-]{1,200}$/;
6
+ const MAX_CURSOR_RESPONSE_BYTES = 1_024;
7
+ const MAX_CLIENTS_RESPONSE_BYTES = 1024 * 1024;
8
+ const CURSOR_TIMEOUT_MS = 200;
9
+
10
+ export interface DesktopWindowBounds {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export function hyprlandCursorSocket(env: NodeJS.ProcessEnv): string | undefined {
18
+ const runtime = env["XDG_RUNTIME_DIR"];
19
+ const signature = env["HYPRLAND_INSTANCE_SIGNATURE"];
20
+ if (!(runtime && signature && runtime.startsWith("/") && HYPRLAND_SIGNATURE_PATTERN.test(signature)))
21
+ return undefined;
22
+ return join(runtime, "hypr", signature, ".socket.sock");
23
+ }
24
+
25
+ export function parseHyprlandCursorResponse(value: string): DesktopCursorPosition {
26
+ const position = parseDesktopCursorPosition(JSON.parse(value) as unknown);
27
+ if (!position) throw new Error("Hyprland returned an empty cursor position.");
28
+ return position;
29
+ }
30
+
31
+ function parseCoordinatePair(value: unknown, label: string): [number, number] {
32
+ if (
33
+ !Array.isArray(value) ||
34
+ value.length !== 2 ||
35
+ typeof value[0] !== "number" ||
36
+ !Number.isFinite(value[0]) ||
37
+ typeof value[1] !== "number" ||
38
+ !Number.isFinite(value[1])
39
+ ) {
40
+ throw new Error(`Hyprland ${label} is invalid.`);
41
+ }
42
+ return [value[0], value[1]];
43
+ }
44
+
45
+ export function parseHyprlandClientBounds(value: string, pid: number): DesktopWindowBounds {
46
+ const clients = JSON.parse(value) as unknown;
47
+ if (!Array.isArray(clients)) throw new Error("Hyprland clients response must be an array.");
48
+ const client = clients.find(
49
+ (candidate) =>
50
+ candidate !== null &&
51
+ typeof candidate === "object" &&
52
+ !Array.isArray(candidate) &&
53
+ (candidate as Record<string, unknown>)["pid"] === pid,
54
+ );
55
+ if (!client) throw new Error(`Hyprland did not report the Pi Pet window for process ${pid}.`);
56
+ const input = client as Record<string, unknown>;
57
+ const [x, y] = parseCoordinatePair(input["at"], "window position");
58
+ const [width, height] = parseCoordinatePair(input["size"], "window size");
59
+ if (width < 1 || height < 1 || width > 4_096 || height > 4_096) throw new Error("Hyprland window size is invalid.");
60
+ return { x, y, width, height };
61
+ }
62
+
63
+ async function readHyprlandCommand<T>(
64
+ path: string,
65
+ command: string,
66
+ maximumBytes: number,
67
+ responseComplete: (response: string) => boolean,
68
+ parse: (response: string) => T,
69
+ ): Promise<T> {
70
+ return await new Promise((resolve, reject) => {
71
+ const socket = createConnection(path);
72
+ let response = "";
73
+ let completed = false;
74
+ const fail = (error: Error): void => {
75
+ if (completed) return;
76
+ completed = true;
77
+ socket.destroy();
78
+ reject(error);
79
+ };
80
+ socket.setEncoding("utf8");
81
+ socket.setTimeout(CURSOR_TIMEOUT_MS);
82
+ socket.on("connect", () => socket.end(command));
83
+ socket.on("data", (chunk: string) => {
84
+ response += chunk;
85
+ if (Buffer.byteLength(response) > maximumBytes) {
86
+ fail(new Error("Hyprland returned an oversized IPC response."));
87
+ return;
88
+ }
89
+ if (!responseComplete(response.trimEnd())) return;
90
+ try {
91
+ const result = parse(response);
92
+ completed = true;
93
+ socket.destroy();
94
+ resolve(result);
95
+ } catch (error) {
96
+ fail(error instanceof Error ? error : new Error("Hyprland returned an invalid IPC response."));
97
+ }
98
+ });
99
+ socket.on("timeout", () => fail(new Error("Hyprland cursor IPC timed out.")));
100
+ socket.on("error", (error) => fail(error));
101
+ socket.on("end", () => {
102
+ if (completed) return;
103
+ try {
104
+ const result = parse(response);
105
+ completed = true;
106
+ resolve(result);
107
+ } catch (error) {
108
+ fail(error instanceof Error ? error : new Error("Hyprland returned an invalid IPC response."));
109
+ }
110
+ });
111
+ });
112
+ }
113
+
114
+ export async function readHyprlandCursor(path: string): Promise<DesktopCursorPosition> {
115
+ return await readHyprlandCommand(
116
+ path,
117
+ "j/cursorpos",
118
+ MAX_CURSOR_RESPONSE_BYTES,
119
+ (response) => response.endsWith("}"),
120
+ parseHyprlandCursorResponse,
121
+ );
122
+ }
123
+
124
+ async function readHyprlandClientBounds(path: string, pid: number): Promise<DesktopWindowBounds> {
125
+ return await readHyprlandCommand(
126
+ path,
127
+ "j/clients",
128
+ MAX_CLIENTS_RESPONSE_BYTES,
129
+ (response) => response.endsWith("]"),
130
+ (response) => parseHyprlandClientBounds(response, pid),
131
+ );
132
+ }
133
+
134
+ export function createCursorReader(
135
+ env: NodeJS.ProcessEnv,
136
+ fallback: () => DesktopCursorPosition,
137
+ ): () => Promise<DesktopCursorPosition> {
138
+ const socket = hyprlandCursorSocket(env);
139
+ if (socket) return async () => await readHyprlandCursor(socket);
140
+ return async () => fallback();
141
+ }
142
+
143
+ export function createHyprlandBoundsReader(
144
+ env: NodeJS.ProcessEnv,
145
+ pid: number,
146
+ ): (() => Promise<DesktopWindowBounds>) | undefined {
147
+ const socket = hyprlandCursorSocket(env);
148
+ if (!socket) return undefined;
149
+ return async () => await readHyprlandClientBounds(socket, pid);
150
+ }