@livedesk/hub 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # @livedesk/hub
2
2
 
3
- Local LiveDesk manager hub.
3
+ Local LiveDesk Hub runtime.
4
4
 
5
5
  This package is used by the `livedesk` launcher package. Most users should run:
6
6
 
7
7
  ```powershell
8
8
  npx -y livedesk
9
9
  ```
10
+
11
+ Mode 4 runs its Atlas compositor in a separate worker process. It consumes
12
+ latest-only Mode 2 frames and exposes one H.264 wall stream at
13
+ `/api/remote/atlas/ws`. The grid uses native 320x180 cells until the configured
14
+ 1920x1080 bound requires smaller cells; focused control continues to use direct
15
+ Mode 3.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.0",
4
- "description": "LiveDesk local RemoteHub API and browser frame bridge",
3
+ "version": "0.1.1",
4
+ "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
7
7
  "files": [
@@ -14,8 +14,11 @@
14
14
  "check": "node --check src/server.js && node --check src/remote-hub.js"
15
15
  },
16
16
  "dependencies": {
17
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
17
18
  "cors": "^2.8.5",
18
19
  "express": "^4.21.2",
20
+ "ffmpeg-static": "^5.3.0",
21
+ "path-to-regexp": "0.1.13",
19
22
  "ws": "^8.18.3"
20
23
  },
