@pablopunk/webr 0.3.9 → 0.3.11

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.
@@ -345,6 +345,10 @@ async function setProjectIcon(project, png) {
345
345
  body: png
346
346
  })).ok) throw new Error("Could not save this icon.");
347
347
  }
348
+ async function fetchIconCandidates(project) {
349
+ const response = await fetch(`/api/projects/${encodeURIComponent(project.machineId ?? "local")}/${encodeURIComponent(project.id)}/icon-candidates`);
350
+ return response.ok ? await response.json() : [];
351
+ }
348
352
  async function resetProjectIcon(project) {
349
353
  if (!(await fetch(projectIconUrl(project), { method: "DELETE" })).ok) throw new Error("Could not reset this icon.");
350
354
  }
@@ -830,7 +834,8 @@ var uploadErrors = {
830
834
  file_too_large: "This file is larger than 5 MB.",
831
835
  empty_file: "This file is empty.",
832
836
  unsupported_image: "Only PNG, JPEG, GIF and WebP images can be attached.",
833
- uploads_unsupported_target: "Images can be attached only on the Local machine for now.",
837
+ uploads_unsupported_target: "Files cannot be attached on this machine yet.",
838
+ upload_failed: "The file could not be sent to the machine.",
834
839
  machine_disconnected: "The machine is not connected."
835
840
  };
836
841
  var isAttachableImage = (file) => attachableTypes.includes(file.type);
