@shimabell06/cloud-arch-icon-browser 0.2.1 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @shimabell06/cloud-arch-icon-browser
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 46753f3: Add an Experimental Windows PowerPoint Copy all workflow for Tray items through a capability-scoped localhost bridge, with bounded transient PNG handling and perceived-size normalization. Physical Windows/PowerPoint paste validation remains tracked by #57 and is not yet a formal compatibility claim.
8
+ - 9aeba15: Use a stable canonical localhost origin for packaged runs, reuse an already-running matching local instance, and let supported browsers reopen a previously validated local ZIP through a persisted File System Access handle without storing package bytes.
9
+ - 2e1045e: Add a session-only Tray with quantity/order controls, multi-select and drag collection, reusable locally persisted Saved Sets, recently-used history, and frequently-used shortcuts.
10
+
11
+ ### Patch Changes
12
+
13
+ - 2666a3e: Reduce initial and scroll-time icon preview latency with shared prefetch observation and bounded viewport-prioritized preview work.
14
+
3
15
  ## 0.2.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -17,21 +17,43 @@ Cloud Arch Icon Browser is an independent open-source project. It is not affilia
17
17
 
18
18
  3. Choose the downloaded ZIP in the browser.
19
19
 
20
- The command starts a temporary localhost server bound to `127.0.0.1` and opens the app in your default browser. The selected ZIP is processed locally and is not uploaded or persisted by the application.
20
+ The command serves the app from the stable local origin `http://127.0.0.1:41731/` and opens it in your default browser. A second invocation reuses the same running app; if another process occupies that port, the CLI reports an error rather than switching origins. The selected ZIP is processed locally and its bytes are never uploaded or copied into application storage.
21
+
22
+ On browsers that support the File System Access API, a successfully validated selection can remember only the local file handle in IndexedDB. A later launch can use `Open previous ZIP` to request/read that file under normal browser permission rules, and `Forget previous ZIP reference` removes the handle. The app does not trigger a file-permission prompt automatically on page load. Unsupported browsers continue to use the normal ZIP picker.
21
23
 
22
24
  ## Features
23
25
 
24
26
  - Fast search across icon names, original filenames, and category paths.
25
27
  - Category browsing with explicit search filter chips.
26
- - Favorites, recent icons, recent searches, and Grid / Compact views.
28
+ - Favorites, recently used icons, recent searches, Frequently used shortcuts, Tray, Saved Sets, and Grid / Compact views.
27
29
  - Centered icon details with real package metadata.
28
30
  - Copy icons as transparent 512×512 PNG images for compatible clipboard workflows such as Windows PowerPoint and Excel.
29
- - Copy SVG where supported or download the original SVG with its original filename.
31
+ - Copy original SVG source text where supported or download the original SVG with its original filename.
32
+ - Experimental Windows PowerPoint `Copy all` from the Tray in the packaged `npx` runtime, preserving Tray order and quantities without a flattened-image fallback.
30
33
  - System, Light, and Dark themes with responsive keyboard-accessible navigation.
31
- - Local-only package processing with no automatic runtime network access.
34
+ - Local-only package processing with no automatic runtime network access; supported browsers can remember only a local file reference for the previous ZIP.
32
35
 
33
36
  `Copy image` depends on browser Clipboard API support and permission. If image clipboard writes are unavailable or denied, the app reports the failure and the original SVG remains available for download.
34
37
 
38
+ ### Experimental PowerPoint Copy all
39
+
40
+ On Windows, the packaged `npx` runtime exposes an explicitly Experimental `Copy all` action in the Tray. It prepares up to 36 transient 512×512 PNG representations, preserves Tray order and quantities, asks desktop PowerPoint to copy them as a multi-shape selection, and then lets you paste once in PowerPoint. Generated PNGs and temporary Office files stay local and are deleted after the operation. There is no cloud service and no flattened combined-image fallback.
41
+
42
+ This workflow is **not yet formally supported** because real-machine Windows 11 + current Chromium + Microsoft 365 PowerPoint validation is still pending. The UI shows an Experimental warning on first use. If the later validation fails, the feature will be disabled or removed rather than replaced by a flattened image.
43
+
44
+ The feature is enabled by default for evaluation. To disable it locally, run this in the app's browser developer console and reload:
45
+
46
+ ```js
47
+ localStorage.setItem(
48
+ "cloud-arch-icon-browser:feature:powerpoint-copy-all",
49
+ "off",
50
+ );
51
+ ```
52
+
53
+ Delete that key (or set it to `on`) to return to the built-in default. The existing single-icon `Copy image` workflow remains the stable cross-platform baseline.
54
+
55
+ True vector clipboard copy and direct browser-to-PowerPoint drag are not part of this Experimental release. The `Copy SVG source` action copies SVG markup text; it is not a vector-image clipboard operation.
56
+
35
57
  ## Official Azure icons
