@reidelsaltres/pureper 0.2.32 → 0.2.34
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/out/foundation/CacheManager.d.ts +20 -0
- package/out/foundation/CacheManager.d.ts.map +1 -0
- package/out/foundation/CacheManager.js +129 -0
- package/out/foundation/CacheManager.js.map +1 -0
- package/out/foundation/Injection.d.ts +6 -0
- package/out/foundation/Injection.d.ts.map +1 -1
- package/out/foundation/Injection.js +23 -0
- package/out/foundation/Injection.js.map +1 -1
- package/out/foundation/Module.d.ts +47 -5
- package/out/foundation/Module.d.ts.map +1 -1
- package/out/foundation/Module.js +277 -26
- package/out/foundation/Module.js.map +1 -1
- package/out/foundation/Theme.d.ts +2 -0
- package/out/foundation/Theme.d.ts.map +1 -1
- package/out/foundation/Theme.js +9 -1
- package/out/foundation/Theme.js.map +1 -1
- package/out/foundation/Triplet.d.ts +10 -0
- package/out/foundation/Triplet.d.ts.map +1 -1
- package/out/foundation/Triplet.js +17 -0
- package/out/foundation/Triplet.js.map +1 -1
- package/out/foundation/TripletDecorator.d.ts.map +1 -1
- package/out/foundation/TripletDecorator.js +28 -1
- package/out/foundation/TripletDecorator.js.map +1 -1
- package/out/foundation/engine/Expression.d.ts.map +1 -1
- package/out/foundation/engine/Expression.js +11 -11
- package/out/foundation/engine/Expression.js.map +1 -1
- package/out/foundation/engine/TemplateEngine.d.ts.map +1 -1
- package/out/foundation/engine/TemplateEngine.js +6 -2
- package/out/foundation/engine/TemplateEngine.js.map +1 -1
- package/out/foundation/worker/ServiceWorker.d.ts +2 -25
- package/out/foundation/worker/ServiceWorker.d.ts.map +1 -1
- package/out/foundation/worker/ServiceWorker.js +2 -94
- package/out/foundation/worker/ServiceWorker.js.map +1 -1
- package/out/index.d.ts +4 -3
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -2
- package/out/index.js.map +1 -1
- package/package.json +1 -1
- package/src/foundation/CacheManager.ts +151 -0
- package/src/foundation/Injection.ts +24 -0
- package/src/foundation/Module.ts +312 -27
- package/src/foundation/Theme.ts +10 -1
- package/src/foundation/Triplet.ts +21 -0
- package/src/foundation/TripletDecorator.ts +19 -1
- package/src/foundation/engine/Expression.ts +11 -12
- package/src/foundation/engine/TemplateEngine.ts +6 -3
- package/src/foundation/worker/ServiceWorker.ts +2 -101
- package/src/index.ts +4 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Observable from "./api/Observer.js";
|
|
2
|
+
import Fetcher from "./Fetcher.js";
|
|
3
|
+
|
|
4
|
+
export type DownloadProgress = {
|
|
5
|
+
totalFiles: number;
|
|
6
|
+
completedFiles: number;
|
|
7
|
+
currentFile: string;
|
|
8
|
+
totalBytes: number;
|
|
9
|
+
downloadedBytes: number;
|
|
10
|
+
speed: number;
|
|
11
|
+
active: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default class CacheManager {
|
|
15
|
+
private static readonly CACHE_NAME = 'purper-modules';
|
|
16
|
+
|
|
17
|
+
static resolveUrl(url: string): string {
|
|
18
|
+
return Fetcher.resolveUrl(url);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static async download(urls: string[], progress?: Observable<DownloadProgress>): Promise<number> {
|
|
22
|
+
if (urls.length === 0) return 0;
|
|
23
|
+
|
|
24
|
+
const totalFiles = urls.length;
|
|
25
|
+
let completedFiles = 0;
|
|
26
|
+
let downloadedBytes = 0;
|
|
27
|
+
let totalBytes = 0;
|
|
28
|
+
let lastTime = performance.now();
|
|
29
|
+
let lastBytes = 0;
|
|
30
|
+
let speed = 0;
|
|
31
|
+
|
|
32
|
+
const update = (currentFile: string, active: boolean) => {
|
|
33
|
+
progress?.setObject({ totalFiles, completedFiles, currentFile, totalBytes, downloadedBytes, speed, active });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Estimate total size via HEAD
|
|
37
|
+
const sizes = await Promise.all(urls.map(async (url) => {
|
|
38
|
+
try {
|
|
39
|
+
const resp = await fetch(this.resolveUrl(url), { method: 'HEAD' });
|
|
40
|
+
const cl = resp.headers.get('content-length');
|
|
41
|
+
return cl ? parseInt(cl, 10) : 0;
|
|
42
|
+
} catch { return 0; }
|
|
43
|
+
}));
|
|
44
|
+
totalBytes = sizes.reduce((a, b) => a + b, 0);
|
|
45
|
+
|
|
46
|
+
update('', true);
|
|
47
|
+
|
|
48
|
+
const failedFiles: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (const url of urls) {
|
|
51
|
+
const resolved = this.resolveUrl(url);
|
|
52
|
+
const fileName = url.split('/').pop() || url;
|
|
53
|
+
update(fileName, true);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(resolved);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
console.warn(`[CacheManager]: Failed to download ${url}: ${response.status}`);
|
|
59
|
+
failedFiles.push(url);
|
|
60
|
+
completedFiles++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const reader = response.body?.getReader();
|
|
65
|
+
const chunks: BlobPart[] = [];
|
|
66
|
+
|
|
67
|
+
if (reader) {
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
if (done) break;
|
|
71
|
+
chunks.push(value);
|
|
72
|
+
downloadedBytes += value.byteLength;
|
|
73
|
+
|
|
74
|
+
const now = performance.now();
|
|
75
|
+
const elapsed = (now - lastTime) / 1000;
|
|
76
|
+
if (elapsed >= 0.5) {
|
|
77
|
+
speed = (downloadedBytes - lastBytes) / elapsed;
|
|
78
|
+
lastTime = now;
|
|
79
|
+
lastBytes = downloadedBytes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
update(fileName, true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if ('caches' in window) {
|
|
87
|
+
try {
|
|
88
|
+
const cache = await caches.open(this.CACHE_NAME);
|
|
89
|
+
const blob = new Blob(chunks);
|
|
90
|
+
const cacheResponse = new Response(blob, { headers: response.headers });
|
|
91
|
+
await cache.put(resolved, cacheResponse);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(`[CacheManager]: Failed to cache ${url}`, e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
completedFiles++;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.warn(`[CacheManager]: Error downloading ${url}`, e);
|
|
100
|
+
failedFiles.push(url);
|
|
101
|
+
completedFiles++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
speed = 0;
|
|
106
|
+
update('', false);
|
|
107
|
+
|
|
108
|
+
if (failedFiles.length > 0) {
|
|
109
|
+
throw new Error(`[CacheManager]: ${failedFiles.length} file(s) failed to download: ${failedFiles.join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return downloadedBytes;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static async delete(urls: string[]): Promise<void> {
|
|
116
|
+
if (!('caches' in window) || urls.length === 0) return;
|
|
117
|
+
const cache = await caches.open(this.CACHE_NAME);
|
|
118
|
+
for (const url of urls) {
|
|
119
|
+
await cache.delete(this.resolveUrl(url));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static async isCached(url: string): Promise<boolean> {
|
|
124
|
+
if (!('caches' in window)) return false;
|
|
125
|
+
const cache = await caches.open(this.CACHE_NAME);
|
|
126
|
+
const match = await cache.match(this.resolveUrl(url));
|
|
127
|
+
return match !== undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async getCachedSize(urls: string[]): Promise<number> {
|
|
131
|
+
if (!('caches' in window) || urls.length === 0) return 0;
|
|
132
|
+
const cache = await caches.open(this.CACHE_NAME);
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const url of urls) {
|
|
135
|
+
const match = await cache.match(this.resolveUrl(url));
|
|
136
|
+
if (match) {
|
|
137
|
+
const blob = await match.blob();
|
|
138
|
+
total += blob.size;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return total;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static formatBytes(bytes: number): string {
|
|
145
|
+
if (bytes === 0) return '0 B';
|
|
146
|
+
const k = 1024;
|
|
147
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
148
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
149
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -165,6 +165,30 @@ export class Placeholder {
|
|
|
165
165
|
console.info(`[Placeholder:${placeholderName}]: Reloaded ${reloads.length} instance(s)`);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** Return all registered placeholder names. */
|
|
169
|
+
public static getAllNames(): string[] {
|
|
170
|
+
return Array.from(this._all.keys());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Deactivate a placeholder — hides all instances. */
|
|
174
|
+
public static deactivate(name: string): void {
|
|
175
|
+
const p = this._all.get(name);
|
|
176
|
+
if (!p) return;
|
|
177
|
+
p.activeImpl.setObject(null);
|
|
178
|
+
console.info(`[Placeholder:${name}]: Deactivated`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Activate a placeholder — restores the default implementation. */
|
|
182
|
+
public static activate(name: string): void {
|
|
183
|
+
const p = this._all.get(name);
|
|
184
|
+
if (!p) return;
|
|
185
|
+
const first = p.implementations.values().next().value;
|
|
186
|
+
if (first && !p.activeImpl.getObject()) {
|
|
187
|
+
p.activeImpl.setObject(first);
|
|
188
|
+
console.info(`[Placeholder:${name}]: Activated with "${first.name}"`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
168
192
|
/**
|
|
169
193
|
* Switch a single instance to a different implementation and reload it.
|
|
170
194
|
* Does NOT change the global active implementation.
|
package/src/foundation/Module.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import Observable from "./api/Observer.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Placeholder } from "./Injection.js";
|
|
3
|
+
import { REGISTRY, RegistryCapture } from "./Triplet.js";
|
|
4
|
+
import CacheManager, { DownloadProgress } from "./CacheManager.js";
|
|
5
|
+
|
|
6
|
+
export type SubModuleStruct = {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
inbuilt?: boolean;
|
|
10
|
+
resources?: string[];
|
|
11
|
+
};
|
|
3
12
|
|
|
4
13
|
export type ModuleStruct = {
|
|
5
14
|
name: string;
|
|
@@ -7,25 +16,51 @@ export type ModuleStruct = {
|
|
|
7
16
|
icon?: string;
|
|
8
17
|
core?: boolean;
|
|
9
18
|
enabled?: boolean;
|
|
19
|
+
resources?: string[];
|
|
20
|
+
subModules?: SubModuleStruct[];
|
|
10
21
|
};
|
|
11
22
|
|
|
12
|
-
export default class Module {
|
|
23
|
+
export default class Module extends Observable<boolean> {
|
|
13
24
|
public readonly name: string;
|
|
14
25
|
public readonly description?: string;
|
|
15
26
|
public readonly icon?: string;
|
|
16
27
|
public readonly core: boolean;
|
|
17
|
-
public readonly enabled: Observable<boolean>;
|
|
18
28
|
public readonly downloaded: Observable<boolean>;
|
|
29
|
+
public readonly resources: string[];
|
|
30
|
+
public readonly totalSize: Observable<number>;
|
|
31
|
+
public readonly downloadProgress: Observable<DownloadProgress>;
|
|
32
|
+
public readonly downloadError: Observable<string>;
|
|
19
33
|
|
|
20
34
|
private _registrations: (() => Promise<void>)[] = [];
|
|
35
|
+
private _placeholderNames: string[] = [];
|
|
36
|
+
private _initialized: boolean = false;
|
|
37
|
+
private _subModules: SubModule[] = [];
|
|
38
|
+
private static _claimedPlaceholders = new Set<string>();
|
|
39
|
+
|
|
40
|
+
public get enabled(): Observable<boolean> {
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
21
43
|
|
|
22
44
|
public constructor(struct: ModuleStruct) {
|
|
45
|
+
super(struct.core ? true : (struct.enabled ?? false));
|
|
23
46
|
this.name = struct.name;
|
|
24
47
|
this.description = struct.description;
|
|
25
48
|
this.icon = struct.icon;
|
|
26
49
|
this.core = struct.core ?? false;
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
50
|
+
this.downloaded = new Observable<boolean>(false);
|
|
51
|
+
this.resources = struct.resources ?? [];
|
|
52
|
+
this.totalSize = new Observable<number>(0);
|
|
53
|
+
this.downloadProgress = new Observable<DownloadProgress>({
|
|
54
|
+
totalFiles: 0, completedFiles: 0, currentFile: '',
|
|
55
|
+
totalBytes: 0, downloadedBytes: 0, speed: 0, active: false
|
|
56
|
+
});
|
|
57
|
+
this.downloadError = new Observable<string>('');
|
|
58
|
+
|
|
59
|
+
if (struct.subModules) {
|
|
60
|
+
for (const sub of struct.subModules) {
|
|
61
|
+
this.addSubModule(sub);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
29
64
|
}
|
|
30
65
|
|
|
31
66
|
public addRegistration(fn: () => Promise<void>): void {
|
|
@@ -46,12 +81,60 @@ export default class Module {
|
|
|
46
81
|
public captureRegistrations(registry: (() => Promise<void>)[]): void {
|
|
47
82
|
const captured = registry.splice(0, registry.length);
|
|
48
83
|
this._registrations.push(...captured);
|
|
49
|
-
|
|
84
|
+
|
|
85
|
+
// Discover placeholder names created by this module's imports
|
|
86
|
+
for (const name of Placeholder.getAllNames()) {
|
|
87
|
+
if (!Module._claimedPlaceholders.has(name)) {
|
|
88
|
+
Module._claimedPlaceholders.add(name);
|
|
89
|
+
this._placeholderNames.push(name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`[Module]: "${this.name}" captured ${captured.length} registration(s), ${this._placeholderNames.length} placeholder(s)`);
|
|
94
|
+
|
|
95
|
+
const capturedResources = RegistryCapture.drain();
|
|
96
|
+
if (capturedResources.length > 0) {
|
|
97
|
+
this.resources.push(...capturedResources);
|
|
98
|
+
console.log(`[Module]: "${this.name}" auto-captured ${capturedResources.length} resource path(s)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public addPlaceholder(name: string): void {
|
|
103
|
+
this._placeholderNames.push(name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public getPlaceholderNames(): ReadonlyArray<string> {
|
|
107
|
+
return this._placeholderNames;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public markInitialized(): void {
|
|
111
|
+
this._initialized = true;
|
|
50
112
|
}
|
|
51
113
|
|
|
52
|
-
public enable(): void {
|
|
53
|
-
if (this.
|
|
54
|
-
this.
|
|
114
|
+
public async enable(): Promise<void> {
|
|
115
|
+
if (this.getObject() === true) return;
|
|
116
|
+
this.setObject(true);
|
|
117
|
+
|
|
118
|
+
if (!this._initialized) {
|
|
119
|
+
this._initialized = true;
|
|
120
|
+
// Snapshot existing placeholders before running registrations
|
|
121
|
+
const before = new Set(Placeholder.getAllNames());
|
|
122
|
+
for (const reg of this._registrations) {
|
|
123
|
+
await reg();
|
|
124
|
+
}
|
|
125
|
+
// Discover which placeholders were added by this module
|
|
126
|
+
for (const name of Placeholder.getAllNames()) {
|
|
127
|
+
if (!before.has(name)) {
|
|
128
|
+
this._placeholderNames.push(name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// Re-activate previously registered placeholders
|
|
133
|
+
for (const name of this._placeholderNames) {
|
|
134
|
+
Placeholder.activate(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
55
138
|
console.log(`[Module]: "${this.name}" enabled`);
|
|
56
139
|
}
|
|
57
140
|
|
|
@@ -59,13 +142,19 @@ export default class Module {
|
|
|
59
142
|
if (this.core) {
|
|
60
143
|
throw new Error(`[Module]: Cannot disable core module "${this.name}"`);
|
|
61
144
|
}
|
|
62
|
-
if (this.
|
|
63
|
-
this.
|
|
145
|
+
if (this.getObject() === false) return;
|
|
146
|
+
this.setObject(false);
|
|
147
|
+
|
|
148
|
+
// Deactivate placeholders
|
|
149
|
+
for (const name of this._placeholderNames) {
|
|
150
|
+
Placeholder.deactivate(name);
|
|
151
|
+
}
|
|
152
|
+
|
|
64
153
|
console.log(`[Module]: "${this.name}" disabled`);
|
|
65
154
|
}
|
|
66
155
|
|
|
67
156
|
public isActive(): boolean {
|
|
68
|
-
return this.
|
|
157
|
+
return this.getObject() === true;
|
|
69
158
|
}
|
|
70
159
|
|
|
71
160
|
/** True if enabled but not downloaded — works only in the current session. */
|
|
@@ -73,22 +162,161 @@ export default class Module {
|
|
|
73
162
|
return this.isActive() && !this.downloaded.getObject();
|
|
74
163
|
}
|
|
75
164
|
|
|
76
|
-
public download(): void {
|
|
77
|
-
this.downloaded.
|
|
78
|
-
|
|
165
|
+
public async download(): Promise<void> {
|
|
166
|
+
if (this.downloaded.getObject()) return;
|
|
167
|
+
this.downloadError.setObject('');
|
|
168
|
+
|
|
169
|
+
const hasInbuiltSubs = this._subModules.some(s => s.inbuilt && !s.downloaded.getObject() && s.resources.length > 0);
|
|
170
|
+
if (this.resources.length === 0 && !hasInbuiltSubs) {
|
|
171
|
+
this.downloaded.setObject(true);
|
|
172
|
+
console.log(`[Module]: "${this.name}" downloaded (no resources)`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// Download module's own resources
|
|
178
|
+
let totalBytes = await CacheManager.download(this.resources, this.downloadProgress);
|
|
179
|
+
|
|
180
|
+
// Also download all inbuilt sub-modules
|
|
181
|
+
for (const sub of this._subModules) {
|
|
182
|
+
if (sub.inbuilt && !sub.downloaded.getObject()) {
|
|
183
|
+
const subBytes = await CacheManager.download(sub.resources, this.downloadProgress);
|
|
184
|
+
sub.totalSize.setObject(subBytes);
|
|
185
|
+
sub.downloaded.setObject(true);
|
|
186
|
+
totalBytes += subBytes;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.totalSize.setObject(totalBytes);
|
|
191
|
+
this.downloaded.setObject(true);
|
|
192
|
+
// Clear ephemeral flag if re-downloaded
|
|
193
|
+
ModuleManager.clearEphemeralCore(this.name);
|
|
194
|
+
console.log(`[Module]: "${this.name}" downloaded (${this.resources.length} files, ${CacheManager.formatBytes(totalBytes)})`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
197
|
+
this.downloadError.setObject(msg);
|
|
198
|
+
console.error(`[Module]: "${this.name}" download failed:`, msg);
|
|
199
|
+
}
|
|
79
200
|
}
|
|
80
201
|
|
|
81
|
-
public
|
|
82
|
-
|
|
83
|
-
|
|
202
|
+
public formatBytes(bytes: number): string {
|
|
203
|
+
return CacheManager.formatBytes(bytes);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public async undownload(): Promise<void> {
|
|
207
|
+
this.downloadError.setObject('');
|
|
208
|
+
await CacheManager.delete(this.resources);
|
|
209
|
+
|
|
210
|
+
// Also undownload all sub-modules
|
|
211
|
+
for (const sub of this._subModules) {
|
|
212
|
+
if (sub.downloaded.getObject()) {
|
|
213
|
+
if (sub.inbuilt) {
|
|
214
|
+
// Inbuilt subs: clean up directly (bypass the guard)
|
|
215
|
+
await CacheManager.delete(sub.resources);
|
|
216
|
+
sub.downloaded.setObject(false);
|
|
217
|
+
sub.totalSize.setObject(0);
|
|
218
|
+
sub.downloadError.setObject('');
|
|
219
|
+
} else {
|
|
220
|
+
await sub.undownload();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
84
223
|
}
|
|
224
|
+
|
|
85
225
|
this.downloaded.setObject(false);
|
|
226
|
+
this.totalSize.setObject(0);
|
|
86
227
|
console.log(`[Module]: "${this.name}" removed from downloads`);
|
|
87
228
|
}
|
|
229
|
+
|
|
230
|
+
public addSubModule(struct: SubModuleStruct): SubModule {
|
|
231
|
+
const sub = new SubModule(struct, this);
|
|
232
|
+
this._subModules.push(sub);
|
|
233
|
+
return sub;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public getSubModules(): ReadonlyArray<SubModule> {
|
|
237
|
+
return this._subModules;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class SubModule {
|
|
242
|
+
public readonly name: string;
|
|
243
|
+
public readonly description?: string;
|
|
244
|
+
public readonly inbuilt: boolean;
|
|
245
|
+
public readonly resources: string[];
|
|
246
|
+
public readonly downloaded: Observable<boolean>;
|
|
247
|
+
public readonly parent: Module;
|
|
248
|
+
public readonly totalSize: Observable<number>;
|
|
249
|
+
public readonly downloadProgress: Observable<DownloadProgress>;
|
|
250
|
+
public readonly downloadError: Observable<string>;
|
|
251
|
+
|
|
252
|
+
constructor(struct: SubModuleStruct, parent: Module) {
|
|
253
|
+
this.name = struct.name;
|
|
254
|
+
this.description = struct.description;
|
|
255
|
+
this.inbuilt = struct.inbuilt ?? true;
|
|
256
|
+
this.resources = struct.resources ?? [];
|
|
257
|
+
this.downloaded = new Observable<boolean>(false);
|
|
258
|
+
this.parent = parent;
|
|
259
|
+
this.totalSize = new Observable<number>(0);
|
|
260
|
+
this.downloadProgress = new Observable<DownloadProgress>({
|
|
261
|
+
totalFiles: 0, completedFiles: 0, currentFile: '',
|
|
262
|
+
totalBytes: 0, downloadedBytes: 0, speed: 0, active: false
|
|
263
|
+
});
|
|
264
|
+
this.downloadError = new Observable<string>('');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private assertParentActive(): void {
|
|
268
|
+
if (!this.parent.isActive()) {
|
|
269
|
+
throw new Error(`[SubModule]: Cannot access "${this.name}" — parent module "${this.parent.name}" is disabled`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
private assertParentDownloaded(): void {
|
|
273
|
+
if (!this.parent.downloaded.getObject()) {
|
|
274
|
+
throw new Error(`[SubModule]: Cannot access "${this.name}" — parent module "${this.parent.name}" is not downloaded`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public async download(): Promise<void> {
|
|
279
|
+
if (this.inbuilt) {
|
|
280
|
+
throw new Error(`[SubModule]: "${this.name}" is inbuilt and cannot be downloaded separately`);
|
|
281
|
+
}
|
|
282
|
+
this.assertParentDownloaded();
|
|
283
|
+
if (this.downloaded.getObject()) return;
|
|
284
|
+
this.downloadError.setObject('');
|
|
285
|
+
|
|
286
|
+
if (this.resources.length === 0) {
|
|
287
|
+
this.downloaded.setObject(true);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const bytes = await CacheManager.download(this.resources, this.downloadProgress);
|
|
293
|
+
this.totalSize.setObject(bytes);
|
|
294
|
+
this.downloaded.setObject(true);
|
|
295
|
+
console.log(`[SubModule]: "${this.name}" of "${this.parent.name}" downloaded (${CacheManager.formatBytes(bytes)})`);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
298
|
+
this.downloadError.setObject(msg);
|
|
299
|
+
console.error(`[SubModule]: "${this.name}" download failed:`, msg);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public async undownload(): Promise<void> {
|
|
304
|
+
if (this.inbuilt) {
|
|
305
|
+
throw new Error(`[SubModule]: "${this.name}" is inbuilt and cannot be removed separately`);
|
|
306
|
+
}
|
|
307
|
+
if (!this.downloaded.getObject()) return;
|
|
308
|
+
this.downloadError.setObject('');
|
|
309
|
+
|
|
310
|
+
await CacheManager.delete(this.resources);
|
|
311
|
+
this.downloaded.setObject(false);
|
|
312
|
+
this.totalSize.setObject(0);
|
|
313
|
+
console.log(`[SubModule]: "${this.name}" of "${this.parent.name}" removed from downloads`);
|
|
314
|
+
}
|
|
88
315
|
}
|
|
89
316
|
|
|
90
317
|
export class ModuleManager {
|
|
91
318
|
private static _modules: Map<string, Module> = new Map();
|
|
319
|
+
private static _userEphemeralCores = new Set<string>();
|
|
92
320
|
private static readonly STORAGE_KEY = "purper:modules";
|
|
93
321
|
private static readonly SESSION_KEY = "purper:modules:session";
|
|
94
322
|
|
|
@@ -129,6 +357,7 @@ export class ModuleManager {
|
|
|
129
357
|
|
|
130
358
|
for (const mod of this._modules.values()) {
|
|
131
359
|
if (mod.isActive()) {
|
|
360
|
+
mod.markInitialized();
|
|
132
361
|
console.log(`[Module]: Initializing module "${mod.name}" (${mod.getRegistrations().length} registration(s))`);
|
|
133
362
|
for (const reg of mod.getRegistrations()) {
|
|
134
363
|
promises.push(reg());
|
|
@@ -147,18 +376,50 @@ export class ModuleManager {
|
|
|
147
376
|
}
|
|
148
377
|
}
|
|
149
378
|
|
|
379
|
+
// Auto-download enabled modules that are not yet downloaded
|
|
380
|
+
// (fire-and-forget — does not block initialization)
|
|
381
|
+
this.autoDownload();
|
|
382
|
+
|
|
150
383
|
return promises;
|
|
151
384
|
}
|
|
152
385
|
|
|
386
|
+
private static async autoDownload(): Promise<void> {
|
|
387
|
+
for (const mod of this._modules.values()) {
|
|
388
|
+
if (mod.core && !mod.downloaded.getObject() && !this._userEphemeralCores.has(mod.name)) {
|
|
389
|
+
console.log(`[Module]: Auto-downloading "${mod.name}"...`);
|
|
390
|
+
mod.download().then(() => {
|
|
391
|
+
this.persistState();
|
|
392
|
+
}).catch(e => {
|
|
393
|
+
console.warn(`[Module]: Auto-download failed for "${mod.name}"`, e);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
153
399
|
static persistState(): void {
|
|
154
|
-
const localState: Record<string, { enabled: boolean, downloaded: boolean }> = {};
|
|
400
|
+
const localState: Record<string, { enabled: boolean, downloaded: boolean, size: number, subModules?: Record<string, { downloaded: boolean, size: number }> }> = {};
|
|
155
401
|
const sessionState: Record<string, { enabled: boolean }> = {};
|
|
156
402
|
|
|
157
403
|
for (const mod of this._modules.values()) {
|
|
158
|
-
|
|
404
|
+
const subs = mod.getSubModules();
|
|
405
|
+
let subModules: Record<string, { downloaded: boolean, size: number }> | undefined;
|
|
406
|
+
if (subs.length > 0) {
|
|
407
|
+
subModules = {};
|
|
408
|
+
for (const sub of subs) {
|
|
409
|
+
subModules[sub.name] = {
|
|
410
|
+
downloaded: sub.downloaded.getObject() === true,
|
|
411
|
+
size: sub.totalSize.getObject() ?? 0
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
159
415
|
|
|
160
|
-
if (mod.downloaded.getObject()) {
|
|
161
|
-
localState[mod.name] = {
|
|
416
|
+
if (mod.downloaded.getObject() || mod.core) {
|
|
417
|
+
localState[mod.name] = {
|
|
418
|
+
enabled: mod.isActive(),
|
|
419
|
+
downloaded: mod.downloaded.getObject() === true,
|
|
420
|
+
size: mod.totalSize.getObject() ?? 0,
|
|
421
|
+
...(subModules ? { subModules } : {})
|
|
422
|
+
};
|
|
162
423
|
} else if (mod.isActive()) {
|
|
163
424
|
sessionState[mod.name] = { enabled: true };
|
|
164
425
|
}
|
|
@@ -173,25 +434,49 @@ export class ModuleManager {
|
|
|
173
434
|
}
|
|
174
435
|
}
|
|
175
436
|
|
|
437
|
+
static clearEphemeralCore(name: string): void {
|
|
438
|
+
this._userEphemeralCores.delete(name);
|
|
439
|
+
}
|
|
440
|
+
|
|
176
441
|
static restoreState(): void {
|
|
177
442
|
// Restore from localStorage (downloaded modules)
|
|
178
443
|
try {
|
|
179
444
|
const raw = localStorage.getItem(this.STORAGE_KEY);
|
|
180
445
|
if (raw) {
|
|
181
|
-
const state: Record<string, { enabled: boolean, downloaded: boolean } | boolean> = JSON.parse(raw);
|
|
446
|
+
const state: Record<string, { enabled: boolean, downloaded: boolean, size?: number, subModules?: Record<string, { enabled?: boolean, downloaded: boolean, size?: number }> } | boolean> = JSON.parse(raw);
|
|
182
447
|
for (const [name, data] of Object.entries(state)) {
|
|
183
448
|
const mod = this._modules.get(name);
|
|
184
|
-
if (mod
|
|
449
|
+
if (mod) {
|
|
185
450
|
if (typeof data === 'boolean') {
|
|
186
451
|
// Old format: value is just a boolean for enabled state
|
|
187
|
-
mod.
|
|
452
|
+
if (!mod.core) mod.setObject(data);
|
|
188
453
|
console.log(`[Module]: Migrated old format "${name}" → enabled=${data}`);
|
|
189
454
|
continue;
|
|
190
455
|
}
|
|
191
456
|
if (data.downloaded) {
|
|
192
457
|
mod.downloaded.setObject(true);
|
|
193
458
|
}
|
|
194
|
-
|
|
459
|
+
if (data.size) {
|
|
460
|
+
mod.totalSize.setObject(data.size);
|
|
461
|
+
}
|
|
462
|
+
if (!mod.core) {
|
|
463
|
+
mod.setObject(data.enabled);
|
|
464
|
+
}
|
|
465
|
+
// Track core modules explicitly made ephemeral by user
|
|
466
|
+
if (mod.core && !data.downloaded) {
|
|
467
|
+
this._userEphemeralCores.add(name);
|
|
468
|
+
}
|
|
469
|
+
if (data.subModules) {
|
|
470
|
+
for (const sub of mod.getSubModules()) {
|
|
471
|
+
const subData = data.subModules[sub.name];
|
|
472
|
+
if (subData) {
|
|
473
|
+
sub.downloaded.setObject(subData.downloaded);
|
|
474
|
+
if (subData.size) {
|
|
475
|
+
sub.totalSize.setObject(subData.size);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
195
480
|
console.log(`[Module]: Restored "${name}" → downloaded=${data.downloaded}, enabled=${data.enabled}`);
|
|
196
481
|
}
|
|
197
482
|
}
|
|
@@ -208,7 +493,7 @@ export class ModuleManager {
|
|
|
208
493
|
for (const [name, data] of Object.entries(state)) {
|
|
209
494
|
const mod = this._modules.get(name);
|
|
210
495
|
if (mod && !mod.core && !mod.downloaded.getObject()) {
|
|
211
|
-
mod.
|
|
496
|
+
mod.setObject(data.enabled);
|
|
212
497
|
console.log(`[Module]: Restored ephemeral "${name}" → enabled=${data.enabled}`);
|
|
213
498
|
}
|
|
214
499
|
}
|
package/src/foundation/Theme.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Fetcher from "./Fetcher.js";
|
|
2
2
|
import Observable from "./api/Observer.js";
|
|
3
3
|
|
|
4
|
+
export const DEFAULT_THEME = "Blazor";
|
|
4
5
|
export let ACTIVE_THEME_KEY = "Empty";
|
|
5
6
|
let activeThemeSheet: CSSStyleSheet | null = null;
|
|
6
7
|
let _internalThemeSwitch = false;
|
|
@@ -20,7 +21,7 @@ export async function init() {
|
|
|
20
21
|
if (ACTIVE_THEME_KEY) {
|
|
21
22
|
await setTheme(ACTIVE_THEME_KEY);
|
|
22
23
|
} else {
|
|
23
|
-
await setTheme(
|
|
24
|
+
await setTheme(DEFAULT_THEME);
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
export async function setTheme(name: string) {
|
|
@@ -168,3 +169,11 @@ export async function deactivateAppTheme(): Promise<void> {
|
|
|
168
169
|
console.info(`[Theme]: AppTheme "${current.name}" deactivated`);
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
export async function resetToDefault(): Promise<void> {
|
|
173
|
+
await deactivateAppTheme();
|
|
174
|
+
await setTheme(DEFAULT_THEME);
|
|
175
|
+
ACTIVE_THEME_KEY = DEFAULT_THEME;
|
|
176
|
+
localStorage.setItem("theme", DEFAULT_THEME);
|
|
177
|
+
console.info(`[Theme]: Reset to default theme "${DEFAULT_THEME}"`);
|
|
178
|
+
}
|
|
179
|
+
|
|
@@ -10,6 +10,27 @@ import { Implementation, ImplementationStruct, Placeholder } from "./Injection.j
|
|
|
10
10
|
|
|
11
11
|
export const REGISTRY: (() => Promise<void>)[] = [];
|
|
12
12
|
|
|
13
|
+
export class RegistryCapture {
|
|
14
|
+
private static _unclaimed: string[] = [];
|
|
15
|
+
private static _classResources = new Map<Function, string[]>();
|
|
16
|
+
|
|
17
|
+
/** Called by decorators to record which file paths a class uses. */
|
|
18
|
+
static capture(cls: Function, paths: string[]): void {
|
|
19
|
+
this._classResources.set(cls, paths);
|
|
20
|
+
this._unclaimed.push(...paths);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Drain all unclaimed resource paths. Called by Module.captureRegistrations(). */
|
|
24
|
+
static drain(): string[] {
|
|
25
|
+
return this._unclaimed.splice(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get resource paths for a specific class. */
|
|
29
|
+
static getResources(cls: Function): string[] {
|
|
30
|
+
return this._classResources.get(cls) ?? [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export enum AccessType {
|
|
14
35
|
NONE = 0,
|
|
15
36
|
OFFLINE = 1 << 0,
|