sliftutils 1.4.9 → 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.9",
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;
@@ -86,20 +86,14 @@ type RemoteConfig = { url: string; password: string };
86
86
  function remoteConfigKey() { return getFileAPIKey() + ":remote"; }
87
87
  function loadRemoteConfig(): RemoteConfig | undefined {
88
88
  try {
89
- const key = remoteConfigKey();
90
- const s = localStorage.getItem(key);
91
- console.log("[remotecfg] load", JSON.stringify(key), "->", s);
89
+ const s = localStorage.getItem(remoteConfigKey());
92
90
  if (!s) return undefined;
93
91
  const c = JSON.parse(s);
94
92
  if (c && typeof c.url === "string" && typeof c.password === "string") return c;
95
- } catch (e) { console.warn("[remotecfg] load error", e); }
93
+ } catch { /* ignore */ }
96
94
  return undefined;
97
95
  }
98
- function saveRemoteConfig(c: RemoteConfig) {
99
- const key = remoteConfigKey();
100
- localStorage.setItem(key, JSON.stringify(c));
101
- console.log("[remotecfg] saved", JSON.stringify(key), "readback ->", localStorage.getItem(key));
102
- }
96
+ function saveRemoteConfig(c: RemoteConfig) { localStorage.setItem(remoteConfigKey(), JSON.stringify(c)); }
103
97
 
104
98
  // The server is always HTTPS on the filehoster's default port, so the user only needs to type the host
105
99
  // (e.g. "65.109.93.113"). We strip any scheme/path they include and default the port if omitted.
@@ -139,11 +133,12 @@ class DirectoryPrompter extends preact.Component {
139
133
  }
140
134
  }
141
135
 
142
- // "Connect to a server" option for the directory prompt: collapses to a button, expands to URL +
143
- // password fields. On connect it validates the credentials against the server, persists the config, and
144
- // 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.
145
140
  @observer
146
- class ServerConnectForm extends preact.Component {
141
+ class ServerConnectForm extends preact.Component<{ onConnected: (config: RemoteConfig) => void }> {
147
142
  private obs = observable({ expanded: false, url: "", password: "", error: "", connecting: false, needsCert: false, showPassword: false });
148
143
  private cleanUrl() { return normalizeServerUrl(this.obs.url); }
149
144
  private connect = async () => {
@@ -153,22 +148,28 @@ class ServerConnectForm extends preact.Component {
153
148
  s.connecting = true;
154
149
  try {
155
150
  const url = this.cleanUrl();
156
- if (!url) { s.error = "Enter a server URL"; return; }
151
+ if (!url) { s.error = "Enter a server address."; return; }
157
152
  const result = await probeRemoteConnection(url, s.password.trim());
158
153
  if (result.status === "ok") {
159
- saveRemoteConfig({ url, password: s.password.trim() });
160
- 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)
161
157
  return;
162
158
  }
163
159
  if (result.status === "unauthorized") {
164
160
  s.error = "The server rejected that password.";
161
+ console.error("Remote connect: unauthorized for", url);
165
162
  } else {
166
- // 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.
167
165
  s.needsCert = true;
168
- 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);
169
168
  }
170
169
  } catch (e) {
171
- 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);
172
173
  } finally {
173
174
  s.connecting = false;
174
175
  }
@@ -362,141 +363,100 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
362
363
  }
363
364
 
364
365
 
365
- // NOTE: Blocks until the user provides a directory
366
- // 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.
367
- 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> {
368
374
  if (isNode()) {
369
- return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
375
+ return { type: "local", handle: new NodeJSDirectoryHandleWrapper(path.resolve("./data/")) };
370
376
  }
377
+ const savedRemote = loadRemoteConfig();
378
+ if (savedRemote) return { type: "remote", config: savedRemote };
379
+
371
380
  let root = document.createElement("div");
372
381
  document.body.appendChild(root);
373
382
  preact.render(<DirectoryPrompter />, root);
374
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; });
375
387
 
376
- 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
+ );
377
408
 
409
+ // A previously-picked local folder: try to restore its handle (may need a click for activation).
378
410
  const storedId = localStorage.getItem(getFileAPIKey());