@@ -2456,6 +2461,22 @@ function ProjectIconDialog({ project, open, onClose, onError }) {
2456
2461
  const picker = useRef(null);
2457
2462
  const [dragging, setDragging] = useState(false);
2458
2463
  const [busy, setBusy] = useState(false);
2464
+ const [candidates, setCandidates] = useState([]);
2465
+ useEffect(() => {
2466
+ if (!open) return;
2467
+ let current = true;
2468
+ fetchIconCandidates(project).then((found) => {
2469
+ if (current) setCandidates(found);
2470
+ }).catch(() => {});
2471
+ return () => {
2472
+ current = false;
2473
+ setCandidates([]);
2474
+ };
2475
+ }, [
2476
+ open,
2477
+ project.id,
2478
+ project.machineId
2479
+ ]);
2459
2480
  const run = async (action) => {
2460
2481
  setBusy(true);
2461
2482
  try {
@@ -2470,6 +2491,7 @@ function ProjectIconDialog({ project, open, onClose, onError }) {
2470
2491
  const upload = (file) => {
2471
2492
  if (file) run(async () => setProjectIcon(project, await squarePng(file)));
2472
2493
  };
2494
+ const choose = (candidate) => void run(async () => setProjectIcon(project, await squarePng(await (await fetch(candidate.url)).blob())));
2473
2495
  return /* @__PURE__ */ jsx(Dialog.Root, {
2474
2496
  open,
2475
2497
  onOpenChange: (next) => {
@@ -2482,6 +2504,10 @@ function ProjectIconDialog({ project, open, onClose, onError }) {
2482
2504
  className: "dialog-message",
2483
2505
  children: ["Change icon for ", project.name]
2484
2506
  }),
2507
+ /* @__PURE__ */ jsxs("div", {
2508
+ className: "icon-current",
2509
+ children: [/* @__PURE__ */ jsx(ProjectIcon, { project }), /* @__PURE__ */ jsx("span", { children: "Current icon" })]
2510
+ }),
2485
2511
  /* @__PURE__ */ jsxs("button", {
2486
2512
  type: "button",
2487
2513
  className: `icon-dropzone ${dragging ? "is-dragging" : ""}`,
@@ -2512,6 +2538,24 @@ function ProjectIconDialog({ project, open, onClose, onError }) {
2512
2538
  event.target.value = "";
2513
2539
  }
2514
2540
  }),
2541
+ candidates.length > 0 && /* @__PURE__ */ jsx("div", {
2542
+ className: "icon-candidates",
2543
+ role: "group",
2544
+ "aria-label": "Images found in this project",
2545
+ children: candidates.map((candidate) => /* @__PURE__ */ jsx("button", {
2546
+ type: "button",
2547
+ className: "icon-candidate",
2548
+ disabled: busy,
2549
+ title: candidate.name,
2550
+ "aria-label": `Use ${candidate.name}`,
2551
+ onClick: () => choose(candidate),
2552
+ children: /* @__PURE__ */ jsx("img", {
2553
+ src: candidate.url,
2554
+ alt: "",
2555
+ loading: "lazy"
2556
+ })
2557
+ }, candidate.url))
2558
+ }),
2515
2559
  /* @__PURE__ */ jsxs("div", {
2516
2560
  className: "dialog-actions",
2517
2561
  children: [/* @__PURE__ */ jsx("button", {
@@ -1,6 +1,6 @@
1
1
  import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
2
2
  import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
3
- import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
3
+ import { n as $$Base, t as App } from "./App_DDDJNk_6.mjs";
4
4
  //#region src/pages/threads/[id].astro
5
5
  var _id__exports = /* @__PURE__ */ __exportAll({
6
6
  default: () => $$Id,
@@ -1,6 +1,6 @@
1
1
  import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
2
2
  import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
3
- import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
3
+ import { n as $$Base, t as App } from "./App_DDDJNk_6.mjs";
4
4
  //#region src/pages/sessions/[session]/tabs/[tab].astro
5
5
  var _tab__exports = /* @__PURE__ */ __exportAll({
6
6
  default: () => $$Tab,
@@ -1,6 +1,6 @@
1
1
  import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
2
2
  import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
3
- import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
3
+ import { n as $$Base, t as App } from "./App_DDDJNk_6.mjs";
4
4
  //#region src/pages/new.astro
5
5
  var new_exports = /* @__PURE__ */ __exportAll({
6
6
  default: () => $$New,
@@ -1,6 +1,6 @@
1
1
  import { S as createAstro, V as __exportAll, i as renderComponent, u as renderTemplate } from "./server_dfdLOyV2.mjs";
2
2
  import { t as createComponent } from "./compiler_BnrQvXXD.mjs";
3
- import { n as $$Base, t as App } from "./App_C1bSzRXS.mjs";
3
+ import { n as $$Base, t as App } from "./App_DDDJNk_6.mjs";
4
4
  //#region src/pages/settings.astro
5
5
  var settings_exports = /* @__PURE__ */ __exportAll({
6
6
  default: () => $$Settings,
@@ -2426,10 +2426,10 @@ var renderers = [Object.assign({
2426
2426
  //#endregion
2427
2427
  //#region \0virtual:astro:pages
2428
2428
  var _page0 = () => import("./chunks/node_C8FqXy_n.mjs").then((n) => n.t);
2429
- var _page1 = () => import("./chunks/new_DC57I9zU.mjs");
2430
- var _page2 = () => import("./chunks/_tab__OowoBszk.mjs");
2431
- var _page3 = () => import("./chunks/settings_BIpDm2u1.mjs");
2432
- var _page4 = () => import("./chunks/_id__PkkT8hXq.mjs");
2429
+ var _page1 = () => import("./chunks/new_C-ziQti8.mjs");
2430
+ var _page2 = () => import("./chunks/_tab__D95X1O-n.mjs");
2431
+ var _page3 = () => import("./chunks/settings_BYiZ6OG8.mjs");
2432
+ var _page4 = () => import("./chunks/_id__Bg5VthsE.mjs");
2433
2433
  var _page5 = () => import("./chunks/index_Bvv1gpI0.mjs");
2434
2434
  var pageMap = /* @__PURE__ */ new Map([
2435
2435
  ["node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js", _page0],
@@ -2441,7 +2441,7 @@ var pageMap = /* @__PURE__ */ new Map([
2441
2441
  ]);
2442
2442
  //#endregion
2443
2443
  //#region \0virtual:astro:manifest
2444
- var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/new","isIndex":false,"type":"page","pattern":"^\\/new\\/?$","segments":[[{"content":"new","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/new.astro","pathname":"/new","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/sessions/[session]/tabs/[tab]","isIndex":false,"type":"page","pattern":"^\\/sessions\\/([^/]+?)\\/tabs\\/([^/]+?)\\/?$","segments":[[{"content":"sessions","dynamic":false,"spread":false}],[{"content":"session","dynamic":true,"spread":false}],[{"content":"tabs","dynamic":false,"spread":false}],[{"content":"tab","dynamic":true,"spread":false}]],"params":["session","tab"],"component":"src/pages/sessions/[session]/tabs/[tab].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/settings","isIndex":false,"type":"page","pattern":"^\\/settings\\/?$","segments":[[{"content":"settings","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/settings.astro","pathname":"/settings","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.CJakfIju.css"}],"routeData":{"route":"/threads/[id]","isIndex":false,"type":"page","pattern":"^\\/threads\\/([^/]+?)\\/?$","segments":[[{"content":"threads","dynamic":false,"spread":false}],[{"content":"id","dynamic":true,"spread":false}]],"params":["id"],"component":"src/pages/threads/[id].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/","isIndex":true,"type":"page","pattern":"^\\/$","segments":[],"params":[],"component":"src/pages/index.astro","pathname":"/","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}}],"serverLike":true,"middlewareMode":"classic","base":"/","trailingSlash":"ignore","compressHTML":"jsx","componentMetadata":[["/home/runner/work/webr/webr/src/pages/new.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/sessions/[session]/tabs/[tab].astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/settings.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/threads/[id].astro",{"propagation":"none","containsHead":true}]],"renderers":[],"clientDirectives":[["idle","(()=>{var l=(n,t)=>{let i=async()=>{await(await n())()},e=typeof t.value==\"object\"?t.value:void 0,s={timeout:e==null?void 0:e.timeout};\"requestIdleCallback\"in window?window.requestIdleCallback(i,s):setTimeout(i,s.timeout||200)};(self.Astro||(self.Astro={})).idle=l;window.dispatchEvent(new Event(\"astro:idle\"));})();"],["load","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event(\"astro:load\"));})();"],["media","(()=>{var n=(a,t)=>{let i=async()=>{await(await a())()};if(t.value){let e=matchMedia(t.value);e.matches?i():e.addEventListener(\"change\",i,{once:!0})}};(self.Astro||(self.Astro={})).media=n;window.dispatchEvent(new Event(\"astro:media\"));})();"],["only","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event(\"astro:only\"));})();"],["visible","(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},t=typeof i.value==\"object\"?i.value:void 0,c={rootMargin:t==null?void 0:t.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event(\"astro:visible\"));})();"]],"entryModules":{"\u0000noop-middleware":"virtual_astro_middleware.mjs","\u0000virtual:astro:server-island-manifest":"chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs","\u0000virtual:astro:session-driver":"chunks/_virtual_astro_session-driver_Dp7RXjOf.mjs","\u0000virtual:astro:actions/noop-entrypoint":"chunks/noop-entrypoint_Z3zFhrGC.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/@astrojs+react@7.0.0_@types+node@22.20.4_@types+react-dom@19.3.0_@types+react@19.3.0__@_02aa85dceaccf3d763325676dd8540a8/node_modules/@astrojs/react/dist/vnode-children.js":"chunks/vnode-children_BSi0H4PL.mjs","@astrojs/node/server.js":"entry.mjs","\u0000virtual:astro:page:src/pages/threads/[id]@_@astro":"chunks/_id__PkkT8hXq.mjs","\u0000virtual:astro:page:src/pages/sessions/[session]/tabs/[tab]@_@astro":"chunks/_tab__OowoBszk.mjs","\u0000virtual:astro:page:src/pages/index@_@astro":"chunks/index_Bvv1gpI0.mjs","\u0000virtual:astro:page:src/pages/new@_@astro":"chunks/new_DC57I9zU.mjs","\u0000virtual:astro:page:node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node@_@js":"chunks/node_C8FqXy_n.mjs","\u0000virtual:astro:page:src/pages/settings@_@astro":"chunks/settings_BIpDm2u1.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/services/sharp.js":"chunks/sharp_8J8zk4Wd.mjs","/home/runner/work/webr/webr/src/components/App.tsx":"_astro/App.BtZuijDn.js","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/components/ClientRouter.astro?astro&type=script&index=0&lang.ts":"_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","@astrojs/react/client.js":"_astro/client.BDkzSRUN.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-fit@0.10.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-fit/lib/addon-fit.js":"_astro/addon-fit.jgKqzwky.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-web-links@0.11.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-web-links/lib/addon-web-links.js":"_astro/addon-web-links.C9lyQRxI.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-webgl@0.18.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-webgl/lib/addon-webgl.js":"_astro/addon-webgl.Bd5ox92E.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+xterm@5.5.0/node_modules/@xterm/xterm/lib/xterm.js":"_astro/xterm.B-l5AIf6.js","astro:scripts/before-hydration.js":""},"inlinedScripts":[],"assets":["/apple-touch-icon.png","/brand-mark.png","/favicon-16.png","/favicon-32.png","/favicon.ico","/icon-192.png","/icon-512.png","/icon-maskable-512.png","/manifest.webmanifest","/_astro/App.BtZuijDn.js","/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","/_astro/addon-fit.jgKqzwky.js","/_astro/addon-web-links.C9lyQRxI.js","/_astro/addon-webgl.Bd5ox92E.js","/_astro/client.BDkzSRUN.js","/_astro/client.Bbg9OBwT.js","/_astro/client.CLSsMr2g.js","/_astro/xterm.B-l5AIf6.js","/_astro/App.CJakfIju.css","/_astro/jetbrains-mono.CFJaiWaY.woff2","/_astro/symbols-nerd.CEdkKAK8.woff2"],"buildFormat":"directory","checkOrigin":true,"actionBodySizeLimit":1048576,"serverIslandBodySizeLimit":1048576,"allowedDomains":[],"key":"bhQt7blSutZXqvha3yFzjOGVMCtQJ/c1nNTFxOla9cU=","sessionConfig":{"driver":"unstorage/drivers/fs-lite","options":{"base":"/home/runner/work/webr/webr/node_modules/.astro/sessions"}},"image":{},"devToolbar":{"enabled":false,"debugInfoOutput":""},"logLevel":"info","shouldInjectCspMetaTags":false});
2444
+ var _manifest = deserializeManifest({"rootDir":"file:///home/runner/work/webr/webr/","cacheDir":"file:///home/runner/work/webr/webr/node_modules/.astro/","outDir":"file:///home/runner/work/webr/webr/dist/","srcDir":"file:///home/runner/work/webr/webr/src/","publicDir":"file:///home/runner/work/webr/webr/public/","buildClientDir":"file:///home/runner/work/webr/webr/dist/client/","buildServerDir":"file:///home/runner/work/webr/webr/dist/server/","adapterName":"@astrojs/node","assetsDir":"_astro","routes":[{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"type":"page","component":"_server-islands.astro","params":["name"],"segments":[[{"content":"_server-islands","dynamic":false,"spread":false}],[{"content":"name","dynamic":true,"spread":false}]],"pattern":"^\\/_server-islands\\/([^/]+?)\\/?$","prerender":false,"isIndex":false,"fallbackRoutes":[],"route":"/_server-islands/[name]","origin":"internal","distURL":[],"_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/_image","component":"node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node.js","params":[],"pathname":"/_image","pattern":"^\\/_image\\/?$","segments":[[{"content":"_image","dynamic":false,"spread":false}]],"type":"endpoint","prerender":false,"fallbackRoutes":[],"distURL":[],"isIndex":false,"origin":"internal","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.8h0zS6BR.css"}],"routeData":{"route":"/new","isIndex":false,"type":"page","pattern":"^\\/new\\/?$","segments":[[{"content":"new","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/new.astro","pathname":"/new","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.8h0zS6BR.css"}],"routeData":{"route":"/sessions/[session]/tabs/[tab]","isIndex":false,"type":"page","pattern":"^\\/sessions\\/([^/]+?)\\/tabs\\/([^/]+?)\\/?$","segments":[[{"content":"sessions","dynamic":false,"spread":false}],[{"content":"session","dynamic":true,"spread":false}],[{"content":"tabs","dynamic":false,"spread":false}],[{"content":"tab","dynamic":true,"spread":false}]],"params":["session","tab"],"component":"src/pages/sessions/[session]/tabs/[tab].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.8h0zS6BR.css"}],"routeData":{"route":"/settings","isIndex":false,"type":"page","pattern":"^\\/settings\\/?$","segments":[[{"content":"settings","dynamic":false,"spread":false}]],"params":[],"component":"src/pages/settings.astro","pathname":"/settings","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[{"type":"external","src":"_astro/App.8h0zS6BR.css"}],"routeData":{"route":"/threads/[id]","isIndex":false,"type":"page","pattern":"^\\/threads\\/([^/]+?)\\/?$","segments":[[{"content":"threads","dynamic":false,"spread":false}],[{"content":"id","dynamic":true,"spread":false}]],"params":["id"],"component":"src/pages/threads/[id].astro","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}},{"file":"","links":[],"scripts":[],"styles":[],"routeData":{"route":"/","isIndex":true,"type":"page","pattern":"^\\/$","segments":[],"params":[],"component":"src/pages/index.astro","pathname":"/","prerender":false,"fallbackRoutes":[],"distURL":[],"origin":"project","_meta":{"trailingSlash":"ignore"}}}],"serverLike":true,"middlewareMode":"classic","base":"/","trailingSlash":"ignore","compressHTML":"jsx","componentMetadata":[["/home/runner/work/webr/webr/src/pages/new.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/sessions/[session]/tabs/[tab].astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/settings.astro",{"propagation":"none","containsHead":true}],["/home/runner/work/webr/webr/src/pages/threads/[id].astro",{"propagation":"none","containsHead":true}]],"renderers":[],"clientDirectives":[["idle","(()=>{var l=(n,t)=>{let i=async()=>{await(await n())()},e=typeof t.value==\"object\"?t.value:void 0,s={timeout:e==null?void 0:e.timeout};\"requestIdleCallback\"in window?window.requestIdleCallback(i,s):setTimeout(i,s.timeout||200)};(self.Astro||(self.Astro={})).idle=l;window.dispatchEvent(new Event(\"astro:idle\"));})();"],["load","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event(\"astro:load\"));})();"],["media","(()=>{var n=(a,t)=>{let i=async()=>{await(await a())()};if(t.value){let e=matchMedia(t.value);e.matches?i():e.addEventListener(\"change\",i,{once:!0})}};(self.Astro||(self.Astro={})).media=n;window.dispatchEvent(new Event(\"astro:media\"));})();"],["only","(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event(\"astro:only\"));})();"],["visible","(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},t=typeof i.value==\"object\"?i.value:void 0,c={rootMargin:t==null?void 0:t.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event(\"astro:visible\"));})();"]],"entryModules":{"\u0000noop-middleware":"virtual_astro_middleware.mjs","\u0000virtual:astro:server-island-manifest":"chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs","\u0000virtual:astro:session-driver":"chunks/_virtual_astro_session-driver_Dp7RXjOf.mjs","\u0000virtual:astro:actions/noop-entrypoint":"chunks/noop-entrypoint_Z3zFhrGC.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/@astrojs+react@7.0.0_@types+node@22.20.4_@types+react-dom@19.3.0_@types+react@19.3.0__@_02aa85dceaccf3d763325676dd8540a8/node_modules/@astrojs/react/dist/vnode-children.js":"chunks/vnode-children_BSi0H4PL.mjs","@astrojs/node/server.js":"entry.mjs","\u0000virtual:astro:page:src/pages/threads/[id]@_@astro":"chunks/_id__Bg5VthsE.mjs","\u0000virtual:astro:page:src/pages/sessions/[session]/tabs/[tab]@_@astro":"chunks/_tab__D95X1O-n.mjs","\u0000virtual:astro:page:src/pages/index@_@astro":"chunks/index_Bvv1gpI0.mjs","\u0000virtual:astro:page:src/pages/new@_@astro":"chunks/new_C-ziQti8.mjs","\u0000virtual:astro:page:node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/endpoint/node@_@js":"chunks/node_C8FqXy_n.mjs","\u0000virtual:astro:page:src/pages/settings@_@astro":"chunks/settings_BYiZ6OG8.mjs","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/dist/assets/services/sharp.js":"chunks/sharp_8J8zk4Wd.mjs","/home/runner/work/webr/webr/src/components/App.tsx":"_astro/App.CCpaClja.js","/home/runner/work/webr/webr/node_modules/.pnpm/astro@7.3.5_@types+node@22.20.4_tsx@4.23.15_yaml@2.9.1/node_modules/astro/components/ClientRouter.astro?astro&type=script&index=0&lang.ts":"_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","@astrojs/react/client.js":"_astro/client.BDkzSRUN.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-fit@0.10.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-fit/lib/addon-fit.js":"_astro/addon-fit.jgKqzwky.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-web-links@0.11.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-web-links/lib/addon-web-links.js":"_astro/addon-web-links.C9lyQRxI.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+addon-webgl@0.18.0_@xterm+xterm@5.5.0/node_modules/@xterm/addon-webgl/lib/addon-webgl.js":"_astro/addon-webgl.Bd5ox92E.js","/home/runner/work/webr/webr/node_modules/.pnpm/@xterm+xterm@5.5.0/node_modules/@xterm/xterm/lib/xterm.js":"_astro/xterm.B-l5AIf6.js","astro:scripts/before-hydration.js":""},"inlinedScripts":[],"assets":["/apple-touch-icon.png","/brand-mark.png","/favicon-16.png","/favicon-32.png","/favicon.ico","/icon-192.png","/icon-512.png","/icon-maskable-512.png","/manifest.webmanifest","/_astro/App.CCpaClja.js","/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js","/_astro/addon-fit.jgKqzwky.js","/_astro/addon-web-links.C9lyQRxI.js","/_astro/addon-webgl.Bd5ox92E.js","/_astro/client.BDkzSRUN.js","/_astro/client.Bbg9OBwT.js","/_astro/client.CLSsMr2g.js","/_astro/xterm.B-l5AIf6.js","/_astro/App.8h0zS6BR.css","/_astro/jetbrains-mono.CFJaiWaY.woff2","/_astro/symbols-nerd.CEdkKAK8.woff2"],"buildFormat":"directory","checkOrigin":true,"actionBodySizeLimit":1048576,"serverIslandBodySizeLimit":1048576,"allowedDomains":[],"key":"zhV/mlRUF6aAPRL/kGMDJ+5B47sw/eEY3vjPTu56EeA=","sessionConfig":{"driver":"unstorage/drivers/fs-lite","options":{"base":"/home/runner/work/webr/webr/node_modules/.astro/sessions"}},"image":{},"devToolbar":{"enabled":false,"debugInfoOutput":""},"logLevel":"info","shouldInjectCspMetaTags":false});
2445
2445
  var manifestRoutes = _manifest.routes;
2446
2446
  var manifest = Object.assign(_manifest, {
2447
2447
  renderers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablopunk/webr",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Control your Herdr agents from anywhere.",
5
5
  "keywords": [
6
6
  "herdr",
@@ -14,7 +14,7 @@ import { isTrustedHost } from './auth/access';
14
14
  import { registerDevHmrProxy } from './dev-hmr-proxy';
15
15
  import { registerVoiceRoutes } from './voice/routes';
16
16
  import type { Transcriber } from './voice/transcriber';
17
- import { MAX_IMAGE_BYTES, UploadStore, isFileUpload, uploadContentTypes } from './uploads';
17
+ import { MAX_IMAGE_BYTES, UploadStore, uploadContentTypes, uploadFileName } from './uploads';
18
18
 
19
19
  const localOwner = 'local';
20
20
  type SsrHandler = (request: FastifyRequest['raw'], response: import('node:http').ServerResponse, next: (error?: unknown) => void, locals: Record<string, unknown>) => void;
@@ -41,6 +41,7 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
41
41
  if (status === 413) return reply.code(413).send({ error: 'image_too_large' });
42
42
  if (message === 'file_too_large') return reply.code(413).send({ error: message });
43
43
  if (message === 'empty_file') return reply.code(400).send({ error: message });
44
+ if (message === 'upload_failed') return reply.code(502).send({ error: message });
44
45
  if (status === 415 || message === 'unsupported_image') return reply.code(415).send({ error: 'unsupported_image' });
45
46
  if (status && status >= 400 && status < 500) return reply.code(status).send({ error: 'invalid_request' });
46
47
  if (message === 'thread_not_found') return reply.code(404).send({ error: message });
@@ -64,10 +65,10 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
64
65
  const { machineId, name } = z.object({ machineId: opaqueId, name: z.string().max(255).optional() }).strict().parse(request.query);
65
66
  const supervisor = manager.supervisors.get(machineId);
66
67
  if (!supervisor?.connected) return reply.code(409).send({ error: 'machine_disconnected' });
67
- if (!supervisor.target.acceptsLocalFiles) return reply.code(409).send({ error: 'uploads_unsupported_target' });
68
+ if (!supervisor.target.acceptsUploads) return reply.code(409).send({ error: 'uploads_unsupported_target' });
68
69
  if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'unsupported_image' });
69
- const contentType = request.headers['content-type'];
70
- return { path: isFileUpload(contentType) ? await uploads.saveFile(name, request.body) : await uploads.save(contentType, request.body) };
70
+ const fileName = uploadFileName(request.headers['content-type'], name, request.body);
71
+ return { path: supervisor.target.storeUpload ? await supervisor.target.storeUpload(fileName, request.body) : await uploads.saveAs(fileName, request.body) };
71
72
  });
72
73
  app.get('/api/directories', async (request, reply) => {
73
74
  const { machineId, prefix } = z.object({ machineId: opaqueId, prefix: z.string().max(1000).refine((path) => !/[\x00-\x1f]/.test(path)) }).strict().parse(request.query);
@@ -110,6 +111,19 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
110
111
  if (icon.contentType === 'image/svg+xml') reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
111
112
  return reply.type(icon.contentType).send(icon.bytes);
112
113
  });
114
+ const candidateParams = z.object({ machineId: opaqueId, projectId: opaqueId });
115
+ app.get('/api/projects/:machineId/:projectId/icon-candidates', async (request) => {
116
+ const { machineId, projectId } = candidateParams.parse(request.params);
117
+ const found = await manager.supervisors.get(machineId)?.target.iconCandidates?.(projectId) ?? [];
118
+ return found.map((candidate, index) => ({ name: candidate.name, url: `/api/projects/${encodeURIComponent(machineId)}/${encodeURIComponent(projectId)}/icon-candidates/${index}` }));
119
+ });
120
+ app.get('/api/projects/:machineId/:projectId/icon-candidates/:index', async (request, reply) => {
121
+ const { machineId, projectId, index } = candidateParams.extend({ index: z.coerce.number().int().min(0).max(99) }).parse(request.params);
122
+ const candidate = (await manager.supervisors.get(machineId)?.target.iconCandidates?.(projectId))?.[index];
123
+ if (!candidate) return reply.code(404).send();
124
+ if (candidate.contentType === 'image/svg+xml') reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");
125
+ return reply.type(candidate.contentType).send(candidate.bytes);
126
+ });
113
127
  app.post('/api/threads', async (request, reply) => {
114
128
  const input = launchInput.parse(request.body);
115
129
  const key = z.uuid().parse(request.headers['idempotency-key']);
@@ -3,7 +3,7 @@ import type { NativeSnapshot } from '../protocol/native';
3
3
  import type { TerminalFrame, TerminalStream } from '../terminal/cli';
4
4
  import type { LaunchInput } from '../../shared/runtime';
5
5
  import type { WorktreeEntry } from './herdr-actions';
6
- import type { Icon } from '../transport/icons';
6
+ import type { Icon, IconCandidate } from '../transport/icons';
7
7
 
8
8
  export type TerminalDirection = 'right' | 'down';
9
9
  export type LaunchLocation = { projectId: string; localId?: string; logicalId?: string; path: string; workspaceId: string };
@@ -15,12 +15,14 @@ export type TargetAdapter = {
15
15
  locations: LaunchLocation[];
16
16
  writable: boolean;
17
17
  os?: MachineOs;
18
- acceptsLocalFiles?: boolean;
18
+ acceptsUploads?: boolean;
19
+ storeUpload?(fileName: string, bytes: Buffer): Promise<string>;
19
20
  subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds?: string[]): Promise<() => void>;
20
21
  snapshot(): Promise<NativeSnapshot>;
21
22
  catalog(projectId?: string): Promise<Machine>;
22
23
  canLaunch?(input: LaunchInput): boolean;
23
24
  icon?(projectId: string): Promise<Icon | undefined>;
25
+ iconCandidates?(projectId: string): Promise<IconCandidate[]>;
24
26
  worktrees?(projectId: string): Promise<WorktreeEntry[]>;
25
27
  openWorktree?(projectId: string, path: string): Promise<void>;
26
28
  suggestDirectories?(prefix: string): Promise<string[]>;
@@ -5,6 +5,7 @@ import { SocketApi } from '../protocol/socket';
5
5
  import { nativeSnapshot, nativeWorkspace, nativeTab, nativePane } from '../protocol/native';
6
6
  import { openCliStream } from '../terminal/cli';
7
7
  import { boundedProcess } from './process';
8
+ import { uploadOverSsh } from './remote-upload';
8
9
  import { SshForward, sshOptions, remoteCommand, quoteShell } from './ssh';
9
10
  import type { LaunchInput } from '../../shared/runtime';
10
11
  import type { Machine, MachineOs } from '../../lib/machines';
@@ -12,7 +13,7 @@ import { harnessName } from '../../lib/models';
12
13
  import { acceptsModelFlag, herdrAgentKinds, isHerdrAgentKind } from '../../shared/agent-kinds';
13
14
  import { modelListings, parseModelListing } from './model-discovery';
14
15
  import { HerdrActions } from '../runtime/herdr-actions';
15
- import { readApprovedIcon, type Icon } from './icons';
16
+ import { listIconCandidates, readApprovedIcon, type Icon, type IconCandidate } from './icons';
16
17
  import { targetFingerprint } from './identity';
17
18
  import { scopedProjectId } from '../../shared/projects';
18
19
  import { validateInstalledSchema } from '../protocol/validate';
@@ -31,7 +32,8 @@ export class HerdrTarget implements TargetAdapter {
31
32
  get enabled() { return this.profile.enabled; }
32
33
  get writable() { return this.compatible; }
33
34
  get os() { return this.profile.transport === 'local' ? osFromPlatform(process.platform) : this.remoteOs; }
34
- get acceptsLocalFiles() { return this.profile.transport === 'local'; }
35
+ get acceptsUploads() { return this.profile.transport === 'local' || this.remoteOs !== 'windows'; }
36
+ storeUpload?: (fileName: string, bytes: Buffer) => Promise<string>;
35
37
  private api?: SocketApi;
36
38
  private forwarding?: SshForward;
37
39
  private compatible = false;
@@ -39,7 +41,9 @@ export class HerdrTarget implements TargetAdapter {
39
41
  private connecting?: Promise<SocketApi>;
40
42
  private streams = new Set<ReturnType<typeof openCliStream>>();
41
43
  private icons = new Map<string, { expires: number; value: Promise<Icon | undefined> }>();
44
+ private candidates = new Map<string, { expires: number; value: Promise<IconCandidate[]> }>();
42
45
  constructor(private profile: TargetProfile, private dependencies: Dependencies = { process: boundedProcess, cli: openCliStream }) {
46
+ if (profile.transport === 'ssh') this.storeUpload = (fileName, bytes) => uploadOverSsh(profile.host!, fileName, bytes);
43
47
  this.id = profile.id; this.name = profile.name; this.session = profile.session;
44
48
  this.fingerprint = targetFingerprint(profile);
45
49
  this.locations = profile.locations.map((location) => ({ ...location, localId: location.projectId, logicalId: location.logicalId ?? location.projectId, projectId: scopedProjectId(profile.id, location.projectId) }));
@@ -146,6 +150,14 @@ export class HerdrTarget implements TargetAdapter {
146
150
  const value = readApprovedIcon(location.path);
147
151
  this.icons.set(projectId, { expires: Date.now() + 60_000, value }); return value;
148
152
  }
153
+ async iconCandidates(projectId: string) {
154
+ const location = this.locations.find((location) => location.projectId === projectId);
155
+ if (this.profile.transport !== 'local' || !location) return [];
156
+ const cached = this.candidates.get(projectId);
157
+ if (cached && cached.expires > Date.now()) return cached.value;
158
+ const value = listIconCandidates(location.path);
159
+ this.candidates.set(projectId, { expires: Date.now() + 60_000, value }); return value;
160
+ }
149
161
  openTerminal: TargetAdapter['openTerminal'] = (terminalId, mode, cols, rows, takeover, onFrame, onClose) => {
150
162
  if (!this.compatible || mode === 'control' && !this.writable || mode === 'observe' && takeover) throw new Error('terminal_control_unavailable');
151
163
  const command = this.command(['terminal', 'session', mode, terminalId, '--cols', String(cols), '--rows', String(rows), ...(takeover ? ['--takeover'] : [])]);
@@ -5,6 +5,8 @@ const contentTypes: Record<string, string> = { '.ico': 'image/x-icon', '.png': '
5
5
  const maxBytes = 2 * 1024 * 1024;
6
6
  const directories = ['build/Icon.icon/Assets', '', 'public', 'static', 'assets', 'web', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
7
7
  export type Icon = { bytes: Buffer; contentType: string };
8
+ export type IconCandidate = Icon & { name: string; rank: number };
9
+ const maxCandidates = 12;
8
10
 
9
11
  function withinRoot(root: string, path: string) {
10
12
  const part = relative(root, path);
@@ -24,7 +26,7 @@ function rank(name: string) {
24
26
  return Infinity;
25
27
  }
26
28
 
27
- async function iconInDirectory(root: string, directory: string): Promise<Icon | undefined> {
29
+ async function* iconsInDirectory(root: string, directory: string): AsyncGenerator<IconCandidate> {
28
30
  let actual: string;
29
31
  try { actual = await realpath(directory); } catch { return; }
30
32
  if (!withinRoot(root, actual)) return;
@@ -40,7 +42,7 @@ async function iconInDirectory(root: string, directory: string): Promise<Icon |
40
42
  const info = await stat(file);
41
43
  if (!info.isFile() || info.size <= 0 || info.size > maxBytes) continue;
42
44
  const bytes = await readFile(file);
43
- if (bytes.length <= maxBytes) return { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()] };
45
+ if (bytes.length <= maxBytes) yield { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()], name: relative(root, file), rank: rank(entry.name) };
44
46
  } catch {}
45
47
  }
46
48
  }
@@ -63,11 +65,27 @@ async function sourceDirectories(root: string) {
63
65
  } catch { return []; }
64
66
  }
65
67
 
68
+ async function searchDirectories(root: string) {
69
+ return [...directories.map((part) => join(root, part)), ...await sourceDirectories(root), ...await appDirectories(root)];
70
+ }
71
+
66
72
  export async function readApprovedIcon(path: string): Promise<Icon | undefined> {
67
73
  let root: string;
68
74
  try { root = await realpath(path); } catch { return; }
69
- for (const directory of [...directories.map((part) => join(root, part)), ...await sourceDirectories(root), ...await appDirectories(root)]) {
70
- const icon = await iconInDirectory(root, directory);
71
- if (icon) return icon;
75
+ for (const directory of await searchDirectories(root)) {
76
+ for await (const icon of iconsInDirectory(root, directory)) return icon;
77
+ }
78
+ }
79
+
80
+ export async function listIconCandidates(path: string): Promise<IconCandidate[]> {
81
+ let root: string;
82
+ try { root = await realpath(path); } catch { return []; }
83
+ const seen = new Set<string>(); const found: IconCandidate[] = [];
84
+ for (const directory of await searchDirectories(root)) {
85
+ for await (const icon of iconsInDirectory(root, directory)) {
86
+ if (seen.has(icon.name)) continue;
87
+ seen.add(icon.name); found.push(icon);
88
+ }
72
89
  }
90
+ return found.sort((a, b) => a.rank - b.rank).slice(0, maxCandidates);
73
91
  }
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { quoteShell, sshOptions } from './ssh';
3
+
4
+ const UPLOAD_TIMEOUT_MS = 60_000;
5
+ const EXPIRY_DAYS = 7;
6
+ const OUTPUT_LIMIT = 4096;
7
+ const hostPattern = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,200}$/;
8
+ const fileNamePattern = /^(image|file)-[0-9a-f-]{36}(\.[a-z0-9]{1,10})?$/;
9
+ const absolutePath = /^\/[^\0\r\n]+$/;
10
+
11
+ export const remoteUploadScript = (fileName: string) => {
12
+ if (!fileNamePattern.test(fileName)) throw new Error('invalid_upload_name');
13
+ return [
14
+ 'set -e', 'umask 077',
15
+ 'dir="${XDG_CACHE_HOME:-$HOME/.cache}/webr-uploads"', 'mkdir -p "$dir"',
16
+ `target="$dir/${fileName}"`,
17
+ 'cat > "$target.part"', 'mv "$target.part" "$target"',
18
+ `find "$dir" -type f \\( -name 'image-*' -o -name 'file-*' \\) -mtime +${EXPIRY_DAYS} -exec rm -f {} + >/dev/null 2>&1 || true`,
19
+ 'printf \'%s\\n\' "$target"',
20
+ ].join('\n');
21
+ };
22
+
23
+ export function uploadOverSsh(host: string, fileName: string, bytes: Buffer, spawnProcess: typeof spawn = spawn, timeoutMs = UPLOAD_TIMEOUT_MS): Promise<string> {
24
+ if (!hostPattern.test(host)) throw new Error('invalid_ssh_target');
25
+ const command = 'sh -c ' + quoteShell(remoteUploadScript(fileName));
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawnProcess('ssh', [...sshOptions, host, command], { stdio: ['pipe', 'pipe', 'ignore'] });
28
+ let output = ''; let settled = false;
29
+ const settle = (error?: Error, path?: string) => {
30
+ if (settled) return; settled = true; clearTimeout(timer);
31
+ if (error) { child.kill('SIGTERM'); reject(error); } else resolve(path!);
32
+ };
33
+ const timer = setTimeout(() => settle(new Error('upload_failed')), timeoutMs);
34
+ child.stdout!.on('data', (chunk: Buffer) => { output += chunk; if (output.length > OUTPUT_LIMIT) settle(new Error('upload_failed')); });
35
+ child.stdin!.on('error', () => {});
36
+ child.on('error', () => settle(new Error('upload_failed')));
37
+ child.on('close', (code) => { const path = output.trim().split('\n').at(-1) ?? ''; if (code === 0 && absolutePath.test(path)) settle(undefined, path); else settle(new Error('upload_failed')); });
38
+ child.stdin!.end(bytes);
39
+ });
40
+ }
@@ -24,17 +24,17 @@ export function imageExtension(contentType: string | undefined, bytes: Buffer) {
24
24
  return type.extension;
25
25
  }
26
26
 
27
+ export function uploadFileName(contentType: string | undefined, name: string | undefined, bytes: Buffer) {
28
+ if (!isFileUpload(contentType)) return `image-${randomUUID()}.${imageExtension(contentType, bytes)}`;
29
+ if (!bytes.length) throw new Error('empty_file');
30
+ if (bytes.length > MAX_FILE_BYTES) throw new Error('file_too_large');
31
+ return `file-${randomUUID()}${extensionOf(name)}`;
32
+ }
33
+
27
34
  export class UploadStore {
28
35
  constructor(readonly directory = resolve(process.env.WEBR_UPLOADS ?? '.data/uploads'), private lifetimeMs = UPLOAD_LIFETIME_MS) {}
29
- async save(contentType: string | undefined, bytes: Buffer) {
30
- return this.write(`image-${randomUUID()}.${imageExtension(contentType, bytes)}`, bytes);
31
- }
32
- async saveFile(name: string | undefined, bytes: Buffer) {
33
- if (!bytes.length) throw new Error('empty_file');
34
- if (bytes.length > MAX_FILE_BYTES) throw new Error('file_too_large');
35
- return this.write(`file-${randomUUID()}${extensionOf(name)}`, bytes);
36
- }
37
- private async write(fileName: string, bytes: Buffer) {
36
+ save(contentType: string | undefined, bytes: Buffer, name?: string) { return this.saveAs(uploadFileName(contentType, name, bytes), bytes); }
37
+ async saveAs(fileName: string, bytes: Buffer) {
38
38
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
39
39
  const path = join(this.directory, fileName);
40
40
  await writeFile(path, bytes, { mode: 0o600, flag: 'wx' });