36
58
 
37
59
  Download the current package from [Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/icons/) and use the icons in accordance with Microsoft's terms.
@@ -0,0 +1,406 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ export const POWERPOINT_CAPABILITY_PATH = "/__bridge/powerpoint/capability";
7
+ export const POWERPOINT_COPY_ALL_PATH = "/__bridge/powerpoint/copy-all";
8
+ export const POWERPOINT_CAPABILITY_HEADER = "x-cloud-arch-capability";
9
+ export const MAX_POWERPOINT_OBJECTS = 36;
10
+ const MAX_UNIQUE_IMAGES = 36;
11
+ const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
12
+ const MAX_REQUEST_BYTES = 12 * 1024 * 1024;
13
+ const POWERPOINT_TIMEOUT_MS = 20_000;
14
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
15
+ class PowerPointAutomationError extends Error {
16
+ kind;
17
+ constructor(message, kind) {
18
+ super(message);
19
+ this.kind = kind;
20
+ this.name = "PowerPointAutomationError";
21
+ }
22
+ }
23
+ export function createPowerPointBridge(options = {}) {
24
+ const platform = options.platform ?? process.platform;
25
+ const capabilityToken = options.capabilityToken ?? randomBytes(32).toString("base64url");
26
+ const runner = options.runner ?? runPowerPointCopy;
27
+ let busy = false;
28
+ return {
29
+ matches: (rawUrl) => {
30
+ const pathname = requestPathname(rawUrl);
31
+ return (pathname === POWERPOINT_CAPABILITY_PATH ||
32
+ pathname === POWERPOINT_COPY_ALL_PATH);
33
+ },
34
+ handle: async (request, response, port) => {
35
+ const pathname = requestPathname(request.url);
36
+ if (pathname === POWERPOINT_CAPABILITY_PATH) {
37
+ if (request.method !== "GET") {
38
+ response.setHeader("Allow", "GET");
39
+ sendJson(response, 405, { error: "Method Not Allowed" });
40
+ return;
41
+ }
42
+ const available = platform === "win32";
43
+ sendJson(response, 200, {
44
+ available,
45
+ experimental: true,
46
+ maxObjects: MAX_POWERPOINT_OBJECTS,
47
+ capability: available ? capabilityToken : null,
48
+ reason: available ? null : "windows-npx-only",
49
+ });
50
+ return;
51
+ }
52
+ if (pathname !== POWERPOINT_COPY_ALL_PATH) {
53
+ sendJson(response, 404, { error: "Not Found" });
54
+ return;
55
+ }
56
+ if (request.method !== "POST") {
57
+ response.setHeader("Allow", "POST");
58
+ sendJson(response, 405, { error: "Method Not Allowed" });
59
+ return;
60
+ }
61
+ if (platform !== "win32") {
62
+ sendJson(response, 501, {
63
+ error: "PowerPoint Copy all is available only in the Windows npx runtime.",
64
+ });
65
+ return;
66
+ }
67
+ const expectedOrigin = `http://127.0.0.1:${port}`;
68
+ if (request.headers.origin !== expectedOrigin) {
69
+ sendJson(response, 403, { error: "Forbidden" });
70
+ return;
71
+ }
72
+ if (request.headers[POWERPOINT_CAPABILITY_HEADER] !== capabilityToken) {
73
+ sendJson(response, 403, { error: "Forbidden" });
74
+ return;
75
+ }
76
+ if (!isJsonContentType(request.headers["content-type"])) {
77
+ sendJson(response, 415, { error: "Expected application/json." });
78
+ return;
79
+ }
80
+ if (busy) {
81
+ sendJson(response, 409, {
82
+ error: "A PowerPoint copy operation is already in progress.",
83
+ });
84
+ return;
85
+ }
86
+ let items;
87
+ try {
88
+ const raw = await readBoundedBody(request);
89
+ items = parseCopyPayload(JSON.parse(raw));
90
+ }
91
+ catch (error) {
92
+ if (error instanceof RequestTooLargeError) {
93
+ sendJson(response, 413, { error: error.message });
94
+ return;
95
+ }
96
+ sendJson(response, 400, {
97
+ error: error instanceof Error ? error.message : "Invalid Copy all payload.",
98
+ });
99
+ return;
100
+ }
101
+ busy = true;
102
+ try {
103
+ await runner(items);
104
+ sendJson(response, 200, {
105
+ ok: true,
106
+ objectCount: items.reduce((sum, item) => sum + item.quantity, 0),
107
+ });
108
+ }
109
+ catch (error) {
110
+ const message = error instanceof PowerPointAutomationError
111
+ ? error.message
112
+ : "PowerPoint automation failed. Try again with desktop PowerPoint available.";
113
+ sendJson(response, 503, { error: message });
114
+ }
115
+ finally {
116
+ busy = false;
117
+ }
118
+ },
119
+ };
120
+ }
121
+ export function parseCopyPayload(value) {
122
+ if (!isRecord(value) || !Array.isArray(value.items)) {
123
+ throw new Error("Copy all payload must contain an items array.");
124
+ }
125
+ if (value.items.length === 0 || value.items.length > MAX_UNIQUE_IMAGES) {
126
+ throw new Error(`Copy all supports 1-${MAX_UNIQUE_IMAGES} unique images.`);
127
+ }
128
+ let total = 0;
129
+ const parsed = [];
130
+ for (const item of value.items) {
131
+ if (!isRecord(item))
132
+ throw new Error("Each Copy all item must be an object.");
133
+ const keys = Object.keys(item).sort();
134
+ if (keys.length !== 2 || keys[0] !== "pngBase64" || keys[1] !== "quantity") {
135
+ throw new Error("Copy all items may contain only pngBase64 and quantity.");
136
+ }
137
+ if (typeof item.quantity !== "number" ||
138
+ !Number.isSafeInteger(item.quantity) ||
139
+ item.quantity < 1 ||
140
+ item.quantity > MAX_POWERPOINT_OBJECTS) {
141
+ throw new Error("Copy all item quantity is invalid.");
142
+ }
143
+ if (typeof item.pngBase64 !== "string") {
144
+ throw new Error("Copy all image must be base64 PNG data.");
145
+ }
146
+ const png = decodePng(item.pngBase64);
147
+ total += item.quantity;
148
+ if (total > MAX_POWERPOINT_OBJECTS) {
149
+ throw new Error(`Copy all supports at most ${MAX_POWERPOINT_OBJECTS} objects.`);
150
+ }
151
+ parsed.push({ png, quantity: item.quantity });
152
+ }
153
+ return parsed;
154
+ }
155
+ async function runPowerPointCopy(items) {
156
+ const directory = await mkdtemp(join(tmpdir(), "cloud-arch-icon-browser-powerpoint-"));
157
+ try {
158
+ const manifestItems = [];
159
+ for (let index = 0; index < items.length; index += 1) {
160
+ const item = items[index];
161
+ if (item === undefined)
162
+ continue;
163
+ const path = join(directory, `${String(index).padStart(2, "0")}.png`);
164
+ await writeFile(path, item.png, { flag: "wx" });
165
+ manifestItems.push({ path, quantity: item.quantity });
166
+ }
167
+ const manifestPath = join(directory, "manifest.json");
168
+ await writeFile(manifestPath, JSON.stringify({
169
+ total: items.reduce((sum, item) => sum + item.quantity, 0),
170
+ items: manifestItems,
171
+ }), { encoding: "utf8", flag: "wx" });
172
+ await invokePowerPointAutomation(manifestPath);
173
+ }
174
+ finally {
175
+ await rm(directory, { recursive: true, force: true });
176
+ }
177
+ }
178
+ function invokePowerPointAutomation(manifestPath) {
179
+ return new Promise((resolvePromise, rejectPromise) => {
180
+ const encodedCommand = Buffer.from(POWERPOINT_COPY_SCRIPT, "utf16le").toString("base64");
181
+ const child = spawn("powershell.exe", [
182
+ "-NoLogo",
183
+ "-NoProfile",
184
+ "-NonInteractive",
185
+ "-ExecutionPolicy",
186
+ "Bypass",
187
+ "-EncodedCommand",
188
+ encodedCommand,
189
+ ], {
190
+ windowsHide: true,
191
+ stdio: ["ignore", "ignore", "pipe"],
192
+ env: { ...process.env, CAB_POWERPOINT_MANIFEST: manifestPath },
193
+ });
194
+ let stderr = "";
195
+ let settled = false;
196
+ const finish = (error) => {
197
+ if (settled)
198
+ return;
199
+ settled = true;
200
+ clearTimeout(timeout);
201
+ if (error)
202
+ rejectPromise(error);
203
+ else
204
+ resolvePromise();
205
+ };
206
+ child.stderr.on("data", (chunk) => {
207
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8192);
208
+ });
209
+ child.once("error", (error) => {
210
+ if ("code" in error && error.code === "ENOENT") {
211
+ finish(new PowerPointAutomationError("Windows PowerShell is unavailable, so PowerPoint Copy all cannot run.", "not-installed"));
212
+ return;
213
+ }
214
+ finish(new PowerPointAutomationError("PowerPoint automation could not be started.", "automation-failed"));
215
+ });
216
+ child.once("close", (code) => {
217
+ if (code === 0) {
218
+ finish();
219
+ return;
220
+ }
221
+ if (stderr.includes("CAB_POWERPOINT_NOT_INSTALLED")) {
222
+ finish(new PowerPointAutomationError("Desktop Microsoft PowerPoint was not found. Install/open desktop PowerPoint and try again.", "not-installed"));
223
+ return;
224
+ }
225
+ finish(new PowerPointAutomationError("PowerPoint could not prepare the multi-object clipboard. Close modal dialogs in PowerPoint and try again.", "automation-failed"));
226
+ });
227
+ const timeout = setTimeout(() => {
228
+ child.kill();
229
+ finish(new PowerPointAutomationError("PowerPoint Copy all timed out. Close PowerPoint dialogs and try again.", "timeout"));
230
+ }, POWERPOINT_TIMEOUT_MS);
231
+ timeout.unref();
232
+ });
233
+ }
234
+ function decodePng(base64) {
235
+ if (base64.length === 0 ||
236
+ base64.length % 4 !== 0 ||
237
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
238
+ throw new Error("Copy all image contains invalid base64 data.");
239
+ }
240
+ const png = Buffer.from(base64, "base64");
241
+ if (png.length === 0 || png.length > MAX_IMAGE_BYTES) {
242
+ throw new Error("Copy all PNG size is outside the allowed range.");
243
+ }
244
+ if (png.length < PNG_SIGNATURE.length || !png.subarray(0, 8).equals(PNG_SIGNATURE)) {
245
+ throw new Error("Copy all accepts PNG images only.");
246
+ }
247
+ return png;
248
+ }
249
+ async function readBoundedBody(request) {
250
+ const declared = Number(request.headers["content-length"] ?? 0);
251
+ if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
252
+ throw new RequestTooLargeError();
253
+ }
254
+ const chunks = [];
255
+ let total = 0;
256
+ for await (const chunk of request) {
257
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
258
+ total += buffer.length;
259
+ if (total > MAX_REQUEST_BYTES)
260
+ throw new RequestTooLargeError();
261
+ chunks.push(buffer);
262
+ }
263
+ if (total === 0)
264
+ throw new Error("Copy all payload is empty.");
265
+ return Buffer.concat(chunks).toString("utf8");
266
+ }
267
+ class RequestTooLargeError extends Error {
268
+ constructor() {
269
+ super("Copy all payload is too large.");
270
+ this.name = "RequestTooLargeError";
271
+ }
272
+ }
273
+ function requestPathname(rawUrl) {
274
+ if (!rawUrl?.startsWith("/") || rawUrl.startsWith("//"))
275
+ return null;
276
+ const queryIndex = rawUrl.indexOf("?");
277
+ return queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
278
+ }
279
+ function isJsonContentType(value) {
280
+ return value?.toLowerCase().split(";", 1)[0]?.trim() === "application/json";
281
+ }
282
+ function sendJson(response, statusCode, value) {
283
+ const body = Buffer.from(JSON.stringify(value));
284
+ response.statusCode = statusCode;
285
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
286
+ response.setHeader("Content-Length", String(body.length));
287
+ response.setHeader("Cache-Control", "no-store");
288
+ response.end(body);
289
+ }
290
+ function isRecord(value) {
291
+ return typeof value === "object" && value !== null && !Array.isArray(value);
292
+ }
293
+ const POWERPOINT_COPY_SCRIPT = String.raw `
294
+ $ErrorActionPreference = "Stop"
295
+ Set-StrictMode -Version Latest
296
+ $MsoFalse = 0
297
+ $MsoTrue = -1
298
+ $PpLayoutBlank = 12
299
+ $manifestPath = $env:CAB_POWERPOINT_MANIFEST
300
+ $application = $null
301
+ $presentation = $null
302
+ $slide = $null
303
+ $shapeRange = $null
304
+ $ownedApplication = $false
305
+
306
+ function Release-ComObject {
307
+ param($Object)
308
+ if ($null -ne $Object -and [Runtime.InteropServices.Marshal]::IsComObject($Object)) {
309
+ try { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($Object) } catch {}
310
+ }
311
+ }
312
+
313
+ try {
314
+ if ([string]::IsNullOrWhiteSpace($manifestPath) -or -not (Test-Path -LiteralPath $manifestPath)) {
315
+ throw "Missing internal Copy all manifest."
316
+ }
317
+
318
+ try {
319
+ $application = [Runtime.InteropServices.Marshal]::GetActiveObject("PowerPoint.Application")
320
+ }
321
+ catch {
322
+ try {
323
+ $application = New-Object -ComObject PowerPoint.Application
324
+ $ownedApplication = $true
325
+ }
326
+ catch {
327
+ [Console]::Error.WriteLine("CAB_POWERPOINT_NOT_INSTALLED")
328
+ exit 21
329
+ }
330
+ }
331
+
332
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
333
+ $presentation = $application.Presentations.Add($MsoFalse)
334
+ $slide = $presentation.Slides.Add(1, $PpLayoutBlank)
335
+ $shapeNames = New-Object System.Collections.Generic.List[object]
336
+ $columns = [Math]::Ceiling([Math]::Sqrt([double]$manifest.total))
337
+ $index = 0
338
+
339
+ foreach ($item in $manifest.items) {
340
+ for ($copy = 0; $copy -lt [int]$item.quantity; $copy++) {
341
+ $column = $index % $columns
342
+ $row = [Math]::Floor($index / $columns)
343
+ $left = 10 + ($column * 86)
344
+ $top = 10 + ($row * 86)
345
+ $shape = $slide.Shapes.AddPicture(
346
+ [string]$item.path,
347
+ $MsoFalse,
348
+ $MsoTrue,
349
+ [single]$left,
350
+ [single]$top,
351
+ [single]72,
352
+ [single]72
353
+ )
354
+ try {
355
+ $null = $shapeNames.Add([string]$shape.Name)
356
+ }
357
+ finally {
358
+ Release-ComObject $shape
359
+ }
360
+ $index++
361
+ }
362
+ }
363
+
364
+ $shapeRange = $slide.Shapes.Range([object[]]$shapeNames.ToArray())
365
+ $shapeRange.Copy()
366
+ Start-Sleep -Milliseconds 350
367
+ $presentation.Saved = $MsoTrue
368
+ $presentation.Close()
369
+ Release-ComObject $shapeRange
370
+ $shapeRange = $null
371
+ Release-ComObject $slide
372
+ $slide = $null
373
+ Release-ComObject $presentation
374
+ $presentation = $null
375
+
376
+ if ($ownedApplication) {
377
+ $application.Quit()
378
+ }
379
+ Release-ComObject $application
380
+ $application = $null
381
+ [GC]::Collect()
382
+ [GC]::WaitForPendingFinalizers()
383
+ exit 0
384
+ }
385
+ catch {
386
+ [Console]::Error.WriteLine("CAB_POWERPOINT_AUTOMATION_FAILED")
387
+ exit 22
388
+ }
389
+ finally {
390
+ if ($null -ne $shapeRange) { Release-ComObject $shapeRange }
391
+ if ($null -ne $slide) { Release-ComObject $slide }
392
+ if ($null -ne $presentation) {
393
+ try {
394
+ $presentation.Saved = $MsoTrue
395
+ $presentation.Close()
396
+ } catch {}
397
+ Release-ComObject $presentation
398
+ }
399
+ if ($null -ne $application) {
400
+ if ($ownedApplication) { try { $application.Quit() } catch {} }
401
+ Release-ComObject $application
402
+ }
403
+ [GC]::Collect()
404
+ [GC]::WaitForPendingFinalizers()
405
+ }
406
+ `;
package/cli/server.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import { readFile, readdir, realpath } from "node:fs/promises";
2
- import { createServer, } from "node:http";
2
+ import { createServer, request as httpRequest, } from "node:http";
3
3
  import { extname, resolve, sep } from "node:path";
