sliftutils 1.4.8 → 1.4.10

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/index.d.ts CHANGED
@@ -1384,6 +1384,10 @@ declare module "sliftutils/storage/FileFolderAPI" {
1384
1384
  ]>;
1385
1385
  };
1386
1386
  export declare function setFileAPIKey(key: string): void;
1387
+ type RemoteConfig = {
1388
+ url: string;
1389
+ password: string;
1390
+ };
1387
1391
  export declare class NodeJSFileHandleWrapper implements FileWrapper {
1388
1392
  private filePath;
1389
1393
  constructor(filePath: string);
@@ -1430,6 +1434,18 @@ declare module "sliftutils/storage/FileFolderAPI" {
1430
1434
  }
1431
1435
  ]>;
1432
1436
  }
1437
+ type StorageChoice = {
1438
+ type: "local";
1439
+ handle: DirectoryWrapper;
1440
+ } | {
1441
+ type: "remote";
1442
+ config: RemoteConfig;
1443
+ };
1444
+ export declare const getStorageChoice: {
1445
+ (): Promise<StorageChoice>;
1446
+ reset(): void;
1447
+ set(newValue: Promise<StorageChoice>): void;
1448
+ };
1433
1449
  export declare const getDirectoryHandle: {
1434
1450
  (): Promise<DirectoryWrapper>;
1435
1451
  reset(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.8",
3
+ "version": "1.4.10",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -70,6 +70,10 @@ type DirectoryWrapper = {
70
70
  ]>;
71
71
  };
72
72
  export declare function setFileAPIKey(key: string): void;
73
+ type RemoteConfig = {
74
+ url: string;
75
+ password: string;
76
+ };
73
77
  export declare class NodeJSFileHandleWrapper implements FileWrapper {
74
78
  private filePath;
75
79
  constructor(filePath: string);
@@ -116,6 +120,18 @@ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
116
120
  }
117
121
  ]>;
118
122
  }
123
+ type StorageChoice = {
124
+ type: "local";
125
+ handle: DirectoryWrapper;
126
+ } | {
127
+ type: "remote";
128
+ config: RemoteConfig;
129
+ };
130
+ export declare const getStorageChoice: {
131
+ (): Promise<StorageChoice>;
132
+ reset(): void;
133
+ set(newValue: Promise<StorageChoice>): void;
134
+ };
119
135
  export declare const getDirectoryHandle: {
120
136
  (): Promise<DirectoryWrapper>;
121
137
  reset(): void;
@@ -133,11 +133,12 @@ class DirectoryPrompter extends preact.Component {
133
133
  }
134
134
  }
135
135
 
136
- // "Connect to a server" option for the directory prompt: collapses to a button, expands to URL +
137
- // password fields. On connect it validates the credentials against the server, persists the config, and
138
- // reloads so getFileStorageNested2 picks up the remote storage.
136
+ // "Connect to a server" option for the directory prompt: collapses to a button, expands to address +
137
+ // password fields. On connect it actually connects to the server; on success it persists the config and
138
+ // calls onConnected so the caller (getStorageChoice) resolves with the live remote storage — no reload.
139
+ // Connection failures are shown to the user (and logged, without the password), never swallowed.
139
140
  @observer
140
- class ServerConnectForm extends preact.Component {
141
+ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteConfig) => void }> {
141
142
  private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false, needsCert: false, showPassword: false });
142
143
  private cleanUrl() { return normalizeServerUrl(this.obs.url); }
