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 +16 -0
- package/package.json +1 -1
- package/storage/FileFolderAPI.d.ts +16 -0
- package/storage/FileFolderAPI.tsx +89 -125
- package/storage/remoteFileServer.ts +8 -4
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
|
@@ -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
|
|
137
|
-
// password fields. On connect it
|
|
138
|
-
//
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
437
|
-
console.log("
|
|
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
|