@haiyue/native 0.1.0

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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bridge/audio/pcm-bank.android.ts +81 -0
  4. package/bridge/audio/pcm-bank.ios.ts +80 -0
  5. package/bridge/audio/pcm-bank.ts +2 -0
  6. package/bridge/branding/assets/haiyue-moon.png +0 -0
  7. package/bridge/branding/engine-splash.ts +122 -0
  8. package/bridge/branding/launch-page.ts +28 -0
  9. package/bridge/branding/webpack.cjs +11 -0
  10. package/bridge/display/orientation-policy.ts +18 -0
  11. package/bridge/display/orientation.android.ts +31 -0
  12. package/bridge/display/orientation.ios.ts +57 -0
  13. package/bridge/display/orientation.ts +2 -0
  14. package/bridge/feedback/haptics.android.ts +31 -0
  15. package/bridge/feedback/haptics.ios.ts +35 -0
  16. package/bridge/feedback/haptics.ts +2 -0
  17. package/bridge/files/read-bytes.ts +18 -0
  18. package/bridge/input/native-touch.android.ts +55 -0
  19. package/bridge/input/native-touch.ios.ts +91 -0
  20. package/bridge/input/native-touch.ts +2 -0
  21. package/bridge/input/pointer-target.ts +106 -0
  22. package/bridge/input/touch-identity.ts +20 -0
  23. package/bridge/lifecycle/demand-frames.ts +51 -0
  24. package/bridge/lifecycle/frame-performance.ts +20 -0
  25. package/bridge/lifecycle/frame-scheduler.ts +33 -0
  26. package/bridge/lifecycle/host.ts +330 -0
  27. package/bridge/lifecycle/launch-flags.ts +6 -0
  28. package/bridge/lifecycle/presentation-pause.ts +18 -0
  29. package/bridge/lifecycle/runtime.ts +31 -0
  30. package/bridge/media/save-photo.android.ts +49 -0
  31. package/bridge/media/save-photo.ios.ts +19 -0
  32. package/bridge/media/save-photo.ts +2 -0
  33. package/bridge/motion/android-reading.ts +19 -0
  34. package/bridge/motion/device-motion.android.ts +109 -0
  35. package/bridge/motion/device-motion.ios.ts +121 -0
  36. package/bridge/motion/device-motion.ts +2 -0
  37. package/bridge/motion/motion-sample.ts +62 -0
  38. package/bridge/render/canvas-textures.ios.ts +45 -0
  39. package/bridge/render/canvas-textures.ts +2 -0
  40. package/bridge/render/device-descriptor.ts +8 -0
  41. package/bridge/render/frame-capture.android.ts +12 -0
  42. package/bridge/render/frame-capture.ios.ts +20 -0
  43. package/bridge/render/frame-capture.ts +2 -0
  44. package/bridge/render/queue-fence.android.ts +39 -0
  45. package/bridge/render/queue-fence.ts +1 -0
  46. package/bridge/render/surface.ts +153 -0
  47. package/bridge/render/view-capture.android.ts +11 -0
  48. package/bridge/render/view-capture.ios.ts +17 -0
  49. package/bridge/render/view-capture.ts +1 -0
  50. package/bridge/render/view-rect.android.ts +15 -0
  51. package/bridge/render/view-rect.ios.ts +12 -0
  52. package/bridge/render/view-rect.ts +2 -0
  53. package/bridge/render/webgpu-constants.ts +5 -0
  54. package/bridge/rewards/admob.ts +80 -0
  55. package/bridge/rewards/controller.ts +163 -0
  56. package/bridge/rewards/native/android/org/haiyue/rewards/HYRewardedAds.java +116 -0
  57. package/bridge/rewards/native/ios/HYRewardedAds.swift +169 -0
  58. package/bridge/storage/clone-runtime.ts +6 -0
  59. package/bridge/storage/settings-storage.ts +12 -0
  60. package/index.ts +17 -0
  61. package/package.json +87 -0
  62. package/provenance.json +66 -0