379
411
  if (storedId) {
380
412
  let doneLoad = false;
381
- setTimeout(() => {
382
- if (doneLoad) return;
383
- console.log("Waiting for user to click");
384
- displayData.ui = "Click anywhere to allow file system access";
385
- }, 500);
413
+ setTimeout(() => { if (!doneLoad) displayData.ui = "Click anywhere to allow file system access"; }, 500);
386
414
  try {
387
- handle = await tryToLoadPointer(storedId);
415
+ const handle = await tryToLoadPointer(storedId);
416
+ doneLoad = true;
417
+ if (handle) return { type: "local", handle };
388
418
  } catch (e) {
419
+ doneLoad = true;
389
420
  console.error(e);
390
- // Check if the error is due to user activation being required
391
- const errorMessage = e instanceof Error ? e.message : String(e);
392
- if (errorMessage.includes("user activation") || errorMessage.includes("User activation")) {
393
- doneLoad = true;
394
- // Show UI to get user to click and retry
395
- let retryCallback: (success: boolean) => void;
396
- let retryReject: (err: Error) => void;
397
- let retryPromise = new Promise<boolean>((resolve, reject) => {
398
- retryCallback = resolve;
399
- retryReject = reject;
400
- });
421
+ const msg = e instanceof Error ? e.message : String(e);
422
+ if (msg.includes("user activation") || msg.includes("User activation")) {
401
423
  displayData.ui = (
402
424
  <div className={css.vbox(20).center}>
403
- <button
404
- className={css.fontSize(40).pad2(80, 40)}
405
- onClick={async () => {
406
- displayData.ui = "Loading...";
407
- try {
408
- const retryHandle = await tryToLoadPointer(storedId);
409
- if (retryHandle) {
410
- handle = retryHandle;
411
- retryCallback(true);
412
- } else {
413
- retryCallback(false);
414
- }
415
- } catch (retryError) {
416
- console.error("Retry failed:", retryError);
417
- retryCallback(false);
418
- }
419
- }}
420
- >
421
- Click to restore file system access
422
- </button>
423
- <button
424
- className={css.fontSize(40).pad2(80, 40)}
425
- onClick={async () => {
426
- console.log("Waiting for user to give permission");
427
- const pickedHandle = await window.showDirectoryPicker();
428
- await pickedHandle.requestPermission({ mode: "readwrite" });
429
- let newStoredId = await storeFileSystemPointer({ mode: "readwrite", handle: pickedHandle });
430
- localStorage.setItem(getFileAPIKey(), newStoredId);
431
- handle = pickedHandle as any;
432
- retryCallback(true);
433
- }}
434
- >
435
- Pick Data Directory
436
- </button>
437
- <ServerConnectForm />
438
- <button
439
- className={css.fontSize(40).pad2(80, 40)}
440
- onClick={() => {
441
- retryReject(new Error("User dismissed file system access prompt"));
442
- }}
443
- >
444
- Dismiss
445
- </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()}
446
434
  </div>
447
435
  );
448
- const success = await retryPromise;
449
- if (handle) {
450
- return handle;
451
- }
436
+ return await choicePromise;
452
437
  }
453
438
  }
454
- doneLoad = true;
455
- if (handle) {
456
- return handle;
457
- }
458
439
  }
459
- let fileCallback: (handle: DirectoryWrapper) => void;
460
- let fileReject: (err: Error) => void;
461
- let promise = new Promise<DirectoryWrapper>((resolve, reject) => {
462
- fileCallback = resolve;
463
- fileReject = reject;
464
- });
465
- displayData.ui = (
466
- <div className={css.vbox(20).center}>
467
- <button
468
- className={css.fontSize(40).pad2(80, 40)}
469
- onClick={async () => {
470
- console.log("Waiting for user to give permission");
471
- const handle = await window.showDirectoryPicker();
472
- await handle.requestPermission({ mode: "readwrite" });
473
- let storedId = await storeFileSystemPointer({ mode: "readwrite", handle });
474
- localStorage.setItem(getFileAPIKey(), storedId);
475
- fileCallback(handle as any);
476
- }}
477
- >
478
- Pick Data Directory
479
- </button>
480
- <ServerConnectForm />
481
- <button
482
- className={css.fontSize(40).pad2(80, 40)}
483
- onClick={() => {
484
- fileReject(new Error("User dismissed file system access prompt"));
485
- }}
486
- >
487
- Dismiss
488
- </button>
489
- </div>
490
- );
491
- return await promise;
440
+ displayData.ui = <div className={css.vbox(20).center}>{renderOptions()}</div>;
441
+ return await choicePromise;
492
442
  } finally {
493
443
  preact.render(null, root);
494
444
  root.remove();
495
445
  }
496
446
  });
497
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
+
498
456
  export const getFileStorageNested = cache(async function getFileStorage(path: string): Promise<FileStorage> {
499
- let base = await getDirectoryHandle();
457
+ const choice = await getStorageChoice();
458
+ if (choice.type === "remote") return getRemoteFactory(choice.config)(path);
459
+ let base = choice.handle;
500
460
  for (let part of path.split("/")) {
501
461
  if (!part) continue;
502
462
  base = await base.getDirectoryHandle(part, { create: true });
@@ -507,17 +467,15 @@ export const getFileStorageNested = cache(async function getFileStorage(path: st
507
467
  export const getFileStorageNested2 = cache(async function getFileStorage(pathStr: string): Promise<FileStorage> {
508
468
  let base: DirectoryWrapper;
509
469
  pathStr = pathStr.replaceAll("\\", "/");
510
- if (!isNode()) {
511
- const remote = loadRemoteConfig();
512
- if (remote) return getRemoteFactory(remote)(pathStr);
513
- }
514
470
  if (isNode()) {
515
471
  if (path.isAbsolute(pathStr)) {
516
472
  return wrapHandle(new NodeJSDirectoryHandleWrapper(pathStr));
517
473
  }
518
474
  base = new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
519
475
  } else {
520
- base = await getDirectoryHandle();
476
+ const choice = await getStorageChoice();
477
+ if (choice.type === "remote") return getRemoteFactory(choice.config)(pathStr);
478
+ base = choice.handle;
521
479
  let dirs: string[] = [];
522
480
  let alls: string[] = [];
523
481
  for await (const [name, entry] of base) {
@@ -541,9 +499,9 @@ export const getFileStorage = lazy(async function getFileStorage(): Promise<File
541
499
  if (USE_INDEXED_DB) {
542
500
  return await getFileStorageIndexDB();
543
501
  }
544
-
545
- let handle = await getDirectoryHandle();
546
- return wrapHandle(handle);
502
+ const choice = await getStorageChoice();
503
+ if (choice.type === "remote") return getRemoteFactory(choice.config)("");
504
+ return wrapHandle(choice.handle);
547
505
  });
548
506
  export function resetStorageLocation() {
549
507
  localStorage.removeItem(getFileAPIKey());