@@ -1,6 +1,9 @@
|
|
1
1
|
import { needleDefines } from "./defines.js";
|
2
2
|
export { needleDefines } from "./defines.js";
|
3
3
|
|
4
|
+
import { needleBuildPipeline } from "./build-pipeline.js";
|
5
|
+
export { needleBuildPipeline } from "./build-pipeline.js";
|
6
|
+
|
4
7
|
import { needleBuild } from "./build.js";
|
5
8
|
export { needleBuild } from "./build.js";
|
6
9
|
|
@@ -10,6 +13,9 @@
|
|
10
13
|
import { needlePoster } from "./poster.js"
|
11
14
|
export { needlePoster } from "./poster.js"
|
12
15
|
|
16
|
+
import { needlePWA } from "./pwa.js";
|
17
|
+
export { needlePWA } from "./pwa.js";
|
18
|
+
|
13
19
|
import { needleReload } from "./reload.js"
|
14
20
|
export { needleReload } from "./reload.js"
|
15
21
|
|
@@ -43,7 +49,6 @@
|
|
43
49
|
import { vite_4_4_hack } from "./vite-4.4-hack.js";
|
44
50
|
import { needleImportsLogger } from "./imports-logger.js";
|
45
51
|
import { needleBuildInfo } from "./buildinfo.js";
|
46
|
-
import { needleBuildPipeline } from "./build-pipeline.js";
|
47
52
|
|
48
53
|
|
49
54
|
export * from "./gzip.js";
|
@@ -54,12 +59,33 @@
|
|
54
59
|
allowHotReload: true,
|
55
60
|
}
|
56
61
|
|
57
|
-
/**
|
62
|
+
/** # Needle Engine plugins for Vite
|
63
|
+
* Plugins include hot reload support, meta tags, defines, build pipeline, PWA, and more.
|
64
|
+
* ## Using PWA
|
65
|
+
* How to add PWA support to your vite project:
|
66
|
+
* 1) Install the [vite pwa plugin](https://vite-pwa-org.netlify.app/): `npm install vite-plugin-pwa --save-dev`
|
67
|
+
* 2) Add the following to your vite.config.js:
|
68
|
+
* You first pass the PWAOptions to the needlePlugins function, then you pass the same PWAOptions to the VitePWA plugin.
|
69
|
+
* You *can* use also add a `.webmanifest` file to your web project and edit the [PWA manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) settings there.
|
70
|
+
* ```js
|
71
|
+
* export default defineConfig(async ({ command }) => {
|
72
|
+
// \@type(import("vite-plugin-pwa").VitePWAOptions) (remove the backslash)
|
73
|
+
const PWAOptions = {};
|
74
|
+
...
|
75
|
+
return {
|
76
|
+
plugins: [
|
77
|
+
...
|
78
|
+
needlePlugins(command, needleConfig, { pwaOptions: PWAOptions }),
|
79
|
+
VitePWA(PWAOptions),
|
80
|
+
],
|
81
|
+
}
|
82
|
+
* ```
|
83
|
+
* @param {string} command
|
58
84
|
* @param {import('../types').userSettings} userSettings
|
59
85
|
*/
|
60
86
|
export const needlePlugins = async (command, config, userSettings) => {
|
61
87
|
|
62
|
-
if(!config) config = {}
|
88
|
+
if (!config) config = {}
|
63
89
|
|
64
90
|
// ensure we have user settings initialized with defaults
|
65
91
|
userSettings = { ...defaultUserSettings, ...userSettings }
|
@@ -81,6 +107,7 @@
|
|
81
107
|
needleFacebookInstantGames(command, config, userSettings),
|
82
108
|
needleImportsLogger(command, config, userSettings),
|
83
109
|
needleBuildPipeline(command, config, userSettings),
|
110
|
+
needlePWA(command, config, userSettings),
|
84
111
|
];
|
85
112
|
array.push(await editorConnection(command, config, userSettings, array));
|
86
113
|
return array;
|
@@ -5,7 +5,7 @@
|
|
5
5
|
import { getLoader } from "./engine_gltf.js";
|
6
6
|
import { processNewScripts } from "./engine_mainloop_utils.js";
|
7
7
|
import { registerPrefabProvider, syncInstantiate } from "./engine_networking_instantiate.js";
|
8
|
-
import { assign,SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
|
8
|
+
import { assign, SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
|
9
9
|
import { Context } from "./engine_setup.js";
|
10
10
|
import type { IComponent, IGameObject, SourceIdentifier } from "./engine_types.js";
|
11
11
|
import { download } from "./engine_web_api.js";
|
@@ -413,9 +413,15 @@
|
|
413
413
|
|
414
414
|
|
415
415
|
|
416
|
-
|
417
416
|
const failedTexturePromise = Promise.resolve(null);
|
418
417
|
|
418
|
+
/** Use this if a file is a external image URL
|
419
|
+
* @example
|
420
|
+
* ```ts
|
421
|
+
* @serializable(ImageReference)
|
422
|
+
* myImage?:ImageReference;
|
423
|
+
* ```
|
424
|
+
*/
|
419
425
|
export class ImageReference {
|
420
426
|
|
421
427
|
private static imageReferences = new Map<string, ImageReference>();
|
@@ -507,4 +513,67 @@
|
|
507
513
|
return undefined;
|
508
514
|
}
|
509
515
|
}
|
510
|
-
new ImageReferenceSerializer();
|
516
|
+
new ImageReferenceSerializer();
|
517
|
+
|
518
|
+
|
519
|
+
|
520
|
+
/** Use this if a file is a external file URL. The file can be any arbitrary binary data like a videofile or a text asset
|
521
|
+
*/
|
522
|
+
export class FileReference {
|
523
|
+
|
524
|
+
private static cache = new Map<string, FileReference>();
|
525
|
+
|
526
|
+
static getOrCreate(url: string) {
|
527
|
+
let ref = FileReference.cache.get(url);
|
528
|
+
if (!ref) {
|
529
|
+
ref = new FileReference(url);
|
530
|
+
FileReference.cache.set(url, ref);
|
531
|
+
}
|
532
|
+
return ref;
|
533
|
+
}
|
534
|
+
|
535
|
+
/** Load the file binary data
|
536
|
+
* @returns a promise that resolves to the binary data of the file. Make sure to await this request or use `.then(res => {...})` to get the result.
|
537
|
+
*/
|
538
|
+
async loadRaw(): Promise<Blob> {
|
539
|
+
if (!this.res) this.res = fetch(this.url);
|
540
|
+
return this.res.then(res => res.blob());
|
541
|
+
}
|
542
|
+
|
543
|
+
/** Load the file as text (if the referenced file is a text file like a .txt or .json file)
|
544
|
+
* @returns a promise that resolves to the text data of the file. Make sure to await this request or use `.then(res => {...})` to get the result. If the format is json you can use `JSON.parse(result)` to convert it to a json object
|
545
|
+
*/
|
546
|
+
async loadText(): Promise<string> {
|
547
|
+
if (!this.res) this.res = fetch(this.url);
|
548
|
+
return this.res.then(res => res.text());
|
549
|
+
}
|
550
|
+
|
551
|
+
/** The resolved url to the file */
|
552
|
+
readonly url: string;
|
553
|
+
|
554
|
+
private res?: Promise<Response>;
|
555
|
+
|
556
|
+
constructor(url: string) {
|
557
|
+
this.url = url;
|
558
|
+
}
|
559
|
+
}
|
560
|
+
|
561
|
+
|
562
|
+
export class FileReferenceSerializer extends TypeSerializer {
|
563
|
+
constructor() {
|
564
|
+
super([FileReference]);
|
565
|
+
}
|
566
|
+
|
567
|
+
onSerialize(_data: string, _context: SerializationContext) {
|
568
|
+
return null;
|
569
|
+
}
|
570
|
+
|
571
|
+
onDeserialize(data: string, _context: SerializationContext) {
|
572
|
+
if (typeof data === "string") {
|
573
|
+
const url = resolveUrl(_context.gltfId, data)
|
574
|
+
return FileReference.getOrCreate(url);
|
575
|
+
}
|
576
|
+
return undefined;
|
577
|
+
}
|
578
|
+
}
|
579
|
+
new FileReferenceSerializer();
|
@@ -431,8 +431,6 @@
|
|
431
431
|
this.renderer.shadowMap.type = PCFSoftShadowMap;
|
432
432
|
this.renderer.setSize(this.domWidth, this.domHeight);
|
433
433
|
this.renderer.outputColorSpace = SRGBColorSpace;
|
434
|
-
// https://github.com/mrdoob/three.js/pull/25556
|
435
|
-
this.renderer.useLegacyLights = false;
|
436
434
|
|
437
435
|
this.input.bindEvents();
|
438
436
|
}
|
@@ -198,8 +198,8 @@
|
|
198
198
|
destroyed_objects.push(obj);
|
199
199
|
|
200
200
|
// first disable and call onDestroy on components
|
201
|
-
const components = obj.userData
|
202
|
-
if (components) {
|
201
|
+
const components = obj.userData?.components;
|
202
|
+
if (components != null && Array.isArray(components)) {
|
203
203
|
let lastLength = components.length;
|
204
204
|
for (let i = 0; i < components.length; i++) {
|
205
205
|
const comp: Component = components[i];
|
@@ -69,6 +69,7 @@
|
|
69
69
|
/** the browser event that triggered this event (if any) */
|
70
70
|
readonly source: Event | null;
|
71
71
|
|
72
|
+
/** Is the pointer event created via a touch on screen or a spatial device like a XR controller or hand tracking? */
|
72
73
|
readonly mode: XRTargetRayMode;
|
73
74
|
/** A ray in worldspace for the event.
|
74
75
|
* If the ray is undefined you can also use `space.worldForward` and `space.worldPosition` */
|
@@ -83,12 +84,20 @@
|
|
83
84
|
/** true if this event is a double click */
|
84
85
|
isDoubleClick: boolean = false;
|
85
86
|
|
87
|
+
/** @returns `true` if the event is marked to be used (when `use()` has been called). Default: `false` */
|
88
|
+
get used() { return this._used; }
|
89
|
+
private _used: boolean = false;
|
90
|
+
/** Call to mark an event to be used */
|
91
|
+
use() {
|
92
|
+
this._used = true;
|
93
|
+
}
|
86
94
|
|
87
95
|
/** Unique identifier for this input: a combination of the deviceIndex + button to uniquely identify the exact input (e.g. LeftController:Button0 = 0, RightController:Button1 = 101) */
|
88
96
|
override get pointerId(): number { return this._pointerid; }
|
89
97
|
private readonly _pointerid;
|
90
98
|
|
91
99
|
// this is set via the init arguments (we override it here for intellisense to show the string options)
|
100
|
+
/** What type of input created this event: touch, mouse, xr controller, xr hand tracking... */
|
92
101
|
override get pointerType(): PointerTypeNames { return this._pointerType; }
|
93
102
|
private readonly _pointerType: PointerTypeNames;
|
94
103
|
|
@@ -162,37 +171,84 @@
|
|
162
171
|
declare type KeyboardEventListener = (evt: NEKeyboardEvent) => void;
|
163
172
|
declare type InputEventListener = PointerEventListener | KeyboardEventListener;
|
164
173
|
|
174
|
+
export enum InputEventQueue {
|
175
|
+
Early = -100,
|
176
|
+
Default = 0,
|
177
|
+
Late = 100,
|
178
|
+
}
|
179
|
+
|
165
180
|
export class Input implements IInput {
|
166
181
|
|
167
|
-
|
182
|
+
/** This is a list of event listeners per event type (e.g. pointerdown, pointerup, keydown...). Each entry contains a priority and list of listeners.
|
183
|
+
* That way users can control if they want to receive events before or after other listeners (e.g subscribe to pointer events before the EventSystem receives them) - this allows certain listeners to be always invoked first (or last) and stop propagation
|
184
|
+
* Listeners per event are sorted
|
185
|
+
*/
|
186
|
+
private readonly _eventListeners: { [key: string]: Array<{ priority: number, listeners: InputEventListener[] }> } = {};
|
168
187
|
|
169
|
-
|
188
|
+
/** Adds an event listener for the specified event type. The callback will be called when the event is triggered.
|
189
|
+
* @param type The event type to listen for
|
190
|
+
* @param callback The callback to call when the event is triggered
|
191
|
+
* @param queue The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0.
|
192
|
+
*/
|
193
|
+
addEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener, queue: InputEventQueue = 0): void {
|
170
194
|
if (!this._eventListeners[type]) this._eventListeners[type] = [];
|
171
|
-
this._eventListeners[type]
|
195
|
+
const listeners = this._eventListeners[type];
|
196
|
+
const queueListeners = listeners.find(l => l.priority === queue);
|
197
|
+
if (!queueListeners) {
|
198
|
+
listeners.push({ priority: queue, listeners: [callback] });
|
199
|
+
// ensure we sort the listeners by priority
|
200
|
+
listeners.sort((a, b) => a.priority - b.priority);
|
201
|
+
} else {
|
202
|
+
queueListeners.listeners.push(callback);
|
203
|
+
}
|
172
204
|
}
|
173
|
-
|
205
|
+
/** Removes the event listener from the specified event type. If no queue is specified the listener will be removed from all queues.
|
206
|
+
* @param type The event type to remove the listener from
|
207
|
+
* @param callback The callback to remove
|
208
|
+
* @param queue The queue to remove the listener from. If no queue is specified the listener will be removed from all queues
|
209
|
+
*/
|
210
|
+
removeEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener, queue?: InputEventQueue): void {
|
174
211
|
if (!this._eventListeners[type]) return;
|
175
|
-
const
|
176
|
-
if
|
212
|
+
const listeners = this._eventListeners[type];
|
213
|
+
// if a specific queue is requested the callback should only be removed from that queue
|
214
|
+
if (queue !== undefined) {
|
215
|
+
const queueListeners = listeners.find(l => l.priority === queue);
|
216
|
+
if (!queueListeners) return;
|
217
|
+
const index = queueListeners.listeners.indexOf(callback);
|
218
|
+
if (index >= 0) queueListeners.listeners.splice(index, 1);
|
219
|
+
}
|
220
|
+
// if no queue is requested the callback will be removed from all queues
|
221
|
+
else {
|
222
|
+
for (const l of listeners) {
|
223
|
+
const index = l.listeners.indexOf(callback);
|
224
|
+
if (index >= 0) l.listeners.splice(index, 1);
|
225
|
+
}
|
226
|
+
}
|
177
227
|
}
|
178
228
|
private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
|
229
|
+
let propagationStopped = false;
|
179
230
|
if (evt instanceof NEKeyboardEvent) {
|
180
231
|
const listeners = this._eventListeners[evt.type];
|
181
232
|
if (listeners) {
|
182
|
-
for (const
|
183
|
-
(l
|
233
|
+
for (const queue of listeners) {
|
234
|
+
for (const l of queue.listeners)
|
235
|
+
(l as KeyboardEventListener)(evt);
|
184
236
|
}
|
185
237
|
}
|
186
238
|
}
|
187
239
|
else if (evt instanceof NEPointerEvent) {
|
188
240
|
const listeners = this._eventListeners[evt.type];
|
189
241
|
if (listeners) {
|
190
|
-
for (const
|
191
|
-
if (
|
192
|
-
|
193
|
-
|
242
|
+
for (const queue of listeners) {
|
243
|
+
if (propagationStopped) break;
|
244
|
+
for (const l of queue.listeners) {
|
245
|
+
if (evt.immediatePropagationStopped) {
|
246
|
+
propagationStopped = true;
|
247
|
+
if (debug) console.log("immediatePropagationStopped", evt.type);
|
248
|
+
break;
|
249
|
+
}
|
250
|
+
(l as PointerEventListener)(evt);
|
194
251
|
}
|
195
|
-
(l as PointerEventListener)(evt);
|
196
252
|
}
|
197
253
|
}
|
198
254
|
}
|
@@ -917,14 +973,14 @@
|
|
917
973
|
|
918
974
|
private isMouseEventEmmitedFromTouch(evt: NEPointerEvent) {
|
919
975
|
if (evt.pointerType === PointerType.Mouse) {
|
920
|
-
const lastPointer = this._pointerUpTimestamp.map((v, i) => ({ timestamp: v, index: i})).sort((a, b) => a.timestamp - b.timestamp).at(-1);
|
921
|
-
if(lastPointer && this._pointerTypes[lastPointer.index] === PointerType.Touch) {
|
976
|
+
const lastPointer = this._pointerUpTimestamp.map((v, i) => ({ timestamp: v, index: i })).sort((a, b) => a.timestamp - b.timestamp).at(-1);
|
977
|
+
if (lastPointer && this._pointerTypes[lastPointer.index] === PointerType.Touch) {
|
922
978
|
if (lastPointer.timestamp > 0 && evt.source?.timeStamp !== undefined) {
|
923
979
|
const diff = (evt.source.timeStamp - lastPointer.timestamp);
|
924
980
|
if (diff < 320 && diff >= 0) {
|
925
981
|
// we received an UP event for a touch, ignore this DOWN event
|
926
982
|
if (debug) console.log("Ignoring mouse.down for touch.up");
|
927
|
-
|
983
|
+
|
928
984
|
return true;
|
929
985
|
}
|
930
986
|
}
|
@@ -947,7 +1003,7 @@
|
|
947
1003
|
|
948
1004
|
private updatePointerPosition(evt: NEPointerEvent) {
|
949
1005
|
const index = evt.pointerId;
|
950
|
-
|
1006
|
+
|
951
1007
|
while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
|
952
1008
|
while (index >= this._pointerPositionsLastFrame.length) this._pointerPositionsLastFrame.push(new Vector2());
|
953
1009
|
while (index >= this._pointerPositionsDelta.length) this._pointerPositionsDelta.push(new Vector2());
|
@@ -1,2 +1,3 @@
|
|
1
1
|
export * from './userconfig';
|
2
|
-
export * from "./needleConfig";
|
2
|
+
export * from "./needleConfig";
|
3
|
+
export * from "./webmanifest";
|
@@ -35,16 +35,16 @@
|
|
35
35
|
readonly buttonName: MouseButtonName | GamepadButtonName | undefined;
|
36
36
|
get pressure(): number { return this.event.pressure; }
|
37
37
|
|
38
|
-
|
39
|
-
/** true when `use()` has been called */
|
38
|
+
/** @returns `true` when `use()` has been called. Default: false */
|
40
39
|
get used(): boolean {
|
41
40
|
return this._used;
|
42
41
|
}
|
43
|
-
|
42
|
+
private _used: boolean = false;
|
44
43
|
/** mark this event to be used */
|
45
44
|
use() {
|
46
45
|
if (this._used) return;
|
47
46
|
this._used = true;
|
47
|
+
this.event.use();
|
48
48
|
if (this.pointerId !== undefined)
|
49
49
|
this.input.setPointerUsed(this.pointerId);
|
50
50
|
}
|
@@ -179,7 +179,7 @@
|
|
179
179
|
const res = GameObject.foreachComponent(obj, comp => {
|
180
180
|
// ignore disabled components
|
181
181
|
if (!comp.enabled) return undefined;
|
182
|
-
|
182
|
+
|
183
183
|
const handler = comp as IPointerEventHandler;
|
184
184
|
// if a specific event is passed in, we only check for that event
|
185
185
|
if (event) {
|
@@ -2,15 +2,25 @@
|
|
2
2
|
import { HTMLMesh } from 'three/examples/jsm/interactive/HTMLMesh.js';
|
3
3
|
import { InteractiveGroup } from 'three/examples/jsm/interactive/InteractiveGroup.js';
|
4
4
|
|
5
|
+
import { serializable } from '../../engine/engine_serialization.js';
|
5
6
|
import { getWorldEuler, getWorldRotation, setWorldRotationXYZ } from '../../engine/engine_three_utils.js';
|
6
7
|
import { Behaviour } from '../Component.js';
|
7
8
|
|
8
9
|
export class SpatialHtml extends Behaviour {
|
9
10
|
|
11
|
+
@serializable()
|
10
12
|
id: string | null = null;
|
13
|
+
@serializable()
|
11
14
|
keepAspect: boolean = false;
|
12
15
|
|
13
|
-
|
16
|
+
private _object: InteractiveGroup | null = null;
|
17
|
+
|
18
|
+
onEnable() {
|
19
|
+
if (this._object) {
|
20
|
+
this.gameObject.add(this._object);
|
21
|
+
return;
|
22
|
+
}
|
23
|
+
|
14
24
|
if (!this.id || !this.context.mainCamera) return;
|
15
25
|
const div = document.getElementById(this.id);
|
16
26
|
if (!div) {
|
@@ -27,7 +37,6 @@
|
|
27
37
|
group.add(mesh);
|
28
38
|
mesh.visible = false;
|
29
39
|
|
30
|
-
console.log(mesh);
|
31
40
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
32
41
|
mat.transparent = true;
|
33
42
|
|
@@ -61,4 +70,8 @@
|
|
61
70
|
mesh.scale.multiply(factor);
|
62
71
|
}, 1);
|
63
72
|
}
|
73
|
+
|
74
|
+
onDisable(): void {
|
75
|
+
this._object?.removeFromParent();
|
76
|
+
}
|
64
77
|
}
|
@@ -43,6 +43,11 @@
|
|
43
43
|
*/
|
44
44
|
posterGenerationMode?: "default" | "once";
|
45
45
|
|
46
|
+
/** When set to true the needle pwa plugin will not run */
|
47
|
+
noPWA?: boolean;
|
48
|
+
/** Pass in the VitePWA options */
|
49
|
+
pwaOptions?: {};
|
50
|
+
|
46
51
|
/** used by nextjs config to forward the webpack module */
|
47
52
|
modules: needleModules
|
48
53
|
}
|
@@ -144,7 +144,7 @@
|
|
144
144
|
}
|
145
145
|
|
146
146
|
get videoElement() {
|
147
|
-
if (this._videoElement) if (!this.create(false)) return null;
|
147
|
+
if (!this._videoElement) if (!this.create(false)) return null;
|
148
148
|
return this._videoElement!;
|
149
149
|
}
|
150
150
|
|
@@ -0,0 +1,418 @@
|
|
1
|
+
|
2
|
+
import { resolve, join, isAbsolute } from 'path'
|
3
|
+
import { readdirSync, existsSync, readFileSync, copyFileSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
4
|
+
import { builtAssetsDirectory, getOutputDirectory, tryLoadProjectConfig } from './config.js';
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
/** Process the PWA manifest
|
9
|
+
* @param {import('../types').userSettings} userSettings
|
10
|
+
* @returns {import('vite').Plugin}
|
11
|
+
*/
|
12
|
+
export const needlePWA = (command, config, userSettings) => {
|
13
|
+
if (command !== "build") return;
|
14
|
+
|
15
|
+
if (config?.noPWA === true || userSettings?.noPWA === true) {
|
16
|
+
return;
|
17
|
+
}
|
18
|
+
|
19
|
+
const pwaOptions = userSettings.pwaOptions;
|
20
|
+
|
21
|
+
const currentDir = process.cwd();
|
22
|
+
const manifests = readdirSync(currentDir).filter(f => f.endsWith(".webmanifest")) || [];
|
23
|
+
if (manifests.length <= 0 && !pwaOptions) {
|
24
|
+
log("No webmanifests found in web project directory or in pwa options that are passed to the needle plugin (via `needlePlugin(command, needleConfig, { pwaOptions: PWAOptions })`)");
|
25
|
+
return;
|
26
|
+
}
|
27
|
+
|
28
|
+
/** The context contains files that are generated by the plugin and should be deleted after
|
29
|
+
* @type {import('../types').NeedlePWAProcessContext} */
|
30
|
+
const context = { generatedFiles: [] }
|
31
|
+
|
32
|
+
if (manifests.length <= 0) {
|
33
|
+
// write the manifes to disc temporarely
|
34
|
+
log("No webmanifests found in web project directory. Generating temporary webmanifest...");
|
35
|
+
const manifestName = "needle.generated.webmanifest";
|
36
|
+
const tempManifestPath = currentDir + "/" + manifestName;
|
37
|
+
const content = JSON.stringify(pwaOptions.manifest || {});
|
38
|
+
writeFileSync(tempManifestPath, content, 'utf8');
|
39
|
+
context.generatedFiles.push(tempManifestPath);
|
40
|
+
manifests.push(manifestName);
|
41
|
+
}
|
42
|
+
|
43
|
+
if (!pwaOptions) {
|
44
|
+
log("WARN: No PWA options found in user settings. Please pass in the pwaOptions to the needlePlugin like so: \"needlePlugins(command, needleConfig, { pwaOptions: PWAOptions })\" and pass the same options to vitePWA plugin like so \"VitePWA(PWAOptions)\"");
|
45
|
+
return cleanup(context);
|
46
|
+
}
|
47
|
+
else {
|
48
|
+
if (!pwaOptions.registerType) {
|
49
|
+
log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
|
50
|
+
pwaOptions.registerType = "autoUpdate";
|
51
|
+
}
|
52
|
+
if (!pwaOptions.outDir) {
|
53
|
+
const outDir = getOutputDirectory();
|
54
|
+
log("Set PWA outDir to " + outDir);
|
55
|
+
pwaOptions.outDir = outDir;
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
// backup the original manifests
|
60
|
+
/** @type {Array<{path:string, copypath:string}>} */
|
61
|
+
const originalManifestContents = manifests.map(filename => {
|
62
|
+
const path = currentDir + "/" + filename;
|
63
|
+
const backupDir = currentDir + "/node_modules/.needle";
|
64
|
+
if (!existsSync(backupDir)) {
|
65
|
+
mkdirSync(backupDir, { recursive: true });
|
66
|
+
}
|
67
|
+
const copyPath = backupDir + "/" + filename + ".original";
|
68
|
+
log("Backup original webmanifest", path, "to", copyPath);
|
69
|
+
copyFileSync(path, copyPath);
|
70
|
+
return { path, copyPath };
|
71
|
+
});
|
72
|
+
|
73
|
+
|
74
|
+
// modify the manifests so they're loaded via VitePlugin
|
75
|
+
for (const manifest of manifests) {
|
76
|
+
try {
|
77
|
+
processPWA(manifest, context).catch(e => log("Error post processing PWA", manifest, e));
|
78
|
+
log("Assign PWA manifest to VitePWA plugin: \"" + manifest + "\"");
|
79
|
+
pwaOptions.manifest = {
|
80
|
+
start_url: "./index.html",
|
81
|
+
...JSON.parse(readFileSync(manifest, 'utf8')),
|
82
|
+
...pwaOptions.manifest
|
83
|
+
};
|
84
|
+
// we only want to process the first manifest
|
85
|
+
break;
|
86
|
+
}
|
87
|
+
catch (e) {
|
88
|
+
log("Error post processing PWA", manifest, e);
|
89
|
+
restoreOriginalManifests(originalManifestContents);
|
90
|
+
break;
|
91
|
+
}
|
92
|
+
}
|
93
|
+
|
94
|
+
/** @type {Array<string>} */
|
95
|
+
const postBuildMessages = [];
|
96
|
+
|
97
|
+
return {
|
98
|
+
name: 'needle-pwa',
|
99
|
+
apply: 'build',
|
100
|
+
enforce: "post",
|
101
|
+
config(config) {
|
102
|
+
try {
|
103
|
+
checkIfVitePWAPluginIsPresent(config);
|
104
|
+
|
105
|
+
// check if the index header contains the webmanifest ALSO
|
106
|
+
// if yes then we want to log a waring at the end
|
107
|
+
const indexPath = currentDir + "/index.html";
|
108
|
+
if (existsSync(indexPath)) {
|
109
|
+
const indexContent = readFileSync(indexPath, 'utf8');
|
110
|
+
if (indexContent.includes(".webmanifest")) {
|
111
|
+
throw new Error("ERR: [needle-pwa] index.html contains a reference to a webmanifest. This is not supported in PWA mode. Please remove the reference from the index.html");
|
112
|
+
}
|
113
|
+
}
|
114
|
+
}
|
115
|
+
catch (err) {
|
116
|
+
cleanup(context);
|
117
|
+
throw err;
|
118
|
+
}
|
119
|
+
finally {
|
120
|
+
restoreOriginalManifests(originalManifestContents);
|
121
|
+
}
|
122
|
+
},
|
123
|
+
transformIndexHtml: {
|
124
|
+
enforce: 'pre',
|
125
|
+
transform(html, _ctx) {
|
126
|
+
// see https://vite-pwa-org.netlify.app/guide/auto-update.html
|
127
|
+
// post transform so we want to linebreak after the vite logs
|
128
|
+
console.log("\n");
|
129
|
+
const scriptContent = `// Injected by needle-pwa
|
130
|
+
import { registerSW } from 'virtual:pwa-register'
|
131
|
+
registerSW({ immediate: true })`;
|
132
|
+
log("Inject PWA script into index.html");
|
133
|
+
return {
|
134
|
+
html,
|
135
|
+
tags: [
|
136
|
+
{
|
137
|
+
tag: 'script',
|
138
|
+
children: scriptContent,
|
139
|
+
attrs: { type: "module" },
|
140
|
+
injectTo: 'head',
|
141
|
+
},
|
142
|
+
]
|
143
|
+
}
|
144
|
+
}
|
145
|
+
},
|
146
|
+
closeBundle() {
|
147
|
+
// copy the icons to the output directory
|
148
|
+
const outputDir = getOutputDirectory();
|
149
|
+
const webmanifestPath = outputDir + "/" + readdirSync(outputDir).find(f => f.endsWith(".webmanifest"));
|
150
|
+
if (webmanifestPath) {
|
151
|
+
try {
|
152
|
+
const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
|
153
|
+
copyIcons(manifest, outputDir);
|
154
|
+
}
|
155
|
+
catch (e) {
|
156
|
+
log("Error post processing PWA", webmanifestPath, e);
|
157
|
+
}
|
158
|
+
}
|
159
|
+
|
160
|
+
cleanup(context);
|
161
|
+
|
162
|
+
for (const msg of postBuildMessages) {
|
163
|
+
console.log(msg);
|
164
|
+
}
|
165
|
+
}
|
166
|
+
}
|
167
|
+
}
|
168
|
+
|
169
|
+
/** Checks if the vite-plugin-pwa is present in the vite config
|
170
|
+
* @param {import('vite').UserConfig} config
|
171
|
+
*/
|
172
|
+
function checkIfVitePWAPluginIsPresent(config) {
|
173
|
+
const plugins = config.plugins || [];
|
174
|
+
let foundVitePWAPlugin = false;
|
175
|
+
for (const plugin of plugins) {
|
176
|
+
if (isVitePWAPlugin(plugin)) {
|
177
|
+
foundVitePWAPlugin = true;
|
178
|
+
return;
|
179
|
+
}
|
180
|
+
}
|
181
|
+
function isVitePWAPlugin(p) {
|
182
|
+
if (Array.isArray(p)) {
|
183
|
+
return p.some(isVitePWAPlugin);
|
184
|
+
}
|
185
|
+
return p.name === "vite-plugin-pwa";
|
186
|
+
}
|
187
|
+
if (!foundVitePWAPlugin) {
|
188
|
+
// const { default: VitePWA } = await import('vite-plugin-pwa');
|
189
|
+
// log("Add vite-plugin-pwa to the vite config");
|
190
|
+
// config.plugins.push(VitePWA());
|
191
|
+
throw new Error("ERR: [needle-pwa] vite-plugin-pwa is not present in the vite config. Please install vite-plugin-pwa and add it to the vite config like so: \"VitePWA(PWAOptions)\". \n\nIf you don't intent to build a PWA then don't pass the PWA options to the needlePlugin or set `noPWA: true` in the user settings (third argument).");
|
192
|
+
}
|
193
|
+
}
|
194
|
+
|
195
|
+
function cleanup(context) {
|
196
|
+
for (const file of context.generatedFiles) {
|
197
|
+
log("Cleanup generated file", file);
|
198
|
+
rmSync(file);
|
199
|
+
}
|
200
|
+
context.generatedFiles.length = 0;
|
201
|
+
}
|
202
|
+
|
203
|
+
/**
|
204
|
+
* @param {Array<{path:string, copypath:string}>} manifests
|
205
|
+
*/
|
206
|
+
function restoreOriginalManifests(manifests) {
|
207
|
+
// restore the manifest
|
208
|
+
for (const manifest of manifests) {
|
209
|
+
try {
|
210
|
+
log("Restore original webmanifest", manifest.path);
|
211
|
+
copyFileSync(manifest.copyPath, manifest.path);
|
212
|
+
rmSync(manifest.copyPath);
|
213
|
+
}
|
214
|
+
catch (e) {
|
215
|
+
log("Error restoring original webmanifest", manifest, e);
|
216
|
+
}
|
217
|
+
}
|
218
|
+
manifests.length = 0;
|
219
|
+
}
|
220
|
+
|
221
|
+
|
222
|
+
function log(...args) {
|
223
|
+
console.log("[needle-pwa]", ...args);
|
224
|
+
}
|
225
|
+
function delay(ms) {
|
226
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
227
|
+
}
|
228
|
+
|
229
|
+
/**
|
230
|
+
* @param {string} webmanifestPath
|
231
|
+
* @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
|
232
|
+
* @param {import("../types/index.d.ts").NeedlePWAOptions?} options
|
233
|
+
*/
|
234
|
+
async function processPWA(webmanifestPath, context, options, iteration = 0) {
|
235
|
+
const outDir = getOutputDirectory();
|
236
|
+
if (!existsSync(webmanifestPath)) {
|
237
|
+
if (iteration > 2)
|
238
|
+
return log("No webmanifest found at", webmanifestPath);
|
239
|
+
// try a few times...
|
240
|
+
await delay(100);
|
241
|
+
return processPWA(webmanifestPath, context, options, iteration + 2);
|
242
|
+
}
|
243
|
+
|
244
|
+
/** @type {import("../types/webmanifest.d.ts").Webmanifest}} */
|
245
|
+
const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
|
246
|
+
let modifiedManifest = false;
|
247
|
+
|
248
|
+
if (processIcons(manifest, outDir, context) === true) modifiedManifest = true;
|
249
|
+
if (processWorkboxConfig(manifest) === true) modifiedManifest = true;
|
250
|
+
|
251
|
+
const packageJsonPath = process.cwd() + "/package.json";
|
252
|
+
const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
|
253
|
+
|
254
|
+
if (manifest.id === undefined) {
|
255
|
+
let name = packageJson.name || manifest.short_name || manifest.name;
|
256
|
+
if (name) {
|
257
|
+
manifest.id = "com." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", ".");
|
258
|
+
modifiedManifest = true;
|
259
|
+
}
|
260
|
+
}
|
261
|
+
if (manifest.description === undefined) {
|
262
|
+
manifest.description = packageJson.description || "Made with Needle Engine";
|
263
|
+
if (manifest.description?.length)
|
264
|
+
modifiedManifest = true;
|
265
|
+
}
|
266
|
+
|
267
|
+
if (modifiedManifest) {
|
268
|
+
log("Write modified webmanifest to " + webmanifestPath);
|
269
|
+
const newManifest = JSON.stringify(manifest);
|
270
|
+
writeFileSync(webmanifestPath, newManifest, 'utf8');
|
271
|
+
}
|
272
|
+
}
|
273
|
+
|
274
|
+
/** Copies icons to the output directory
|
275
|
+
* If a start_url is provided, it will be used as the base path for relative icon paths
|
276
|
+
* @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
|
277
|
+
* @param {string} outDir
|
278
|
+
* @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
|
279
|
+
* @returns {boolean} */
|
280
|
+
function processIcons(manifest, outDir, context) {
|
281
|
+
let modified = false;
|
282
|
+
|
283
|
+
if (!manifest.icons?.length) {
|
284
|
+
// generate icons
|
285
|
+
generateIcons(manifest, context);
|
286
|
+
}
|
287
|
+
|
288
|
+
for (let i = 0; i < manifest.icons?.length; i++) {
|
289
|
+
try {
|
290
|
+
const icon = manifest.icons[i];
|
291
|
+
let src = icon.src;
|
292
|
+
const iconSrcIsAbsolute = src.startsWith("http://") || src.startsWith("https://");
|
293
|
+
const hasAbsoluteStartUrl = manifest.start_url?.startsWith("http://") || manifest.start_url?.startsWith("https://");
|
294
|
+
if (!iconSrcIsAbsolute && hasAbsoluteStartUrl) {
|
295
|
+
log("Making icon src relative to start_url", src);
|
296
|
+
icon.src = manifest.start_url + src;
|
297
|
+
modified = true;
|
298
|
+
}
|
299
|
+
}
|
300
|
+
catch (e) {
|
301
|
+
log("Error processing PWA icon[" + i + "]", e);
|
302
|
+
}
|
303
|
+
}
|
304
|
+
return modified;
|
305
|
+
}
|
306
|
+
|
307
|
+
/**
|
308
|
+
* @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
|
309
|
+
* @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
|
310
|
+
*/
|
311
|
+
function generateIcons(manifest, context) {
|
312
|
+
if (!manifest.icons) manifest.icons = [];
|
313
|
+
log("Generating PWA icons");
|
314
|
+
const sizes = [192, 512];
|
315
|
+
const defaultIconSVG = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 160 187.74"><defs><linearGradient id="a" x1="89.64" y1="184.81" x2="90.48" y2="21.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62d399"/><stop offset=".51" stop-color="#acd842"/><stop offset=".9" stop-color="#d7db0a"/></linearGradient><linearGradient id="b" x1="69.68" y1="178.9" x2="68.08" y2="16.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ba398"/><stop offset=".5" stop-color="#4ca352"/><stop offset="1" stop-color="#76a30a"/></linearGradient><linearGradient id="c" x1="36.6" y1="152.17" x2="34.7" y2="84.19" gradientUnits="userSpaceOnUse"><stop offset=".19" stop-color="#36a382"/><stop offset=".54" stop-color="#49a459"/><stop offset="1" stop-color="#76a30b"/></linearGradient><linearGradient id="d" x1="15.82" y1="153.24" x2="18" y2="90.86" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#267880"/><stop offset=".51" stop-color="#457a5c"/><stop offset="1" stop-color="#717516"/></linearGradient><linearGradient id="e" x1="135.08" y1="135.43" x2="148.93" y2="63.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0d939"/><stop offset="1" stop-color="#eadb04"/></linearGradient><linearGradient id="f" x1="-4163.25" y1="2285.12" x2="-4160.81" y2="2215.34" gradientTransform="rotate(20 4088.49 13316.712)" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#74af52"/><stop offset=".48" stop-color="#99be32"/><stop offset="1" stop-color="#c0c40a"/></linearGradient><symbol id="g" viewBox="0 0 160 187.74"><path style="fill:url(#a)" d="M79.32 36.98v150.76L95 174.54l6.59-156.31-22.27 18.75z"/><path style="fill:url(#b)" d="M79.32 36.98 57.05 18.23l6.59 156.31 15.68 13.2V36.98z"/><path style="fill:url(#c)" d="m25.19 104.83 8.63 49.04 12.5-14.95-2.46-56.42-18.67 22.33z"/><path style="fill:url(#d)" d="M25.19 104.83 0 90.24l16.97 53.86 16.85 9.77-8.63-49.04z"/><path style="fill:#9c3" d="M43.86 82.5 18.69 67.98 0 90.24l25.18 14.59L43.86 82.5z"/><path style="fill:url(#e)" d="m134.82 78.69-9.97 56.5 15.58-9.04L160 64.1l-25.18 14.59z"/><path style="fill:url(#f)" d="m134.82 78.69-18.68-22.33-2.86 65 11.57 13.83 9.97-56.5z"/><path style="fill:#ffe113" d="m160 64.1-18.69-22.26-25.17 14.52 18.67 22.33L160 64.1z"/><path style="fill:#f3e600" d="M101.59 18.23 79.32 0 57.05 18.23l22.27 18.75 22.27-18.75z"/></symbol></defs><use width="160" height="187.74" xlink:href="#g"/></svg>`;
|
316
|
+
const iconDir = process.cwd();
|
317
|
+
for (let size of sizes) {
|
318
|
+
const iconName = "pwa-icon-" + size + ".svg";
|
319
|
+
const iconPath = iconDir + "/" + iconName;
|
320
|
+
writeFileSync(iconPath, defaultIconSVG, 'utf8');
|
321
|
+
context.generatedFiles.push(iconPath);
|
322
|
+
const iconSrc = "./" + iconName;
|
323
|
+
log("Generated PWA icon", iconSrc);
|
324
|
+
const iconInfo = {
|
325
|
+
src: iconSrc,
|
326
|
+
type: "image/svg+xml",
|
327
|
+
sizes: size + "x" + size,
|
328
|
+
"purpose": "any"
|
329
|
+
};
|
330
|
+
manifest.icons.push(iconInfo);
|
331
|
+
manifest.icons.push({
|
332
|
+
...iconInfo,
|
333
|
+
purpose: "maskable"
|
334
|
+
})
|
335
|
+
}
|
336
|
+
}
|
337
|
+
|
338
|
+
/** Tries to copy the icons to the output directory
|
339
|
+
*/
|
340
|
+
function copyIcons(manifest, outDir) {
|
341
|
+
for (let i = 0; i < manifest.icons?.length; i++) {
|
342
|
+
try {
|
343
|
+
const icon = manifest.icons[i];
|
344
|
+
let src = icon.src;
|
345
|
+
// if the icon starts with the start url we remove it (to get the output folder relative URL for copying)
|
346
|
+
if (src.startsWith(manifest.start_url)) {
|
347
|
+
src = src.substring(manifest.start_url.length);
|
348
|
+
}
|
349
|
+
// check again if the src is absolute
|
350
|
+
const srcIsAbsolute = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//");
|
351
|
+
if (srcIsAbsolute) {
|
352
|
+
log("PWA icon src is absolute, not supported", icon.src);
|
353
|
+
continue;
|
354
|
+
}
|
355
|
+
// src is relative -> we copy the file to the output directory
|
356
|
+
const srcPath = process.cwd() + "/" + src;
|
357
|
+
if (existsSync(srcPath)) {
|
358
|
+
const targetPath = outDir + "/" + src;
|
359
|
+
// if the icon already exists in the output directory we skip it
|
360
|
+
if (existsSync(targetPath)) {
|
361
|
+
continue;
|
362
|
+
}
|
363
|
+
const outputDirectory = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
364
|
+
if (!existsSync(outputDirectory)) {
|
365
|
+
mkdirSync(outputDirectory, { recursive: true });
|
366
|
+
}
|
367
|
+
log("Copy PWA icon " + src + " to output");
|
368
|
+
copyFileSync(srcPath, targetPath);
|
369
|
+
}
|
370
|
+
}
|
371
|
+
catch (e) {
|
372
|
+
log("Error processing PWA icon[" + i + "]", e);
|
373
|
+
}
|
374
|
+
}
|
375
|
+
}
|
376
|
+
|
377
|
+
|
378
|
+
|
379
|
+
/**
|
380
|
+
* Merges the current workbox config with the default workbox config
|
381
|
+
* @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
|
382
|
+
*/
|
383
|
+
function processWorkboxConfig(manifest) {
|
384
|
+
|
385
|
+
// Workaround: urlPattern, ignoreSearch und ignoreURLParametersMatching, dontCacheBustURLsMatching are because we currently append ?v=... to loaded GLB files
|
386
|
+
|
387
|
+
// this is our default config
|
388
|
+
const defaultWorkboxConfig = {
|
389
|
+
globPatterns: ['**'],
|
390
|
+
// glb files are large – we need to increase the cache size here.
|
391
|
+
// In practice we want to precache everything for offline usage,
|
392
|
+
// but we still want to get a warning beyond 50MB.
|
393
|
+
maximumFileSizeToCacheInBytes: 50000000,
|
394
|
+
dontCacheBustURLsMatching: /\.[a-f0-9]{8}\./,
|
395
|
+
ignoreURLParametersMatching: [/.*/],
|
396
|
+
runtimeCaching: [
|
397
|
+
{
|
398
|
+
urlPattern: ({ url }) => url,
|
399
|
+
// Apply a network-first strategy.
|
400
|
+
handler: 'NetworkFirst',
|
401
|
+
options: {
|
402
|
+
matchOptions: {
|
403
|
+
ignoreSearch: true,
|
404
|
+
},
|
405
|
+
}
|
406
|
+
},
|
407
|
+
],
|
408
|
+
}
|
409
|
+
|
410
|
+
if (manifest.workboxConfig) {
|
411
|
+
manifest.workboxConfig = { ...defaultWorkboxConfig, ...manifest.workboxConfig };
|
412
|
+
return true;
|
413
|
+
}
|
414
|
+
else {
|
415
|
+
manifest.workboxConfig = defaultWorkboxConfig;
|
416
|
+
return true;
|
417
|
+
}
|
418
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
|
4
|
+
declare type Icon = {
|
5
|
+
src: string;
|
6
|
+
sizes: string;
|
7
|
+
type: string;
|
8
|
+
purpose: string;
|
9
|
+
}
|
10
|
+
|
11
|
+
export declare type Webmanifest = {
|
12
|
+
start_url: string;
|
13
|
+
name?: string;
|
14
|
+
short_name?: string;
|
15
|
+
id?: string;
|
16
|
+
icons?: Array<Icon>;
|
17
|
+
workbox?: object;
|
18
|
+
}
|
19
|
+
|
20
|
+
export declare type NeedlePWAOptions = {
|
21
|
+
|
22
|
+
}
|
23
|
+
|
24
|
+
export declare type NeedlePWAProcessContext = {
|
25
|
+
generatedFiles: Array<string>;
|
26
|
+
}
|