@@ -0,0 +1,330 @@
1
+ import { PresentationPause } from './presentation-pause';
2
+ import { Application, File, knownFolders, path, isAndroid } from '@nativescript/core';
3
+ import type { Canvas } from '@nativescript/canvas';
4
+ import { HaiyueEngine } from '@haiyue/engine';
5
+ import { getEngineDiagnosticsSnapshot } from '@haiyue/engine/diagnostics';
6
+ import { FramePerformance } from './frame-performance';
7
+ import { NativeDemandFrames } from './demand-frames';
8
+ import { NativeSurface, NativeSurfaceUnavailableError, type NativeCanvasInput } from '../render/surface';
9
+ import { captureSurfaceFrame, isFrameCaptureRequested } from '../render/frame-capture';
10
+ import { nativeFrames, installNativeFrameRuntime } from './runtime';
11
+
12
+ export interface NativeHostInput {
13
+ suspend(): void;
14
+ resume(): void;
15
+ dispose(): void;
16
+ snapshot(): unknown;
17
+ }
18
+
19
+ export interface NativeRenderHostOptions {
20
+ /** Opt-in demand rendering. Call requestFrame() for input/async changes. */
21
+ needsAnimationFrame?: () => boolean;
22
+ performance?: boolean;
23
+ /** Zero disables periodic snapshots/writes; lifecycle and errors remain logged. */
24
+ diagnosticIntervalFrames?: number;
25
+ canvasInput?: NativeCanvasInput;
26
+ engineOptions?: Pick<ConstructorParameters<typeof HaiyueEngine>[0], 'clearColor' | 'renderProfile' | 'msaaSamples' | 'reverseZ' | 'diagnostics'>;
27
+ bindInput?: (engine: HaiyueEngine, report: (event: string, detail: unknown) => void) => NativeHostInput;
28
+ prepareScene?: (engine: HaiyueEngine) => unknown | Promise<unknown>;
29
+ disposeScene?: () => void;
30
+ diagnosticName?: string;
31
+ capture?: { requested: boolean; file: string };
32
+ }
33
+
34
+ export class NativeRenderHost {
35
+ private engine: HaiyueEngine | null = null;
36
+ private surface: NativeSurface;
37
+ private initializing = false;
38
+ private disposed = false;
39
+ private suspended = false;
40
+ private readonly presentationPause = new PresentationPause(() => this.suspend(), () => this.resume());
41
+ private readonly appSuspend = () => this.presentationPause.setBackground(true);
42
+ private readonly appResume = () => this.presentationPause.setBackground(false);
43
+ /** Stops frames, audio and input until BOTH the presentation and background suspension end. */
44
+ pausePresentation(): () => void { return this.disposed ? () => {} : this.presentationPause.acquire(); }
45
+ /** Present the disabled/loading UI once before stopping the demand renderer. */
46
+ preparePresentation(): Promise<() => void> {
47
+ const engine = this.engine;
48
+ if (!engine || engine.state !== 'ready' || this.suspended || this.disposed) return Promise.resolve(this.pausePresentation());
49
+ return new Promise(resolve => {
50
+ let complete = false;
51
+ const finish = () => {
52
+ if (complete) return;
53
+ complete = true; clearTimeout(timeout); engine.off('after-update', finish);
54
+ resolve(this.pausePresentation());
55
+ };
56
+ const timeout = setTimeout(finish, 150);
57
+ engine.on('after-update', finish); this.requestFrame();
58
+ });
59
+ }
60
+ private surfaceRetry: ReturnType<typeof setTimeout> | null = null;
61
+ private surfaceMisses = 0;
62
+ private surfaceInputPaused = false;
63
+ private surfacePause: (() => void) | null = null;
64
+ private layoutPause: (() => void) | null = null;
65
+ private readonly surfaceDestroyed = (): void => {
66
+ if (!this.disposed && !this.failed && !this.surfacePause) this.surfacePause = this.presentationPause.acquire();
67
+ };
68
+ private readonly surfaceCreated = (): void => {
69
+ const release = this.surfacePause;
70
+ this.surfacePause = null;
71
+ release?.();
72
+ };
73
+ private failed = false;
74
+ private generation = 0;
75
+ private input: NativeHostInput | null = null;
76
+ private sceneDisposed = false;
77
+ private resumeCount = 0;
78
+ private observedDevice: GPUDevice | null = null;
79
+ private captureRequested: boolean;
80
+ private readonly journal: string[] = [];
81
+ private readonly logFile: File;
82
+ private readonly performance = new FramePerformance();
83
+ private readonly demand: NativeDemandFrames | null;
84
+
85
+ constructor(private readonly view: Canvas, private readonly status: (text: string) => void, private readonly options: NativeRenderHostOptions = {}) {
86
+ this.captureRequested = options.capture?.requested ?? isFrameCaptureRequested();
87
+ this.demand = options.needsAnimationFrame ? new NativeDemandFrames({
88
+ start: () => { try { this.engine?.run(); } catch (error) { this.fail(error); } },
89
+ stop: () => this.engine?.stop(),
90
+ defer: callback => { void Promise.resolve().then(callback); },
91
+ }) : null;
92
+ this.logFile = File.fromPath(path.join(knownFolders.documents().path, `${options.diagnosticName ?? 'g02'}-host.jsonl`));
93
+ this.surface = new NativeSurface(view, this.report, options.canvasInput);
94
+ installNativeFrameRuntime(error => this.fail(error));
95
+ Application.on(Application.suspendEvent, this.appSuspend);
96
+ Application.on(Application.resumeEvent, this.appResume);
97
+ view.on('layoutChanged', this.layout);
98
+ view.on('surfaceDestroyed', this.surfaceDestroyed);
99
+ view.on('surfaceCreated', this.surfaceCreated);
100
+ this.report('host-created', { engine: '0.1.0', canvas: '2.1.18', runtime: '9.0.3', backendRoute: isAndroid ? 'Canvas/wgpu/Vulkan' : 'Canvas/wgpu/Metal', dpr: this.surface.pixelRatio, captureRequested: this.captureRequested });
101
+ this.layout();
102
+ }
103
+
104
+ private readonly report = (event: string, detail: unknown): void => {
105
+ const record = JSON.stringify({ time: Date.now(), event, detail });
106
+ console.log(`[${this.options.diagnosticName ?? 'g02'}] ${record}`);
107
+ this.journal.push(record);
108
+ if (this.journal.length > 200) this.journal.splice(1, 1);
109
+ try { this.logFile.writeTextSync(this.journal.join('\n') + '\n'); }
110
+ catch (error) { console.error('[G02] Failed to persist diagnostic journal', error); }
111
+ };
112
+
113
+ private readonly layout = (): void => {
114
+ if (this.disposed || this.failed) return;
115
+ if (!this.surface.hasLayout) {
116
+ if (this.engine && !this.layoutPause) this.layoutPause = this.presentationPause.acquire();
117
+ return;
118
+ }
119
+ if (this.layoutPause) {
120
+ const release = this.layoutPause; this.layoutPause = null; release(); return;
121
+ }
122
+ if (this.suspended || this.surfaceRetry !== null) return;
123
+ if (!this.engine) { void this.initialize(); return; }
124
+ if (this.engine.state === 'ready') {
125
+ try { this.engine.resizeToDisplaySize(); this.requestFrame(); } catch (error) { this.fail(error); }
126
+ }
127
+ };
128
+
129
+ private async initialize(): Promise<void> {
130
+ if (this.initializing || this.disposed || this.failed) return;
131
+ this.initializing = true;
132
+ const generation = ++this.generation;
133
+ this.status('正在初始化原生 WebGPU…');
134
+ try {
135
+ const engine = new HaiyueEngine({ ...this.surface.engineOptions(), renderProfile: 'simple', msaaSamples: 1, timestampQuery: false, diagnostics:{enabled:this.options.performance===true}, recoverDeviceLost: false, clearColor: { r: 0.025, g: 0.055, b: 0.095, a: 1 }, ...this.options.engineOptions });
136
+ this.engine = engine;
137
+ await engine.init();
138
+ if (this.disposed || this.failed || generation !== this.generation) { engine.destroy(); return; }
139
+ this.observedDevice = engine.device;
140
+ this.observedDevice.addEventListener('uncapturederror', this.onGpuError);
141
+ engine.on('update',this.beforeFrame);
142
+ engine.on('device-lost', event => this.fail(new Error(`WebGPU device lost: ${event.detail?.message}`)));
143
+ if (this.options.prepareScene) this.report('scene-ready', await this.options.prepareScene(engine));
144
+ else engine.switchScene(engine.createScene({ render3D: true, view: { clearColor: engine.clearColor } }));
145
+ if (this.disposed || this.failed || generation !== this.generation) { engine.destroy(); return; }
146
+ this.input = this.options.bindInput?.(engine, this.report) ?? null;
147
+ if (this.suspended) this.input?.suspend();
148
+ this.report('input-ready', this.input?.snapshot() ?? null);
149
+ engine.on('after-update', this.afterFrame);
150
+ this.report('engine-ready', { width: engine.width, height: engine.height, format: engine.format, profile: engine.renderProfile, clearColor: engine.clearColor });
151
+ if (!this.suspended) { if (this.demand) this.demand.resume(); else engine.run(); }
152
+ } catch (error) { if (!this.disposed) this.fail(error); }
153
+ finally {
154
+ this.initializing = false;
155
+ if (this.failed) void this.releaseFailedEngine();
156
+ }
157
+ }
158
+
159
+ private readonly afterFrame = (): void => {
160
+ if (this.failed || this.disposed || this.suspended) return;
161
+ if (this.captureRequested && this.surface.presentedFrames === 119) {
162
+ this.captureRequested = false;
163
+ try { this.report('frame-capture', { ...captureSurfaceFrame(this.view, this.options.capture?.file), frame: 120 }); }
164
+ catch (error) { this.report('capture-error', { message: String(error) }); }
165
+ }
166
+ if (!this.surface.present()) { this.finishDemandFrame(); return; }
167
+ if (this.surfaceMisses) {
168
+ this.report('surface-recovered', { attempts: this.surfaceMisses, frames: this.surface.presentedFrames });
169
+ this.surfaceMisses = 0;
170
+ }
171
+ if(this.options.performance)this.performance.end(performance.now());
172
+ const frames = this.surface.presentedFrames;
173
+ const interval = this.options.diagnosticIntervalFrames ?? 120;
174
+ if (frames === 1 || (interval > 0 && frames % interval === 0)) {
175
+ this.status(`原生 WebGPU 已呈现 ${frames} 帧`);
176
+ this.report('present', { frames, width: this.engine?.width, height: this.engine?.height, scheduledCallbacks: nativeFrames.pendingCount, input: this.input?.snapshot() ?? null });
177
+ if(this.options.performance && this.engine)this.report('performance',{frames,...this.performance.take(),thermalState:isAndroid ? null : NSProcessInfo.processInfo.thermalState,engine:getEngineDiagnosticsSnapshot(this.engine)});
178
+ }
179
+ this.finishDemandFrame();
180
+ };
181
+
182
+ requestFrame(): void { this.demand?.request(); }
183
+ renderingSnapshot() {
184
+ return { frames: this.surface.presentedFrames, scheduledCallbacks: nativeFrames.pendingCount, demand: this.demand?.snapshot() ?? null };
185
+ }
186
+ private finishDemandFrame(): void {
187
+ this.demand?.afterFrame(this.captureRequested || this.options.needsAnimationFrame?.() === true);
188
+ }
189
+
190
+ private readonly beforeFrame = ():void => {
191
+ if (this.options.performance) this.performance.begin(performance.now());
192
+ this.surface.beginFrame();
193
+ if (this.surfaceInputPaused) { this.surfaceInputPaused = false; this.input?.resume(); }
194
+ };
195
+
196
+ private cancelSurfaceRetry(): void {
197
+ if (this.surfaceRetry !== null) clearTimeout(this.surfaceRetry);
198
+ this.surfaceRetry = null;
199
+ }
200
+
201
+ private retrySurface(): void {
202
+ if (this.suspended || this.surfaceRetry !== null) return;
203
+ if (++this.surfaceMisses > 8) { this.fail(new Error('Native surface remained unavailable after 8 retries.')); return; }
204
+ this.demand?.suspend();
205
+ this.engine?.stop();
206
+ nativeFrames.cancelAll();
207
+ if (!this.surfaceInputPaused) { this.surfaceInputPaused = true; this.input?.suspend(); }
208
+ this.performance.reset();
209
+ this.report('surface-wait', { attempt: this.surfaceMisses });
210
+ // Never restart inside the failing RAF. Backoff avoids hot-looping a lost surface.
211
+ this.surfaceRetry = setTimeout(() => {
212
+ this.surfaceRetry = null;
213
+ if (this.disposed || this.failed || this.suspended || this.engine?.state !== 'ready') return;
214
+ try {
215
+ if (!this.surface.hasLayout) { this.layout(); return; }
216
+ this.engine.resizeToDisplaySize();
217
+ this.surface.reconfigure();
218
+ if (this.demand) this.demand.resume(); else this.engine.run();
219
+ } catch (error) { this.fail(error); }
220
+ }, Math.min(50 * 2 ** (this.surfaceMisses - 1), 400));
221
+ }
222
+
223
+ private readonly suspend = (): void => {
224
+ this.cancelSurfaceRetry();
225
+ this.surfaceMisses = 0;
226
+ this.performance.reset();
227
+ this.suspended = true;
228
+ this.demand?.suspend();
229
+ this.input?.suspend();
230
+ this.engine?.stop();
231
+ nativeFrames.cancelAll();
232
+ this.report('suspend', { frames: this.surface.presentedFrames, scheduledCallbacks: nativeFrames.pendingCount, input: this.input?.snapshot() ?? null });
233
+ };
234
+
235
+ private readonly resume = (): void => {
236
+ if (this.disposed || this.failed || !this.suspended) return;
237
+ this.suspended = false;
238
+ try {
239
+ this.layout();
240
+ if (this.suspended) return;
241
+ if (this.engine?.state === 'ready' && !this.initializing) {
242
+ this.engine.resizeToDisplaySize(true);
243
+ this.surface.reconfigure();
244
+ this.input?.resume();
245
+ this.surfaceInputPaused = false;
246
+ if (this.demand) this.demand.resume(); else this.engine.run();
247
+ }
248
+ } catch (error) { this.fail(error); return; }
249
+ this.report('resume', { count: ++this.resumeCount, frames: this.surface.presentedFrames, scheduledCallbacks: nativeFrames.pendingCount, input: this.input?.snapshot() ?? null });
250
+ };
251
+
252
+ fail(error: unknown): void {
253
+ if (this.disposed || this.failed) return;
254
+ if (error instanceof NativeSurfaceUnavailableError && this.engine?.state === 'ready' && !this.initializing) {
255
+ this.retrySurface(); return;
256
+ }
257
+ this.failed = true;
258
+ this.cancelSurfaceRetry();
259
+ this.demand?.suspend();
260
+ this.input?.suspend();
261
+ this.engine?.stop();
262
+ nativeFrames.cancelAll();
263
+ const message = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error);
264
+ this.status(`初始化或渲染失败\n${message}`);
265
+ this.report('error', { message });
266
+ if (!this.initializing) void this.releaseFailedEngine();
267
+ }
268
+
269
+ private readonly onGpuError = (event: GPUUncapturedErrorEvent): void => this.fail(event.error);
270
+
271
+ private removeDeviceListener(): void {
272
+ this.engine?.off('update',this.beforeFrame);
273
+ this.observedDevice?.removeEventListener('uncapturederror', this.onGpuError);
274
+ this.observedDevice = null;
275
+ }
276
+
277
+ private async releaseFailedEngine(): Promise<void> {
278
+ const engine = this.engine;
279
+ if (!engine) return;
280
+ // Yield past the current render callback and any Engine device-loss cleanup.
281
+ try { await engine.waitForRecovery(); }
282
+ catch (error) { this.report('recovery-cleanup-error', { message: String(error) }); }
283
+ try {
284
+ this.disposeInput();
285
+ this.disposeScene();
286
+ this.removeDeviceListener();
287
+ engine.destroy();
288
+ if (this.engine === engine) this.engine = null;
289
+ this.report('failure-cleanup', { state: engine.state, scheduledCallbacks: nativeFrames.pendingCount });
290
+ } catch (error) {
291
+ this.report('cleanup-error', { message: String(error) });
292
+ } finally {
293
+ try { this.surface.release(); }
294
+ catch (error) { this.report('surface-cleanup-error', { message: String(error) }); }
295
+ }
296
+ }
297
+
298
+ private disposeInput(): void {
299
+ if (!this.input) return;
300
+ this.input.dispose();
301
+ this.report('input-disposed', this.input.snapshot());
302
+ this.input = null;
303
+ }
304
+
305
+ private disposeScene(): void {
306
+ if (this.sceneDisposed) return;
307
+ this.sceneDisposed = true;
308
+ this.options.disposeScene?.();
309
+ }
310
+
311
+ dispose(): void {
312
+ if (this.disposed) return;
313
+ this.disposed = true;
314
+ this.cancelSurfaceRetry();
315
+ this.demand?.dispose();
316
+ ++this.generation;
317
+ this.disposeInput();
318
+ this.disposeScene();
319
+ Application.off(Application.suspendEvent, this.appSuspend);
320
+ Application.off(Application.resumeEvent, this.appResume);
321
+ this.view.off('layoutChanged', this.layout);
322
+ this.view.off('surfaceDestroyed', this.surfaceDestroyed);
323
+ this.view.off('surfaceCreated', this.surfaceCreated);
324
+ nativeFrames.cancelAll();
325
+ this.removeDeviceListener();
326
+ this.engine?.destroy();
327
+ this.engine = null;
328
+ this.report('disposed', { frames: this.surface.presentedFrames, scheduledCallbacks: nativeFrames.pendingCount, input: null });
329
+ }
330
+ }
@@ -0,0 +1,6 @@
1
+ import { Application, isAndroid } from '@nativescript/core';
2
+ /** Explicit development launch flags; no persistent change to production saves. */
3
+ export function nativeLaunchFlag(name: string): boolean {
4
+ if (isAndroid) return (Application.android.foregroundActivity ?? Application.android.startActivity)?.getIntent()?.getBooleanExtra(name, false) ?? false;
5
+ return String(NSProcessInfo.processInfo.environment.objectForKey(name)) === '1';
6
+ }
@@ -0,0 +1,18 @@
1
+ /** Reconciles OS backgrounding with overlapping native full-screen presentations. */
2
+ export class PresentationPause {
3
+ private background = false;
4
+ private tokens = new Set<object>();
5
+ private paused = false;
6
+ constructor(private readonly suspend: () => void, private readonly resume: () => void) {}
7
+ setBackground(value: boolean): void { this.background = value; this.sync(); }
8
+ acquire(): () => void {
9
+ const token = {}; this.tokens.add(token); this.sync();
10
+ return () => { this.tokens.delete(token); this.sync(); };
11
+ }
12
+ private sync(): void {
13
+ const paused = this.background || this.tokens.size > 0;
14
+ if (paused === this.paused) return;
15
+ this.paused = paused;
16
+ if (paused) this.suspend(); else this.resume();
17
+ }
18
+ }
@@ -0,0 +1,31 @@
1
+ import { requestAnimationFrame, cancelAnimationFrame } from '@nativescript/core/animation-frame';
2
+ import { time } from '@nativescript/core/profiling';
3
+ import { AbortController, AbortSignal } from '@nativescript/core/abortcontroller';
4
+ import { createFrameScheduler } from './frame-scheduler';
5
+
6
+ let reportFrameError: (error: unknown) => void = error => console.error(error);
7
+ const clock = typeof globalThis.performance?.now === 'function'
8
+ ? globalThis.performance.now.bind(globalThis.performance) : time;
9
+ export const nativeFrames = createFrameScheduler({
10
+ request: requestAnimationFrame,
11
+ cancel: cancelAnimationFrame,
12
+ now: clock,
13
+ }, error => reportFrameError(error));
14
+
15
+ /** Only the scheduling globals actually used by Engine; no synthetic DOM. */
16
+ export function installNativeFrameRuntime(onError: (error: unknown) => void): void {
17
+ reportFrameError = onError;
18
+ // Core 9.1 leaves this to the runtime; the Canvas-compatible iOS 9.0.3
19
+ // runtime needs Core's existing implementation for Scene asset ownership.
20
+ if (typeof globalThis.AbortController === 'undefined') {
21
+ Object.defineProperty(globalThis, 'AbortController', { configurable: true, value: AbortController });
22
+ Object.defineProperty(globalThis, 'AbortSignal', { configurable: true, value: AbortSignal });
23
+ }
24
+ if (typeof globalThis.performance?.now !== 'function') {
25
+ Object.defineProperty(globalThis, 'performance', { configurable: true, value: { now: clock } });
26
+ }
27
+ // Core's CommonJS globals are configurable lazy getters without setters.
28
+ // Define our scheduler explicitly instead of assigning to those getters.
29
+ Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true, writable: true, value: (callback: FrameRequestCallback) => nativeFrames.request(callback) });
30
+ Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true, writable: true, value: (id: number) => nativeFrames.cancel(id) });
31
+ }
@@ -0,0 +1,49 @@
1
+ import { Application } from '@nativescript/core';
2
+
3
+ async function legacyPermission(): Promise<void> {
4
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
5
+ const permission = android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
6
+ if (!activity || activity.checkSelfPermission(permission) === android.content.pm.PackageManager.PERMISSION_GRANTED) {
7
+ if (!activity) throw Error('No active activity');
8
+ return;
9
+ }
10
+ await new Promise<void>((resolve, reject) => {
11
+ const requestCode = 7042;
12
+ const listener = (args: { requestCode: number; grantResults: number[] }) => {
13
+ if (args.requestCode !== requestCode) return;
14
+ Application.android.off(Application.android.activityRequestPermissionsEvent, listener);
15
+ if (args.grantResults[0] === android.content.pm.PackageManager.PERMISSION_GRANTED) resolve();
16
+ else reject(Error('Photo permission denied'));
17
+ };
18
+ Application.android.on(Application.android.activityRequestPermissionsEvent, listener);
19
+ activity.requestPermissions([permission], requestCode);
20
+ });
21
+ }
22
+
23
+ /** Save only the image requested by the player; scoped storage needs no library access. */
24
+ export async function savePhoto(canvas: HTMLCanvasElement, filename: string): Promise<'photos'> {
25
+ const scoped = android.os.Build.VERSION.SDK_INT >= 29;
26
+ if (!scoped) await legacyPermission();
27
+ const data = canvas.toDataURL('image/png').split(',')[1];
28
+ if (!data) throw Error('PNG encoding failed');
29
+ const bytes = android.util.Base64.decode(data, android.util.Base64.DEFAULT);
30
+ const resolver = Application.android.context.getContentResolver();
31
+ const values = new android.content.ContentValues();
32
+ values.put('_display_name', filename); values.put('mime_type', 'image/png');
33
+ if (scoped) { values.put('relative_path', 'Pictures/Haiyue'); values.put('is_pending', java.lang.Integer.valueOf(1)); }
34
+ const uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
35
+ if (!uri) throw Error('Cannot create photo');
36
+ try {
37
+ const stream = resolver.openOutputStream(uri);
38
+ if (!stream) throw Error('Cannot open photo');
39
+ try { stream.write(bytes); } finally { stream.close(); }
40
+ if (scoped) {
41
+ const complete = new android.content.ContentValues(); complete.put('is_pending', java.lang.Integer.valueOf(0));
42
+ if (resolver.update(uri, complete, '', []) !== 1) throw Error('Cannot publish photo');
43
+ }
44
+ return 'photos';
45
+ } catch (error) {
46
+ resolver.delete(uri, '', []);
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,19 @@
1
+ /** Add-only permission is requested on export, never when the application starts. */
2
+ export async function savePhoto(canvas: HTMLCanvasElement, _filename: string): Promise<'photos'> {
3
+ const status = await new Promise<PHAuthorizationStatus>(resolve =>
4
+ PHPhotoLibrary.requestAuthorizationForAccessLevelHandler(PHAccessLevel.AddOnly, resolve));
5
+ if (status !== PHAuthorizationStatus.Authorized && status !== PHAuthorizationStatus.Limited)
6
+ throw Error('Photo permission denied');
7
+ const encoded = canvas.toDataURL('image/png').split(',')[1];
8
+ if (!encoded) throw Error('PNG encoding failed');
9
+ const data = NSData.alloc().initWithBase64EncodedStringOptions(encoded, NSDataBase64DecodingOptions.IgnoreUnknownCharacters);
10
+ const image = data && UIImage.imageWithData(data);
11
+ if (!image) throw Error('Cannot decode photo');
12
+ await new Promise<void>((resolve, reject) => {
13
+ PHPhotoLibrary.sharedPhotoLibrary().performChangesCompletionHandler(
14
+ () => { PHAssetChangeRequest.creationRequestForAssetFromImage(image); },
15
+ (success, error) => success ? resolve() : reject(Error(error?.localizedDescription ?? 'Cannot save photo')),
16
+ );
17
+ });
18
+ return 'photos';
19
+ }
@@ -0,0 +1,2 @@
1
+ // NativeScript resolves .android/.ios at bundle time; this entry provides shared types.
2
+ export { savePhoto } from './save-photo.ios';
@@ -0,0 +1,19 @@
1
+ import type { MotionReading, MotionVector } from './motion-sample';
2
+ /** Android rotation vectors describe device-to-world rotation; gravity points down, in g. */
3
+ export function androidMotionReading(timestamp: number, vector: readonly number[], rotationRate: MotionVector, acceleration: MotionVector): MotionReading {
4
+ let [x, y, z, w = Math.sqrt(Math.max(0, 1 - vector[0] ** 2 - vector[1] ** 2 - vector[2] ** 2))] = vector;
5
+ const length = Math.hypot(x, y, z, w);
6
+ if (!Number.isFinite(length) || length < 1e-8) throw new RangeError('Invalid Android rotation vector.');
7
+ x /= length; y /= length; z /= length; w /= length;
8
+ const gravity = { x: -2 * (x * z - w * y), y: -2 * (y * z + w * x), z: -(1 - 2 * (x * x + y * y)) };
9
+ return { timestamp, attitude: {
10
+ pitch: Math.asin(Math.max(-1, Math.min(1, 2 * (w * x - y * z)))),
11
+ roll: Math.atan2(2 * (w * y + x * z), 1 - 2 * (x * x + y * y)),
12
+ yaw: Math.atan2(2 * (w * z + x * y), 1 - 2 * (x * x + z * z)),
13
+ quaternion: { x, y, z, w },
14
+ }, gravity, rotationRate: { ...rotationRate }, userAcceleration: {
15
+ x: acceleration.x / 9.80665 + gravity.x,
16
+ y: acceleration.y / 9.80665 + gravity.y,
17
+ z: acceleration.z / 9.80665 + gravity.z,
18
+ } };
19
+ }
@@ -0,0 +1,109 @@
1
+ import { Application, Utils } from '@nativescript/core';
2
+ import { createMotionSample, validateMotionInterval, validateScreenRotation,
3
+ type NativeMotionSample, type MotionScreenRotation, type MotionReading, type MotionVector } from './motion-sample';
4
+ import { androidMotionReading } from './android-reading';
5
+ export type { NativeMotionSample, MotionScreenRotation } from './motion-sample';
6
+ export interface NativeDeviceMotionOptions { updateIntervalMs?: number; screenRotation?: MotionScreenRotation }
7
+ let owner: NativeDeviceMotion | null = null;
8
+ /** Sensor callbacks only copy data. Game events are delivered from the existing Engine update. */
9
+ export class NativeDeviceMotion {
10
+ private readonly manager: android.hardware.SensorManager;
11
+ private readonly sensors: (android.hardware.Sensor | null)[];
12
+ private readonly listener: android.hardware.SensorEventListener;
13
+ private readonly listeners = new Set<(sample: NativeMotionSample) => void>();
14
+ private interval: number;
15
+ private rotation: MotionScreenRotation;
16
+ private requested = false;
17
+ private running = false;
18
+ private suspended = false;
19
+ private disposed = false;
20
+ private lastTimestamp: number | null = null;
21
+ private floor = 0;
22
+ private rate: MotionVector | null = null;
23
+ private acceleration: MotionVector | null = null;
24
+ private reading: MotionReading | null = null;
25
+ private sample: NativeMotionSample | null = null;
26
+ constructor(options: NativeDeviceMotionOptions = {}) {
27
+ this.interval = validateMotionInterval(options.updateIntervalMs ?? 1000 / 60);
28
+ this.rotation = validateScreenRotation(options.screenRotation ?? 0);
29
+ if (owner) throw new Error('Only one NativeDeviceMotion controller may exist per app.');
30
+ this.manager = Utils.android.getApplicationContext().getSystemService(android.content.Context.SENSOR_SERVICE) as android.hardware.SensorManager;
31
+ const S = android.hardware.Sensor;
32
+ this.sensors = [this.manager.getDefaultSensor(S.TYPE_GAME_ROTATION_VECTOR) ?? this.manager.getDefaultSensor(S.TYPE_ROTATION_VECTOR),
33
+ this.manager.getDefaultSensor(S.TYPE_GYROSCOPE), this.manager.getDefaultSensor(S.TYPE_ACCELEROMETER)];
34
+ this.listener = new android.hardware.SensorEventListener({ onAccuracyChanged: () => {}, onSensorChanged: event => {
35
+ const timestamp = event.timestamp / 1e9;
36
+ if (!this.running || this.suspended || timestamp <= this.floor) return;
37
+ const values = event.values;
38
+ const vector = { x: values[0], y: values[1], z: values[2] };
39
+ switch (event.sensor.getType()) {
40
+ case S.TYPE_GYROSCOPE: this.rate = vector; break;
41
+ case S.TYPE_ACCELEROMETER: this.acceleration = vector; break;
42
+ default:
43
+ if (!this.rate || !this.acceleration) return;
44
+ try { this.reading = androidMotionReading(timestamp, Array.from({length: values.length}, (_, i) => values[i]), this.rate, this.acceleration); }
45
+ catch { this.reading = null; }
46
+ }
47
+ } });
48
+ owner = this;
49
+ this.suspended = !!(Application.inBackground || Application.suspended);
50
+ Application.on(Application.suspendEvent, this.suspend);
51
+ Application.on(Application.resumeEvent, this.resume);
52
+ Application.on(Application.exitEvent, this.dispose);
53
+ }
54
+ get available(): boolean { return this.sensors.every(Boolean); }
55
+ get active(): boolean { return this.running; }
56
+ get latest(): NativeMotionSample | null { return this.sample; }
57
+ onUpdate(listener: (sample: NativeMotionSample) => void): () => void {
58
+ this.assertLive(); this.listeners.add(listener); return () => { this.listeners.delete(listener); };
59
+ }
60
+ start(): boolean {
61
+ this.assertLive(); if (!this.available) return false;
62
+ this.requested = true; if (!this.suspended) this.begin(); return true;
63
+ }
64
+ stop(): void { this.requested = false; this.end(); }
65
+ setUpdateInterval(ms: number): void {
66
+ this.assertLive(); this.interval = validateMotionInterval(ms);
67
+ if (this.running) { this.end(); this.begin(); }
68
+ }
69
+ setScreenRotation(rotation: MotionScreenRotation): void {
70
+ this.assertLive(); this.rotation = validateScreenRotation(rotation); this.sample = null;
71
+ }
72
+ update(deltaMs: number): NativeMotionSample | null {
73
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) throw new RangeError('Motion deltaMs must be finite and non-negative.');
74
+ if (this.disposed || !this.running || this.suspended) return null;
75
+ const reading = this.reading;
76
+ if (!reading || (this.lastTimestamp !== null && reading.timestamp <= this.lastTimestamp)) return null;
77
+ const sample = createMotionSample(reading, deltaMs, this.lastTimestamp === null ? 0 : (reading.timestamp - this.lastTimestamp) * 1000, this.rotation);
78
+ this.lastTimestamp = reading.timestamp; this.sample = sample;
79
+ for (const listener of [...this.listeners]) {
80
+ if (!this.running || this.disposed) break;
81
+ if (this.listeners.has(listener)) listener(sample);
82
+ }
83
+ return sample;
84
+ }
85
+ readonly suspend = (): void => { this.suspended = true; this.end(); };
86
+ readonly resume = (): void => { if (!this.disposed) { this.suspended = false; if (this.requested) this.begin(); } };
87
+ readonly dispose = (): void => {
88
+ if (this.disposed) return;
89
+ this.stop(); this.disposed = true; this.listeners.clear();
90
+ Application.off(Application.suspendEvent, this.suspend);
91
+ Application.off(Application.resumeEvent, this.resume);
92
+ Application.off(Application.exitEvent, this.dispose);
93
+ if (owner === this) owner = null;
94
+ };
95
+ private begin(): void {
96
+ if (this.running) return;
97
+ this.floor = android.os.SystemClock.elapsedRealtimeNanos() / 1e9;
98
+ this.running = true;
99
+ try {
100
+ for (const sensor of this.sensors) if (!sensor || !this.manager.registerListener(this.listener, sensor, Math.round(this.interval * 1000)))
101
+ throw new Error('Android motion sensor registration failed.');
102
+ } catch (error) { this.requested = false; this.end(); throw error; }
103
+ }
104
+ private end(): void {
105
+ if (this.running) this.manager.unregisterListener(this.listener);
106
+ this.running = false; this.lastTimestamp = null; this.sample = null; this.reading = null; this.rate = null; this.acceleration = null;
107
+ }
108
+ private assertLive(): void { if (this.disposed) throw new Error('NativeDeviceMotion is disposed.'); }
109
+ }