@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 +12 -0
- package/README.md +26 -4
- package/cli/powerpoint-bridge.js +406 -0
- package/cli/server.js +75 -12
- package/dist/assets/index-CF_evZHB.js +9 -0
- package/dist/assets/index-jxRCnk3-.css +2 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/dist/assets/index-BPlnSTeH.css +0 -2
- package/dist/assets/index-CwCiU94p.js +0 -9
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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();
|