143
144
  private connect = async () => {
@@ -147,22 +148,28 @@ class ServerConnectForm extends preact.Component {
147
148
  s.connecting = true;
148
149
  try {
149
150
  const url = this.cleanUrl();
150
- if (!url) { s.error = "Enter a server URL"; return; }
151
+ if (!url) { s.error = "Enter a server address."; return; }
151
152
  const result = await probeRemoteConnection(url, s.password.trim());
152
153
  if (result.status === "ok") {
153
- saveRemoteConfig({ url, password: s.password.trim() });
154
- window.location.reload();
154
+ const config: RemoteConfig = { url, password: s.password.trim() };
155
+ saveRemoteConfig(config); // remember for next session
156
+ this.props.onConnected(config); // resolve the pending storage request now (no reload)
155
157
  return;
156
158
  }
157
159
  if (result.status === "unauthorized") {
158
160
  s.error = "The server rejected that password.";
161
+ console.error("Remote connect: unauthorized for", url);
159
162
  } else {
160
- // Reached nothing back — almost always the self-signed certificate isn't trusted yet.
163
+ // Got nothing back — usually the self-signed certificate isn't trusted yet, but show the
164
+ // actual error too so a wrong address / down server / CORS issue is diagnosable.
161
165
  s.needsCert = true;
162
- s.error = "Couldn't reach the server.";
166
+ s.error = "Couldn't reach the server: " + result.error;
167
+ console.error("Remote connect: unreachable", url, "-", result.error);
163
168
  }
164
169
  } catch (e) {
165
- s.error = "Could not connect: " + ((e as Error).message || String(e));
170
+ // Should be rare (probeRemoteConnection handles its own errors), but never swallow it.
171
+ s.error = "Connection error: " + ((e as Error).message || String(e));
172
+ console.error("Remote connect threw:", e);
166
173
  } finally {
167
174
  s.connecting = false;
168
175
  }
@@ -356,141 +363,100 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
356
363
  }
357
364
 
358
365
 
359
- // NOTE: Blocks until the user provides a directory
360
- // NOTE: If you want to override the location, you can explicitly call getDirectoryHandle.set with your own directory wrapper. Which if your server side can be the Node.js directory handle wrapper, or if your client side it can just be a pointer from tryToLoadPointer/fileSystemPointer.ts.
361
- export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
366
+ // What storage the user chose: a local directory handle, or a remote server config.
367
+ type StorageChoice = { type: "local"; handle: DirectoryWrapper } | { type: "remote"; config: RemoteConfig };
368
+
369
+ // Resolves (once) what storage to use. If a remote server was connected in a previous session we use it
370
+ // straight away; otherwise we prompt (Pick directory / Connect to a server / Dismiss). The Connect path
371
+ // actually connects to the server and resolves with its config — so when this returns, the storage is
372
+ // ready. No page reload. Blocks until the user chooses (or dismisses, which rejects).
373
+ export const getStorageChoice = lazy(async function getStorageChoice(): Promise<StorageChoice> {
362
374
  if (isNode()) {
363
- return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
375
+ return { type: "local", handle: new NodeJSDirectoryHandleWrapper(path.resolve("./data/")) };
364
376
  }
377
+ const savedRemote = loadRemoteConfig();
378
+ if (savedRemote) return { type: "remote", config: savedRemote };
379
+
365
380
  let root = document.createElement("div");
366
381
  document.body.appendChild(root);
367
382
  preact.render(<DirectoryPrompter />, root);
368
383
  try {
384
+ let resolveChoice!: (c: StorageChoice) => void;
385
+ let rejectChoice!: (e: Error) => void;
386
+ const choicePromise = new Promise<StorageChoice>((res, rej) => { resolveChoice = res; rejectChoice = rej; });
369
387
 
370
- let handle: DirectoryWrapper | undefined;
388
+ const pickLocal = async () => {
389
+ try {
390
+ const handle = await window.showDirectoryPicker();
391
+ await handle.requestPermission({ mode: "readwrite" });
392
+ const storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
393
+ localStorage.setItem(getFileAPIKey(), storedId);
394
+ resolveChoice({ type: "local", handle: handle as any });
395
+ } catch (e) {
396
+ console.error("Directory pick failed/cancelled:", e); // stay on the prompt
397
+ }
398
+ };
399
+ // The three options, rendered fresh each time (so reused-vnode issues can't arise).
400
+ const renderOptions = () => (
401
+ <>
402
+ <button className={css.fontSize(40).pad2(80, 40)} onClick={pickLocal}>Pick Data Directory</button>
403
+ <ServerConnectForm onConnected={config => resolveChoice({ type: "remote", config })} />
404
+ <button className={css.fontSize(40).pad2(80, 40)}
405
+ onClick={() => rejectChoice(new Error("User dismissed file system access prompt"))}>Dismiss</button>
406
+ </>
407
+ );
371
408
 
409
+ // A previously-picked local folder: try to restore its handle (may need a click for activation).
372
410
  const storedId = localStorage.getItem(getFileAPIKey());
373
411
  if (storedId) {
374
412
  let doneLoad = false;
375
- setTimeout(() => {
376
- if (doneLoad) return;
377
- console.log("Waiting for user to click");
378
- displayData.ui = "Click anywhere to allow file system access";
379
- }, 500);
413
+ setTimeout(() => { if (!doneLoad) displayData.ui = "Click anywhere to allow file system access"; }, 500);
380
414
  try {
381
- handle = await tryToLoadPointer(storedId);
415
+ const handle = await tryToLoadPointer(storedId);
416
+ doneLoad = true;
417
+ if (handle) return { type: "local", handle };
382
418
  } catch (e) {
419
+ doneLoad = true;
383
420
  console.error(e);
384
- // Check if the error is due to user activation being required
385
- const errorMessage = e instanceof Error ? e.message : String(e);
386
- if (errorMessage.includes("user activation") || errorMessage.includes("User activation")) {
387
- doneLoad = true;
388
- // Show UI to get user to click and retry
389
- let retryCallback: (success: boolean) => void;
390
- let retryReject: (err: Error) => void;
391
- let retryPromise = new Promise<boolean>((resolve, reject) => {
392
- retryCallback = resolve;
393
- retryReject = reject;
394
- });
421
+ const msg = e instanceof Error ? e.message : String(e);
422
+ if (msg.includes("user activation") || msg.includes("User activation")) {
395
423
  displayData.ui = (
396
424
  <div className={css.vbox(20).center}>
397
- <button
398
- className={css.fontSize(40).pad2(80, 40)}
399
- onClick={async () => {
400
- displayData.ui = "Loading...";
401
- try {
402
- const retryHandle = await tryToLoadPointer(storedId);
403
- if (retryHandle) {
404
- handle = retryHandle;
405
- retryCallback(true);
406
- } else {
407
- retryCallback(false);
408
- }
409
- } catch (retryError) {
410
- console.error("Retry failed:", retryError);
411
- retryCallback(false);
412
- }
413
- }}
414
- >
415
- Click to restore file system access
416
- </button>
417
- <button
418
- className={css.fontSize(40).pad2(80, 40)}
419
- onClick={async () => {
420
- console.log("Waiting for user to give permission");
421
- const pickedHandle = await window.showDirectoryPicker();
422
- await pickedHandle.requestPermission({ mode: "readwrite" });
423
- let newStoredId = await storeFileSystemPointer({ mode: "readwrite", handle: pickedHandle });
424
- localStorage.setItem(getFileAPIKey(), newStoredId);
425
- handle = pickedHandle as any;
426
- retryCallback(true);
427
- }}
428
- >
429
- Pick Data Directory
430
- </button>
431
- <ServerConnectForm />
432
- <button
433
- className={css.fontSize(40).pad2(80, 40)}
434
- onClick={() => {
435
- retryReject(new Error("User dismissed file system access prompt"));
436
- }}
437
- >
438
- Dismiss
439
- </button>
425
+ <button className={css.fontSize(40).pad2(80, 40)} onClick={async () => {
426
+ displayData.ui = "Loading...";
427
+ try {
428
+ const h = await tryToLoadPointer(storedId);
429
+ if (h) { resolveChoice({ type: "local", handle: h }); return; }
430
+ } catch (retryError) { console.error("Retry failed:", retryError); }
431
+ displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
432
+ }}>Click to restore file system access</button>
433
+ {renderOptions()}
440
434
  </div>
441
435
  );
442
- const success = await retryPromise;
443
- if (handle) {
444
- return handle;
445
- }
436
+ return await choicePromise;
446
437
  }
447
438
  }
448
- doneLoad = true;
449
- if (handle) {
450
- return handle;
451
- }
452
439
  }
453
- let fileCallback: (handle: DirectoryWrapper) => void;
454
- let fileReject: (err: Error) => void;
455
- let promise = new Promise<DirectoryWrapper>((resolve, reject) => {
456
- fileCallback = resolve;
457
- fileReject = reject;
458
- });
459
- displayData.ui = (
460
- <div className={css.vbox(20).center}>
461
- <button
462
- className={css.fontSize(40).pad2(80, 40)}
463
- onClick={async () => {
464
- console.log("Waiting for user to give permission");
465
- const handle = await window.showDirectoryPicker();
466
- await handle.requestPermission({ mode: "readwrite" });
467
- let storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
468
- localStorage.setItem(getFileAPIKey(), storedId);
469
- fileCallback(handle as any);
470
- }}
471
- >
472
- Pick Data Directory
473
- </button>
474
- <ServerConnectForm />
475
- <button
476
- className={css.fontSize(40).pad2(80, 40)}
477
- onClick={() => {
478
- fileReject(new Error("User dismissed file system access prompt"));
479
- }}
480
- >
481
- Dismiss
482
- </button>
483
- </div>
484
- );
485
- return await promise;
440
+ displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
441
+ return await choicePromise;
486
442
  } finally {
487
443
  preact.render(null, root);
488
444
  root.remove();
489
445
  }
490
446
  });
491
447
 
448
+ // Local-only directory handle, for callers that don't support remote storage. Throws if the user has
449
+ // configured a remote server (rather than silently doing the wrong thing).
450
+ export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
451
+ const choice = await getStorageChoice();
452
+ if (choice.type === "remote") throw new Error("Storage is configured to use a remote server; a local directory handle isn't available here.");
453
+ return choice.handle;
454
+ });
455
+
492
456
  export const getFileStorageNested = cache(async function getFileStorage(path: string): Promise<FileStorage> {
493
- let base = await getDirectoryHandle();
457
+ const choice = await getStorageChoice();
458
+ if (choice.type === "remote") return getRemoteFactory(choice.config)(path);
459
+ let base = choice.handle;
494
460
  for (let part of path.split("/")) {
495
461
  if (!part) continue;
496
462
  base = await base.getDirectoryHandle(part, { create: true });
@@ -501,17 +467,15 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
501
467
  export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
502
468
  let base: DirectoryWrapper;
503
469
  pathStr = pathStr.replaceAll("\\", "/");
504
- if (!isNode()) {
505
- const remote = loadRemoteConfig();
506
- if (remote) return getRemoteFactory(remote)(pathStr);
507
- }
508
470
  if (isNode()) {
509
471
  if (path.isAbsolute(pathStr)) {
510
472
  return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
511
473
  }
512
474
  base = new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
513
475
  } else {
514
- base = await getDirectoryHandle();
476
+ const choice = await getStorageChoice();
477
+ if (choice.type === "remote") return getRemoteFactory(choice.config)(pathStr);
478
+ base = choice.handle;
515
479
  let dirs: string[] = [];
516
480
  let alls: string[] = [];
517
481
  for await (const [name, entry] of base) {
@@ -535,9 +499,9 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
535
499
  if (USE_INDEXED_DB) {
536
500
  return await getFileStorageIndexDB();
537
501
  }
538
-
539
- let handle = await getDirectoryHandle();
540
- return wrapHandle(handle);
502
+ const choice = await getStorageChoice();
503
+ if (choice.type === "remote") return getRemoteFactory(choice.config)("");
504
+ return wrapHandle(choice.handle);
541
505
  });
542
506
  export function resetStorageLocation() {
543
507
  localStorage.removeItem(getFileAPIKey());
@@ -427,14 +427,18 @@ export async function runFileHoster(): Promise<void> {
427
427
  let externalIP: string | undefined;
428
428
  try { externalIP = (await getExternalIP()).trim(); } catch { /* offline / unreachable */ }
429
429
 
430
+ // What the user types into the app: just the host, plus ":port" only if it's not the default. No
431
+ // scheme — the app always uses https.
432
+ const host = externalIP || "localhost";
433
+ const appAddress = host + (info.port === 8787 ? "" : ":" + info.port);
434
+ const certUrl = "https://" + host + ":" + info.port;
430
435
  console.log("");
431
436
  console.log(" Serving: " + path.resolve(root));
432
437
  console.log(" Password: " + info.password);
433
- console.log(" Local: " + info.url);
434
- if (externalIP) console.log(" Public: https://" + externalIP + ":" + info.port + " (once port-forwarding succeeds)");
438
+ console.log(" Address: " + appAddress + " <- in the app, choose \"Connect to a server\" and enter this");
435
439
  console.log("");
436
- console.log(" In the app, choose \"Connect to a server\" and enter the URL + password.");
437
- console.log(" (Self-signed cert: open the URL once in your browser and accept it first.)");
440
+ console.log(" First time only: open " + certUrl + " in a browser once and accept the");
441
+ console.log(" self-signed certificate warning, then connect from the app.");
438
442
  console.log("");
439
443
 
440
444
  // Keep the UPnP port mapping alive — leases expire ~hourly, so refresh well within that. No-op on