21
24
  "engines": {
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
4
+ import { isPathWithinRoot } from './path-registry.js';
5
+
6
+ function filesystemError(message, code = 'FILESYSTEM_ERROR', status = 400) {
7
+ const error = new Error(message);
8
+ error.code = code;
9
+ error.status = status;
10
+ return error;
11
+ }
12
+
13
+ function safeEntryName(name) {
14
+ return String(name || '').replace(/[\0\r\n\t]/g, ' ').slice(0, 512);
15
+ }
16
+
17
+ function toPublicEntry(entry) {
18
+ return {
19
+ id: entry.id,
20
+ parentId: entry.parentId,
21
+ name: entry.name,
22
+ type: entry.type,
23
+ size: entry.size || 0,
24
+ modifiedAt: entry.modifiedAt || null,
25
+ extension: entry.type === 'file' ? path.extname(entry.name).toLowerCase() : '',
26
+ hasChildren: entry.type !== 'file' && entry.hasChildren === true,
27
+ displayPath: entry.displayPath,
28
+ locked: entry.locked === true
29
+ };
30
+ }
31
+
32
+ async function readEntryStat(absolutePath) {
33
+ try {
34
+ return await fs.lstat(absolutePath);
35
+ } catch (error) {
36
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
37
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ export async function readDirectoryEntries(registry, folderId) {
43
+ const folder = registry.resolve(folderId);
44
+ if (folder.type === 'file') throw filesystemError('filesystem-entry-is-not-folder', 'NOT_A_FOLDER', 400);
45
+ if (folder.locked) throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
46
+ let entries;
47
+ try {
48
+ entries = await fs.readdir(folder.absolutePath, { withFileTypes: true });
49
+ } catch (error) {
50
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
51
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
52
+ throw error;
53
+ }
54
+
55
+ const output = [];
56
+ for (const dirent of entries) {
57
+ const name = safeEntryName(dirent.name);
58
+ if (!name || name === '.' || name === '..') continue;
59
+ const absolutePath = path.resolve(folder.absolutePath, name);
60
+ if (!isPathWithinRoot(folder.rootPath, absolutePath)) continue;
61
+ const stat = await readEntryStat(absolutePath).catch(error => {
62
+ if (error?.code === 'ACCESS_DENIED' || error?.code === 'NOT_FOUND') return null;
63
+ throw error;
64
+ });
65
+ if (!stat) continue;
66
+ const isSymlink = dirent.isSymbolicLink() || stat.isSymbolicLink();
67
+ const type = !isSymlink && (dirent.isDirectory() || stat.isDirectory()) ? 'folder' : 'file';
68
+ const child = registry.register({
69
+ absolutePath,
70
+ rootPath: folder.rootPath,
71
+ rootId: folder.rootId,
72
+ parentId: folder.id,
73
+ name,
74
+ type,
75
+ displayPath: path.join(folder.displayPath, name),
76
+ locked: isSymlink
77
+ });
78
+ output.push(toPublicEntry({
79
+ ...child,
80
+ size: type === 'file' ? stat.size : 0,
81
+ modifiedAt: stat.mtime?.toISOString(),
82
+ hasChildren: type === 'folder' && !isSymlink
83
+ }));
84
+ }
85
+ output.sort((left, right) => {
86
+ if (left.type !== right.type) return left.type === 'folder' ? -1 : 1;
87
+ return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' });
88
+ });
89
+ return {
90
+ folder: toPublicEntry({ ...folder, hasChildren: true }),
91
+ entries: output
92
+ };
93
+ }
94
+
95
+ async function walkDirectory(registry, entry, relativePrefix, output, visited, counters) {
96
+ const realPath = await fs.realpath(entry.absolutePath).catch(() => entry.absolutePath);
97
+ if (visited.has(realPath)) return;
98
+ visited.add(realPath);
99
+ let dirents;
100
+ try {
101
+ dirents = await fs.readdir(entry.absolutePath, { withFileTypes: true });
102
+ } catch (error) {
103
+ if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
104
+ throw error;
105
+ }
106
+ for (const dirent of dirents) {
107
+ const name = safeEntryName(dirent.name);
108
+ if (!name || name === '.' || name === '..' || dirent.isSymbolicLink()) continue;
109
+ const absolutePath = path.resolve(entry.absolutePath, name);
110
+ if (!isPathWithinRoot(entry.rootPath, absolutePath)) continue;
111
+ const stat = await readEntryStat(absolutePath);
112
+ const relativePath = path.posix.join(relativePrefix, name);
113
+ if (stat.isDirectory()) {
114
+ counters.folders += 1;
115
+ const child = registry.register({
116
+ absolutePath,
117
+ rootPath: entry.rootPath,
118
+ rootId: entry.rootId,
119
+ parentId: entry.id,
120
+ name,
121
+ type: 'folder',
122
+ displayPath: path.join(entry.displayPath, name)
123
+ });
124
+ await walkDirectory(registry, child, relativePath, output, visited, counters);
125
+ } else if (stat.isFile()) {
126
+ const child = registry.register({
127
+ absolutePath,
128
+ rootPath: entry.rootPath,
129
+ rootId: entry.rootId,
130
+ parentId: entry.id,
131
+ name,
132
+ type: 'file',
133
+ displayPath: path.join(entry.displayPath, name)
134
+ });
135
+ output.push({
136
+ id: child.id,
137
+ absolutePath,
138
+ relativePath,
139
+ name,
140
+ size: stat.size,
141
+ modifiedMs: stat.mtimeMs,
142
+ modifiedAt: stat.mtime?.toISOString() || null
143
+ });
144
+ counters.files += 1;
145
+ counters.totalBytes += stat.size;
146
+ }
147
+ if ((counters.files + counters.folders) % 100 === 0) await yieldToEventLoop();
148
+ }
149
+ }
150
+
151
+ export async function scanSelection(registry, itemIds) {
152
+ const ids = [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 5000);
153
+ if (ids.length === 0) throw filesystemError('no-filesystem-items-selected', 'NO_SELECTION', 400);
154
+ const selected = ids.map(id => registry.resolve(id));
155
+ const selectedPaths = selected.filter(entry => entry.type === 'folder').map(entry => path.resolve(entry.absolutePath));
156
+ const topLevel = selected.filter(entry => !selectedPaths.some(parent => parent !== entry.absolutePath && isPathWithinRoot(parent, entry.absolutePath)));
157
+ const files = [];
158
+ const counters = { files: 0, folders: 0, totalBytes: 0 };
159
+ const visited = new Set();
160
+ for (const entry of topLevel) {
161
+ const stat = await readEntryStat(entry.absolutePath);
162
+ if (entry.type === 'file' || stat.isFile()) {
163
+ files.push({
164
+ id: entry.id,
165
+ absolutePath: entry.absolutePath,
166
+ relativePath: entry.name,
167
+ name: entry.name,
168
+ size: stat.size,
169
+ modifiedMs: stat.mtimeMs,
170
+ modifiedAt: stat.mtime?.toISOString() || null
171
+ });
172
+ counters.files += 1;
173
+ counters.totalBytes += stat.size;
174
+ continue;
175
+ }
176
+ counters.folders += 1;
177
+ await walkDirectory(registry, entry, entry.name, files, visited, counters);
178
+ }
179
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath, undefined, { numeric: true }));
180
+ return { ...counters, files };
181
+ }
182
+
183
+ export function toFilesystemHttpError(error) {
184
+ return error?.status
185
+ ? error
186
+ : filesystemError('filesystem-operation-failed', 'FILESYSTEM_ERROR', 500);
187
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { discoverFilesystemRoots } from './roots.js';
4
+ import { OpaquePathRegistry, isPathWithinRoot } from './path-registry.js';
5
+ import { readDirectoryEntries, scanSelection } from './directory-reader.js';
6
+
7
+ export class HubFilesystem {
8
+ constructor() {
9
+ this.registry = new OpaquePathRegistry();
10
+ this.rootsCache = null;
11
+ this.rootsLoadedAt = 0;
12
+ }
13
+
14
+ async getRoots({ refresh = false } = {}) {
15
+ if (!refresh && this.rootsCache && Date.now() - this.rootsLoadedAt < 30_000) return this.rootsCache;
16
+ const roots = await discoverFilesystemRoots();
17
+ const entries = roots.map(root => this.registry.register({
18
+ absolutePath: root.path,
19
+ rootPath: root.path,
20
+ rootId: `root:${root.path}`,
21
+ parentId: 'this-pc',
22
+ name: root.name,
23
+ type: 'folder',
24
+ displayPath: root.displayPath
25
+ }));
26
+ this.rootsCache = {
27
+ roots: entries.map((entry, index) => ({
28
+ id: entry.id,
29
+ parentId: 'this-pc',
30
+ name: entry.name,
31
+ type: 'drive',
32
+ size: 0,
33
+ modifiedAt: null,
34
+ hasChildren: roots[index].hasChildren,
35
+ displayPath: roots[index].displayPath,
36
+ driveType: roots[index].driveType,
37
+ totalBytes: roots[index].totalBytes,
38
+ freeBytes: roots[index].freeBytes
39
+ }))
40
+ };
41
+ this.rootsLoadedAt = Date.now();
42
+ return this.rootsCache;
43
+ }
44
+
45
+ async getEntries(id) {
46
+ return readDirectoryEntries(this.registry, id);
47
+ }
48
+
49
+ async scan(itemIds) {
50
+ return scanSelection(this.registry, itemIds);
51
+ }
52
+
53
+ resolve(id) {
54
+ return this.registry.resolve(id);
55
+ }
56
+
57
+ async registerPersistedFolder(absolutePath, displayPath = absolutePath) {
58
+ const normalized = path.resolve(String(absolutePath || ''));
59
+ const roots = await discoverFilesystemRoots();
60
+ const root = roots.find(candidate => isPathWithinRoot(candidate.path, normalized));
61
+ if (!root) return null;
62
+ const stat = await fs.lstat(normalized).catch(() => null);
63
+ if (!stat?.isDirectory()) return null;
64
+ return this.registry.register({
65
+ absolutePath: normalized,
66
+ rootPath: root.path,
67
+ rootId: `root:${root.path}`,
68
+ parentId: null,
69
+ name: path.basename(normalized) || normalized,
70
+ type: 'folder',
71
+ displayPath
72
+ });
73
+ }
74
+ }
75
+
76
+ export function createHubFilesystem() {
77
+ return new HubFilesystem();
78
+ }
@@ -0,0 +1,78 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ function isWithinRoot(rootPath, candidatePath) {
5
+ const relative = path.relative(rootPath, candidatePath);
6
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
7
+ }
8
+
9
+ export class OpaquePathRegistry {
10
+ constructor({ ttlMs = 30 * 60 * 1000 } = {}) {
11
+ this.ttlMs = ttlMs;
12
+ this.entries = new Map();
13
+ this.byPath = new Map();
14
+ }
15
+
16
+ register({ absolutePath, rootPath, rootId, parentId = null, name, type, displayPath = absolutePath, locked = false }) {
17
+ const normalizedPath = path.resolve(absolutePath);
18
+ const normalizedRoot = path.resolve(rootPath);
19
+ if (!isWithinRoot(normalizedRoot, normalizedPath)) {
20
+ const error = new Error('filesystem-path-outside-root');
21
+ error.code = 'PATH_OUTSIDE_ROOT';
22
+ throw error;
23
+ }
24
+ const key = `${rootId}:${normalizedPath}`;
25
+ const existing = this.byPath.get(key);
26
+ if (existing && this.entries.has(existing)) {
27
+ const entry = this.entries.get(existing);
28
+ entry.expiresAt = Date.now() + this.ttlMs;
29
+ return entry;
30
+ }
31
+ const entry = {
32
+ id: `fs_${crypto.randomBytes(18).toString('base64url')}`,
33
+ absolutePath: normalizedPath,
34
+ rootPath: normalizedRoot,
35
+ rootId: String(rootId),
36
+ parentId,
37
+ name: String(name || path.basename(normalizedPath) || normalizedPath),
38
+ type: type === 'file' ? 'file' : 'folder',
39
+ displayPath: String(displayPath || normalizedPath),
40
+ locked: locked === true,
41
+ expiresAt: Date.now() + this.ttlMs
42
+ };
43
+ this.entries.set(entry.id, entry);
44
+ this.byPath.set(key, entry.id);
45
+ return entry;
46
+ }
47
+
48
+ resolve(id) {
49
+ this.prune();
50
+ const entry = this.entries.get(String(id || ''));
51
+ if (!entry) {
52
+ const error = new Error('invalid-filesystem-id');
53
+ error.code = 'INVALID_ID';
54
+ error.status = 400;
55
+ throw error;
56
+ }
57
+ entry.expiresAt = Date.now() + this.ttlMs;
58
+ return entry;
59
+ }
60
+
61
+ remove(id) {
62
+ const entry = this.entries.get(String(id || ''));
63
+ if (!entry) return;
64
+ this.entries.delete(entry.id);
65
+ this.byPath.delete(`${entry.rootId}:${entry.absolutePath}`);
66
+ }
67
+
68
+ prune() {
69
+ const now = Date.now();
70
+ for (const [id, entry] of this.entries) {
71
+ if (entry.expiresAt <= now) this.remove(id);
72
+ }
73
+ }
74
+ }
75
+
76
+ export function isPathWithinRoot(rootPath, candidatePath) {
77
+ return isWithinRoot(path.resolve(rootPath), path.resolve(candidatePath));
78
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import os from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ function normalizeRootPath(value) {
10
+ const text = String(value || '').trim();
11
+ if (!text) return '';
12
+ return process.platform === 'win32'
13
+ ? text.replace(/[\\/]+$/, '\\')
14
+ : text.replace(/\/+$/, '') || '/';
15
+ }
16
+
17
+ async function statfsCapacity(rootPath) {
18
+ try {
19
+ const stat = await fs.statfs(rootPath);
20
+ const blockSize = Number(stat.bsize || stat.frsize || 0);
21
+ return {
22
+ totalBytes: blockSize > 0 ? Number(stat.blocks || 0) * blockSize : 0,
23
+ freeBytes: blockSize > 0 ? Number(stat.bavail || stat.bfree || 0) * blockSize : 0
24
+ };
25
+ } catch {
26
+ return { totalBytes: 0, freeBytes: 0 };
27
+ }
28
+ }
29
+
30
+ async function windowsDriveTypes() {
31
+ if (process.platform !== 'win32') return new Map();
32
+ try {
33
+ const { stdout } = await execFileAsync('powershell.exe', [
34
+ '-NoProfile', '-NonInteractive', '-Command',
35
+ '$ProgressPreference="SilentlyContinue"; Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID,DriveType | ConvertTo-Json -Compress'
36
+ ], { timeout: 2500, windowsHide: true, maxBuffer: 1024 * 1024 });
37
+ const parsed = JSON.parse(String(stdout || 'null'));
38
+ const rows = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
39
+ const names = new Map([[2, 'removable'], [3, 'fixed'], [4, 'network'], [5, 'optical']]);
40
+ return new Map(rows.map(row => [String(row.DeviceID || '').toUpperCase(), names.get(Number(row.DriveType)) || 'unknown']));
41
+ } catch {
42
+ return new Map();
43
+ }
44
+ }
45
+
46
+ async function windowsRoots() {
47
+ const types = await windowsDriveTypes();
48
+ const roots = [];
49
+ for (let code = 65; code <= 90; code += 1) {
50
+ const letter = String.fromCharCode(code);
51
+ const path = `${letter}:\\`;
52
+ try {
53
+ await fs.access(path, fsConstants.R_OK);
54
+ const capacity = await statfsCapacity(path);
55
+ const driveType = types.get(`${letter}:`.toUpperCase()) || 'fixed';
56
+ roots.push({
57
+ path,
58
+ name: `${driveType === 'network' ? 'Network' : driveType === 'removable' ? 'External' : 'Drive'} (${letter}:)`,
59
+ displayPath: path,
60
+ type: 'drive',
61
+ driveType,
62
+ ...capacity,
63
+ hasChildren: true
64
+ });
65
+ } catch {
66
+ // An unavailable or inaccessible drive is not exposed to the browser.
67
+ }
68
+ }
69
+ return roots;
70
+ }
71
+
72
+ async function mountedRoots() {
73
+ const candidates = new Set(['/']);
74
+ if (process.platform === 'darwin') candidates.add('/Volumes');
75
+ if (process.platform === 'linux') {
76
+ candidates.add('/mnt');
77
+ candidates.add('/media');
78
+ }
79
+ const roots = [];
80
+ for (const candidate of candidates) {
81
+ try {
82
+ const stat = await fs.stat(candidate);
83
+ if (!stat.isDirectory()) continue;
84
+ const capacity = await statfsCapacity(candidate);
85
+ roots.push({
86
+ path: candidate,
87
+ name: candidate === '/' ? 'System root (/)' : candidate,
88
+ displayPath: candidate,
89
+ type: 'drive',
90
+ driveType: candidate === '/' ? 'fixed' : 'mount',
91
+ ...capacity,
92
+ hasChildren: true
93
+ });
94
+ } catch {
95
+ // Keep probing the remaining mount points.
96
+ }
97
+ }
98
+ return roots;
99
+ }
100
+
101
+ export async function discoverFilesystemRoots() {
102
+ const roots = process.platform === 'win32' ? await windowsRoots() : await mountedRoots();
103
+ if (roots.length > 0) return roots;
104
+ const fallback = process.platform === 'win32' ? `${process.env.SystemDrive || 'C:'}\\` : os.homedir();
105
+ return [{
106
+ path: fallback,
107
+ name: fallback,
108
+ displayPath: fallback,
109
+ type: 'drive',
110
+ driveType: 'unknown',
111
+ totalBytes: 0,
112
+ freeBytes: 0,
113
+ hasChildren: true
114
+ }];
115
+ }
@@ -0,0 +1,180 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const MANIFEST_VERSION = 1;
7
+ const AUTO_SYNC_MS = 30_000;
8
+
9
+ function publicFolder(folder, sourceId, status = 'ready', message = '') {
10
+ return {
11
+ id: folder.id,
12
+ sourceId,
13
+ displayPath: folder.displayPath,
14
+ name: folder.name,
15
+ autoSync: folder.autoSync === true,
16
+ status,
17
+ fileCount: Number(folder.fileCount || 0),
18
+ totalBytes: Number(folder.totalBytes || 0),
19
+ lastScannedAt: folder.lastScannedAt || undefined,
20
+ lastSyncedAt: folder.lastSyncedAt || undefined,
21
+ message: message || folder.message || undefined
22
+ };
23
+ }
24
+
25
+ export class HubSharedFolders {
26
+ constructor({ filesystem, transferJobs, remoteHub, dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
27
+ this.filesystem = filesystem;
28
+ this.transferJobs = transferJobs;
29
+ this.remoteHub = remoteHub;
30
+ this.dataDir = dataDir;
31
+ this.filePath = path.join(dataDir, 'shared-folders.json');
32
+ this.folders = new Map();
33
+ this.loaded = false;
34
+ this.autoSyncTimer = null;
35
+ this.syncing = new Set();
36
+ }
37
+
38
+ async ensureLoaded() {
39
+ if (this.loaded) return;
40
+ this.loaded = true;
41
+ try {
42
+ const parsed = JSON.parse(await fs.readFile(this.filePath, 'utf8'));
43
+ for (const folder of Array.isArray(parsed?.folders) ? parsed.folders : []) {
44
+ if (folder?.id && folder?.sourcePath) this.folders.set(folder.id, { ...folder, manifestVersion: MANIFEST_VERSION });
45
+ }
46
+ } catch (error) {
47
+ if (error?.code !== 'ENOENT') console.warn(`[LiveDesk Hub] shared folder store unavailable: ${error?.message || error}`);
48
+ }
49
+ }
50
+
51
+ async persist() {
52
+ await fs.mkdir(this.dataDir, { recursive: true });
53
+ const temp = `${this.filePath}.tmp`;
54
+ await fs.writeFile(temp, JSON.stringify({ version: MANIFEST_VERSION, folders: [...this.folders.values()] }, null, 2), 'utf8');
55
+ await fs.rename(temp, this.filePath);
56
+ }
57
+
58
+ async list() {
59
+ await this.ensureLoaded();
60
+ const result = [];
61
+ for (const folder of this.folders.values()) {
62
+ const entry = await this.filesystem.registerPersistedFolder(folder.sourcePath, folder.displayPath);
63
+ result.push(publicFolder(folder, entry?.id || '', entry ? 'ready' : 'error', entry ? '' : 'Drive or folder unavailable'));
64
+ }
65
+ return result;
66
+ }
67
+
68
+ async add(sourceId) {
69
+ await this.ensureLoaded();
70
+ const entry = this.filesystem.resolve(sourceId);
71
+ if (entry.type !== 'folder') throw new Error('sync-source-must-be-folder');
72
+ const existing = [...this.folders.values()].find(folder => path.resolve(folder.sourcePath) === path.resolve(entry.absolutePath));
73
+ if (existing) return publicFolder(existing, sourceId);
74
+ const folder = {
75
+ id: `sync_${crypto.randomBytes(12).toString('base64url')}`,
76
+ sourcePath: entry.absolutePath,
77
+ displayPath: entry.displayPath,
78
+ name: entry.name,
79
+ autoSync: false,
80
+ fileCount: 0,
81
+ totalBytes: 0,
82
+ manifestVersion: MANIFEST_VERSION,
83
+ manifests: {}
84
+ };
85
+ this.folders.set(folder.id, folder);
86
+ await this.persist();
87
+ return publicFolder(folder, sourceId);
88
+ }
89
+
90
+ async update(id, patch = {}) {
91
+ await this.ensureLoaded();
92
+ const folder = this.folders.get(String(id || ''));
93
+ if (!folder) throw new Error('sync-folder-not-found');
94
+ if (typeof patch.autoSync === 'boolean') folder.autoSync = patch.autoSync;
95
+ await this.persist();
96
+ const entry = await this.filesystem.registerPersistedFolder(folder.sourcePath, folder.displayPath);
97
+ return publicFolder(folder, entry?.id || '', entry ? 'ready' : 'error', entry ? '' : 'Drive or folder unavailable');
98
+ }
99
+
100
+ async remove(id) {
101
+ await this.ensureLoaded();
102
+ const removed = this.folders.delete(String(id || ''));
103
+ if (removed) await this.persist();
104
+ return { ok: removed };
105
+ }
106
+
107
+ async sync(id, deviceIds, remoteDirectory) {
108
+ await this.ensureLoaded();
109
+ const folder = this.folders.get(String(id || ''));
110
+ if (!folder) throw new Error('sync-folder-not-found');
111
+ if (this.syncing.has(folder.id)) throw new Error('sync-already-running');
112
+ const source = await this.filesystem.registerPersistedFolder(folder.sourcePath, folder.displayPath);
113
+ if (!source) throw new Error('sync-source-unavailable');
114
+ const scan = await this.filesystem.scan([source.id]);
115
+ folder.fileCount = scan.files.length;
116
+ folder.totalBytes = scan.totalBytes;
117
+ folder.lastScannedAt = new Date().toISOString();
118
+ folder.message = '';
119
+ const jobs = [];
120
+ const targets = [...new Set((Array.isArray(deviceIds) ? deviceIds : []).map(value => String(value || '').trim()).filter(Boolean))];
121
+ this.syncing.add(folder.id);
122
+ try {
123
+ for (const deviceId of targets) {
124
+ const previous = folder.manifests?.[deviceId]?.entries || {};
125
+ const changedFiles = scan.files.filter(file => previous[file.relativePath]?.size !== file.size || previous[file.relativePath]?.modifiedMs !== file.modifiedMs);
126
+ if (changedFiles.length === 0) continue;
127
+ const fingerprints = Object.fromEntries(scan.files.map(file => [file.relativePath, { size: file.size, modifiedMs: file.modifiedMs }]));
128
+ jobs.push(this.transferJobs.create({
129
+ files: changedFiles,
130
+ deviceIds: [deviceId],
131
+ remoteDirectory,
132
+ onComplete: async ({ completed }) => {
133
+ if (!completed) return;
134
+ folder.manifests = folder.manifests || {};
135
+ folder.manifests[deviceId] = { version: MANIFEST_VERSION, entries: fingerprints, updatedAt: new Date().toISOString() };
136
+ folder.lastSyncedAt = new Date().toISOString();
137
+ await this.persist();
138
+ }
139
+ }));
140
+ }
141
+ folder.message = jobs.length > 0 ? `Syncing ${jobs.length} workstation${jobs.length === 1 ? '' : 's'}` : 'No changed files';
142
+ await this.persist();
143
+ return { ok: true, jobs, files: scan.files.length, totalBytes: scan.totalBytes };
144
+ } finally {
145
+ this.syncing.delete(folder.id);
146
+ }
147
+ }
148
+
149
+ startAutoSync(getDeviceIds, getRemoteDirectory = () => '') {
150
+ if (this.autoSyncTimer) return;
151
+ this.autoSyncTimer = setInterval(() => {
152
+ void this.runAutoSync(getDeviceIds, getRemoteDirectory);
153
+ }, AUTO_SYNC_MS);
154
+ this.autoSyncTimer.unref?.();
155
+ }
156
+
157
+ async runAutoSync(getDeviceIds, getRemoteDirectory) {
158
+ const deviceIds = typeof getDeviceIds === 'function' ? getDeviceIds() : [];
159
+ if (!deviceIds.length) return;
160
+ await this.ensureLoaded();
161
+ for (const folder of this.folders.values()) {
162
+ if (!folder.autoSync || this.syncing.has(folder.id)) continue;
163
+ try {
164
+ await this.sync(folder.id, deviceIds, getRemoteDirectory());
165
+ } catch (error) {
166
+ folder.message = error instanceof Error ? error.message : String(error);
167
+ await this.persist().catch(() => undefined);
168
+ }
169
+ }
170
+ }
171
+
172
+ close() {
173
+ if (this.autoSyncTimer) clearInterval(this.autoSyncTimer);
174
+ this.autoSyncTimer = null;
175
+ }
176
+ }
177
+
178
+ export function createHubSharedFolders(options) {
179
+ return new HubSharedFolders(options);
180
+ }