4
+ import { createPowerPointBridge, } from "./powerpoint-bridge.js";
4
5
  const LOOPBACK_HOST = "127.0.0.1";
6
+ export const CANONICAL_PORT = 41731;
7
+ export const APP_INSTANCE_HEADER = "X-Cloud-Arch-Icon-Browser-Instance";
8
+ const APP_INSTANCE_HEADER_VALUE = "1";
9
+ const EXISTING_INSTANCE_PROBE_TIMEOUT_MS = 750;
5
10
  const CONTENT_SECURITY_POLICY = [
6
11
  "default-src 'self'",
7
12
  "script-src 'self'",
@@ -37,48 +42,69 @@ const MIME_TYPES = new Map([
37
42
  export async function startStaticServer(options) {
38
43
  const rootDirectory = await realpath(options.rootDirectory);
39
44
  const staticAssets = await loadStaticAssets(rootDirectory);
40
- let selectedPort = 0;
45
+ const powerPointBridge = options.powerPointBridge ?? createPowerPointBridge();
46
+ const requestedPort = options.port ?? CANONICAL_PORT;
47
+ let selectedPort = requestedPort;
41
48
  const server = createServer((request, response) => {
42
- try {
43
- handleRequest(request, response, staticAssets, selectedPort);
44
- }
45
- catch {
49
+ void handleRequest(request, response, staticAssets, powerPointBridge, selectedPort).catch(() => {
46
50
  if (!response.headersSent) {
47
51
  sendText(request, response, 500, "Internal Server Error\n");
48
52
  return;
49
53
  }
50
54
  response.destroy();
51
- }
55
+ });
52
56
  });
53
57
  server.on("clientError", (_error, socket) => {
54
58
  if (socket.writable) {
55
59
  socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
56
60
  }
57
61
  });
58
- await listenOnAvailablePort(server);
62
+ try {
63
+ await listenOnPort(server, requestedPort);
64
+ }
65
+ catch (error) {
66
+ if (requestedPort > 0 && isAddressInUseError(error)) {
67
+ const reused = await probeExistingInstance(requestedPort);
68
+ if (reused) {
69
+ return {
70
+ port: requestedPort,
71
+ url: appUrl(requestedPort),
72
+ reused: true,
73
+ close: () => Promise.resolve(),
74
+ };
75
+ }
76
+ throw new Error(`Port ${requestedPort} is already in use by another process. Stop that process and run cloud-arch-icon-browser again.`, { cause: error });
77
+ }
78
+ throw error;
79
+ }
59
80
  const address = server.address();
60
81
  if (address === null || typeof address === "string") {
61
82
  await closeServer(server);
62
83
  throw new Error("Unable to determine the localhost server port.");
63
84
  }
64
85
  selectedPort = address.port;
65
- const url = `http://${LOOPBACK_HOST}:${selectedPort}/`;
86
+ const url = appUrl(selectedPort);
66
87
  let closePromise = null;
67
88
  return {
68
89
  port: selectedPort,
69
90
  url,
91
+ reused: false,
70
92
  close: () => {
71
93
  closePromise ??= closeServer(server);
72
94
  return closePromise;
73
95
  },
74
96
  };
75
97
  }
76
- function handleRequest(request, response, staticAssets, port) {
98
+ async function handleRequest(request, response, staticAssets, powerPointBridge, port) {
77
99
  applySecurityHeaders(response);
78
100
  if (!isExpectedHost(request.headers.host, port)) {
79
101
  sendText(request, response, 403, "Forbidden\n");
80
102
  return;
81
103
  }
104
+ if (powerPointBridge.matches(request.url)) {
105
+ await powerPointBridge.handle(request, response, port);
106
+ return;
107
+ }
82
108
  if (request.method !== "GET" && request.method !== "HEAD") {
83
109
  response.setHeader("Allow", "GET, HEAD");
84
110
  sendText(request, response, 405, "Method Not Allowed\n");
@@ -192,6 +218,7 @@ function cacheControlFor(relativePath) {
192
218
  return "no-cache";
193
219
  }
194
220
  function applySecurityHeaders(response) {
221
+ response.setHeader(APP_INSTANCE_HEADER, APP_INSTANCE_HEADER_VALUE);
195
222
  response.setHeader("Content-Security-Policy", CONTENT_SECURITY_POLICY);
196
223
  response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
197
224
  response.setHeader("Permissions-Policy", permissionsPolicy());
@@ -224,7 +251,7 @@ function sendText(request, response, statusCode, body) {
224
251
  }
225
252
  response.end(body);
226
253
  }
227
- function listenOnAvailablePort(server) {
254
+ function listenOnPort(server, port) {
228
255
  return new Promise((resolvePromise, rejectPromise) => {
229
256
  const handleError = (error) => {
230
257
  server.off("listening", handleListening);
@@ -236,9 +263,45 @@ function listenOnAvailablePort(server) {
236
263
  };
237
264
  server.once("error", handleError);
238
265
  server.once("listening", handleListening);
239
- server.listen({ host: LOOPBACK_HOST, port: 0 });
266
+ server.listen({ host: LOOPBACK_HOST, port });
240
267
  });
241
268
  }
269
+ function probeExistingInstance(port) {
270
+ return new Promise((resolvePromise) => {
271
+ let settled = false;
272
+ const finish = (result) => {
273
+ if (settled)
274
+ return;
275
+ settled = true;
276
+ resolvePromise(result);
277
+ };
278
+ const clientRequest = httpRequest({
279
+ host: LOOPBACK_HOST,
280
+ port,
281
+ path: "/",
282
+ method: "HEAD",
283
+ headers: { Host: `${LOOPBACK_HOST}:${port}` },
284
+ }, (response) => {
285
+ const header = response.headers[APP_INSTANCE_HEADER.toLowerCase()];
286
+ response.resume();
287
+ finish(header === APP_INSTANCE_HEADER_VALUE);
288
+ });
289
+ clientRequest.setTimeout(EXISTING_INSTANCE_PROBE_TIMEOUT_MS, () => {
290
+ clientRequest.destroy();
291
+ finish(false);
292
+ });
293
+ clientRequest.on("error", () => finish(false));
294
+ clientRequest.end();
295
+ });
296
+ }
297
+ function isAddressInUseError(error) {
298
+ return (error instanceof Error &&
299
+ "code" in error &&
300
+ error.code === "EADDRINUSE");
301
+ }
302
+ function appUrl(port) {
303
+ return `http://${LOOPBACK_HOST}:${port}/`;
304
+ }
242
305
  function closeServer(server) {
243
306
  if (!server.listening)
244
307
  return Promise.resolve();