@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,163 @@
1
+ /** SDK-independent daily allowances and durable, idempotent rewarded credits.
2
+ * One controller per namespaced storage key. Never share a key between games. */
3
+ export type RewardPhase = 'ready' | 'loading' | 'earned' | 'cancelled' | 'unavailable' | 'offline' | 'error' | 'limit';
4
+ export interface RewardSnapshot {
5
+ unlimited: boolean; free: number; credits: number; adsRemaining: number;
6
+ busy: boolean; phase: RewardPhase; privacyRequired: boolean;
7
+ initializing: boolean; presenting: boolean; operation: 'ad' | 'privacy' | 'consent' | null;
8
+ }
9
+ export interface RewardPresentation { prepare(): Promise<boolean>; closed(): void; }
10
+ export interface RewardGateway {
11
+ initialize?(presentForm: boolean, beforePresent: () => Promise<boolean>): Promise<void>;
12
+ show(earned: () => void, presentation: RewardPresentation): Promise<void>;
13
+ privacy(presentation: RewardPresentation): Promise<void>;
14
+ privacyRequired(): boolean;
15
+ dispose(): void;
16
+ }
17
+ export interface RewardStorage { read(): string | null; write(value: string): void; }
18
+ interface Wallet { version: 1; day: string; used: number; ads: number; credits: number; sequence: number; rewarded: number; hints: string[]; }
19
+ export class RewardController {
20
+ private wallet!: Wallet;
21
+ private phase: RewardPhase = 'ready';
22
+ private busy = false;
23
+ private disposed = false;
24
+ private broken = false;
25
+ private listeners = new Set<() => void>();
26
+ private initialization?: Promise<void>;
27
+ private initializing: boolean;
28
+ private presenting = false;
29
+ private operation: RewardSnapshot['operation'] = null;
30
+ constructor(private readonly options: {
31
+ storage: RewardStorage; gateway: RewardGateway; entitled: () => boolean;
32
+ dailyFree: number; dailyAds: number; pause: () => (() => void) | Promise<() => void>; now?: () => Date;
33
+ }) {
34
+ this.initializing = !!options.gateway.initialize;
35
+ if (![options.dailyFree, options.dailyAds].every(n => Number.isSafeInteger(n) && n >= 0)) throw Error('Invalid daily allowance');
36
+ try {
37
+ const raw = options.storage.read();
38
+ const data = raw === null ? this.empty() : JSON.parse(raw);
39
+ if (data.version !== 1 || !/^\d{4}-\d{2}-\d{2}$/.test(data.day) ||
40
+ !['used','ads','credits','sequence','rewarded'].every(k => Number.isSafeInteger(data[k]) && data[k] >= 0) ||
41
+ data.rewarded > data.sequence || !Array.isArray(data.hints) || !data.hints.every((k: unknown) => typeof k === 'string')) throw Error('Invalid wallet');
42
+ this.wallet = data;
43
+ this.rollover();
44
+ } catch { this.broken = true; this.phase = 'error'; this.wallet ??= this.empty(); }
45
+ }
46
+ private day(): string {
47
+ const date = this.options.now?.() ?? new Date();
48
+ return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
49
+ }
50
+ private empty(): Wallet { return { version:1, day:this.day(), used:0, ads:0, credits:0, sequence:0, rewarded:0, hints:[] }; }
51
+ private save(next: Wallet): boolean {
52
+ try { this.options.storage.write(JSON.stringify(next)); this.wallet = next; return true; }
53
+ catch { this.broken = true; this.phase = 'error'; return false; }
54
+ }
55
+ private rollover(): void {
56
+ const day = this.day();
57
+ // Moving the clock backwards cannot refill an already used day.
58
+ if (!this.broken && day > this.wallet.day && this.save({ ...this.wallet, day, used:0, ads:0, hints:[] }) && !this.busy) this.phase = 'ready';
59
+ }
60
+ snapshot(): RewardSnapshot {
61
+ this.rollover();
62
+ return { unlimited:this.options.entitled(), free:this.broken ? 0 : Math.max(0,this.options.dailyFree-this.wallet.used),
63
+ credits:this.broken ? 0 : this.wallet.credits, adsRemaining:this.broken ? 0 : Math.max(0,this.options.dailyAds-this.wallet.ads),
64
+ busy:this.busy, phase:this.phase, privacyRequired:this.options.gateway.privacyRequired(),
65
+ initializing:this.initializing, presenting:this.presenting, operation:this.operation };
66
+ }
67
+ subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
68
+ refresh(): void { this.rollover(); this.emit(); }
69
+ /** Startup failure must leave normal play and the wallet intact. */
70
+ initialize(): Promise<void> {
71
+ if (this.disposed || !this.options.gateway.initialize) return Promise.resolve();
72
+ return this.initialization ??= this.initializeConsent();
73
+ }
74
+ private async initializeConsent(): Promise<void> {
75
+ if (this.busy) { this.initializing = false; this.emit(); return; }
76
+ let presenting = false;
77
+ let resume = () => {};
78
+ try {
79
+ await this.options.gateway.initialize!(!this.options.entitled(), async () => {
80
+ // A silent network refresh must not stop calendar, paywall or puzzle input.
81
+ // Explicit ad/privacy work takes precedence and will collect consent itself.
82
+ if (this.disposed || this.busy || this.options.entitled()) return false;
83
+ presenting = true; this.busy = true; this.operation = 'consent'; this.presenting = true; this.emit();
84
+ const pause = this.options.pause();
85
+ resume = typeof pause === 'function' ? pause : await pause;
86
+ return !this.disposed;
87
+ });
88
+ } catch { /* Retry through the next explicit ad/privacy request. */ }
89
+ finally {
90
+ this.initializing = false;
91
+ if (presenting) { this.busy = false; this.presenting = false; this.operation = null; resume(); }
92
+ this.emit();
93
+ }
94
+ }
95
+ private presentation(): { hooks: RewardPresentation; release(): void } {
96
+ let active = true, resume: (() => void) | undefined;
97
+ let preparing: Promise<boolean> | undefined;
98
+ const close = () => {
99
+ preparing = undefined;
100
+ this.presenting = false;
101
+ const release = resume; resume = undefined; release?.(); this.emit();
102
+ };
103
+ return {
104
+ hooks: {
105
+ prepare: () => preparing ??= (async () => {
106
+ if (!active || this.disposed) return false;
107
+ this.presenting = true; this.emit();
108
+ const release = await this.options.pause();
109
+ if (!active || this.disposed) { release(); return false; }
110
+ resume = release; return true;
111
+ })(),
112
+ closed: close,
113
+ },
114
+ release: () => { active = false; close(); },
115
+ };
116
+ }
117
+ private emit(): void { if (!this.disposed) for (const listener of this.listeners) listener(); }
118
+ /** Call only once a useful result is ready. The same result key is free to redisplay. */
119
+ consume(key: string): boolean {
120
+ if (this.disposed || !key) return false;
121
+ if (this.options.entitled()) return true;
122
+ const state = this.snapshot();
123
+ if (this.broken || this.busy) return false;
124
+ if (this.wallet.hints.includes(key)) return true;
125
+ if (!state.free && !state.credits) return false;
126
+ const ok = this.save({ ...this.wallet, used:this.wallet.used + (state.free ? 1 : 0), credits:this.wallet.credits - (state.free ? 0 : 1), hints:[...this.wallet.hints, key] });
127
+ this.emit(); return ok;
128
+ }
129
+ async watch(): Promise<void> {
130
+ if (this.disposed || this.busy || this.options.entitled() || this.broken) return;
131
+ if (!this.snapshot().adsRemaining) { this.phase = 'limit'; this.emit(); return; }
132
+ const sequence = this.wallet.sequence + 1;
133
+ if (!this.save({ ...this.wallet, sequence })) { this.emit(); return; }
134
+ this.busy = true; this.operation = 'ad'; this.phase = 'loading'; this.emit();
135
+ const presentation = this.presentation();
136
+ let earned = false, ended = false;
137
+ try {
138
+ if (this.disposed) return;
139
+ await this.options.gateway.show(() => {
140
+ // Google-earned events precede dismissal. Stale/duplicate callbacks never mint credits.
141
+ if (ended || earned || this.wallet.rewarded >= sequence) return;
142
+ this.rollover();
143
+ earned = this.save({ ...this.wallet, rewarded:sequence, credits:this.wallet.credits+1, ads:this.wallet.ads+1 });
144
+ this.phase = earned ? 'earned' : 'error'; this.emit();
145
+ }, presentation.hooks);
146
+ if (!this.broken) this.phase = earned ? 'earned' : 'cancelled';
147
+ } catch (error) {
148
+ if (!earned && !this.broken) this.phase = error instanceof Error && ['offline','unavailable'].includes(error.message) ? error.message as RewardPhase : 'error';
149
+ } finally { ended = true; this.busy = false; this.operation = null; presentation.release(); this.emit(); }
150
+ }
151
+ async privacy(): Promise<void> {
152
+ if (this.disposed || this.busy) return;
153
+ this.busy = true; this.operation = 'privacy'; this.phase = 'loading'; this.emit();
154
+ const presentation = this.presentation();
155
+ try {
156
+ if (this.disposed) return;
157
+ await this.options.gateway.privacy(presentation.hooks); this.phase = 'ready';
158
+ }
159
+ catch { this.phase = 'error'; }
160
+ finally { this.busy = false; this.operation = null; presentation.release(); this.emit(); }
161
+ }
162
+ dispose(): void { this.disposed = true; this.listeners.clear(); this.options.gateway.dispose(); }
163
+ }
@@ -0,0 +1,116 @@
1
+ package org.haiyue.rewards;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Bundle;
5
+ import android.os.Handler;
6
+ import android.os.Looper;
7
+ import com.google.android.gms.ads.*;
8
+ import com.google.android.gms.ads.rewarded.*;
9
+ import com.google.ads.mediation.admob.AdMobAdapter;
10
+ import com.google.android.ump.*;
11
+
12
+ /** Reusable AdMob adapter. No mediation: Google's reward callback precedes dismissal. */
13
+ public final class HYRewardedAds {
14
+ public interface Events { void onEvent(String event); }
15
+ private final Handler handler = new Handler(Looper.getMainLooper());
16
+ private ConsentInformation consent;
17
+ private Events events;
18
+ private RewardedAd ad;
19
+ private boolean busy, disposed, loading;
20
+ private int generation;
21
+ public boolean privacyRequired(android.content.Context context) {
22
+ consent = UserMessagingPlatform.getConsentInformation(context);
23
+ return consent != null && consent.getPrivacyOptionsRequirementStatus() == ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED;
24
+ }
25
+ private Runnable pendingPresentation;
26
+ private boolean active(int token) { return !disposed && busy && token == generation; }
27
+ private void emit(String value) { if (events != null) events.onEvent(value); }
28
+ private void end(String value) {
29
+ loading = false; busy = false; ++generation; pendingPresentation = null;
30
+ Events callback = events; events = null; ad = null;
31
+ if (callback != null) callback.onEvent(value);
32
+ }
33
+ private void deadline(int token, long milliseconds) {
34
+ loading = true;
35
+ handler.postDelayed(() -> { if (loading && token == generation) end("error:unavailable"); }, milliseconds);
36
+ }
37
+ private void preparePresentation(Activity activity, int token, Runnable show) {
38
+ if (!active(token) || activity.isFinishing() || activity.isDestroyed()) { end("error:unavailable"); return; }
39
+ loading = false;
40
+ pendingPresentation = () -> {
41
+ if (!active(token) || activity.isFinishing() || activity.isDestroyed()) { end("error:unavailable"); return; }
42
+ show.run();
43
+ };
44
+ emit("presenting");
45
+ }
46
+ public void continuePresentation(boolean ready) {
47
+ handler.post(() -> {
48
+ Runnable show = pendingPresentation; pendingPresentation = null;
49
+ if (show == null) return;
50
+ if (!ready || disposed) { end("error:unavailable"); return; }
51
+ show.run();
52
+ });
53
+ }
54
+ public void perform(Activity activity, String action, String unit, Events callback) {
55
+ activity.runOnUiThread(() -> {
56
+ if (disposed || busy || activity.isFinishing() || activity.isDestroyed()) { callback.onEvent("error:unavailable"); return; }
57
+ busy = true; events = callback; final int token = ++generation;
58
+ consent = UserMessagingPlatform.getConsentInformation(activity);
59
+ deadline(token, 20000);
60
+ consent.requestConsentInfoUpdate(activity, new ConsentRequestParameters.Builder().build(), () -> {
61
+ if (!active(token)) return;
62
+ if (action.equals("privacy")) {
63
+ if (!privacyRequired(activity)) { end("closed"); return; }
64
+ preparePresentation(activity, token, () -> UserMessagingPlatform.showPrivacyOptionsForm(activity,
65
+ error -> { if (token == generation) end(error == null ? "closed" : "error:unavailable"); }));
66
+ } else if (consent.getConsentStatus() == ConsentInformation.ConsentStatus.REQUIRED) {
67
+ UserMessagingPlatform.loadConsentForm(activity, form -> {
68
+ if (!active(token)) return;
69
+ preparePresentation(activity, token, () -> form.show(activity, error -> {
70
+ if (token != generation) return;
71
+ emit("presentation-closed");
72
+ if (error != null || disposed || !consent.canRequestAds()) { end("error:unavailable"); return; }
73
+ initialize(activity, unit, token);
74
+ }));
75
+ }, error -> { if (active(token)) end("error:unavailable"); });
76
+ } else if (consent.canRequestAds()) initialize(activity, unit, token);
77
+ else end("error:unavailable");
78
+ }, error -> {
79
+ if (!active(token)) return;
80
+ if (!action.equals("privacy") && consent.canRequestAds()) initialize(activity, unit, token);
81
+ else end("error:unavailable");
82
+ });
83
+ });
84
+ }
85
+ private void initialize(Activity activity, String unit, int token) {
86
+ if (!active(token)) return;
87
+ // New phase invalidates the consent deadline without invalidating callbacks.
88
+ loading = false;
89
+ final int adToken = ++generation;
90
+ deadline(adToken, 45000);
91
+ MobileAds.initialize(activity.getApplicationContext(), status -> handler.post(() -> {
92
+ if (!active(adToken)) return;
93
+ Bundle extras = new Bundle(); extras.putString("npa", "1");
94
+ AdRequest request = new AdRequest.Builder().addNetworkExtrasBundle(AdMobAdapter.class, extras).build();
95
+ RewardedAd.load(activity, unit, request, new RewardedAdLoadCallback() {
96
+ @Override public void onAdFailedToLoad(LoadAdError error) {
97
+ if (active(adToken)) end(error.getCode() == AdRequest.ERROR_CODE_NETWORK_ERROR ? "error:offline" : "error:unavailable");
98
+ }
99
+ @Override public void onAdLoaded(RewardedAd loaded) {
100
+ if (!active(adToken)) return;
101
+ ad = loaded;
102
+ ad.setFullScreenContentCallback(new FullScreenContentCallback() {
103
+ @Override public void onAdDismissedFullScreenContent() { if (adToken == generation) end("closed"); }
104
+ @Override public void onAdFailedToShowFullScreenContent(AdError error) { if (adToken == generation) end("error:unavailable"); }
105
+ });
106
+ preparePresentation(activity, adToken, () -> ad.show(activity, reward -> { if (adToken == generation) emit("earned"); }));
107
+ }
108
+ });
109
+ }));
110
+ }
111
+ public void dispose() {
112
+ disposed = true;
113
+ // Keep earned/dismiss callbacks alive if an ad is already on screen.
114
+ if (loading || pendingPresentation != null) end("error:unavailable");
115
+ }
116
+ }
@@ -0,0 +1,169 @@
1
+ import UIKit
2
+ import GoogleMobileAds
3
+ import UserMessagingPlatform
4
+
5
+ @MainActor @objc(HYRewardedAds) public final class HYRewardedAds: NSObject, FullScreenContentDelegate {
6
+ private var events: ((String) -> Void)?
7
+ private var ad: RewardedAd?
8
+ private var disposed = false
9
+ private var generation = 0
10
+ private var timeout: Task<Void, Never>?
11
+ private var presentation: CheckedContinuation<Bool, Never>?
12
+ private var startedAt = ProcessInfo.processInfo.systemUptime
13
+ @objc public var privacyRequired: Bool { ConsentInformation.shared.privacyOptionsRequirementStatus == .required }
14
+ @objc public var consentRequired: Bool { ConsentInformation.shared.consentStatus == .required }
15
+ private var development: Bool { Bundle.main.object(forInfoDictionaryKey: "HYBuildConfiguration") as? String == "Debug" }
16
+ private func log(_ text: String) { if development { NSLog("[haiyue-consent] %@", text) } }
17
+ private var simulator: Bool {
18
+ #if targetEnvironment(simulator)
19
+ return true
20
+ #else
21
+ return false
22
+ #endif
23
+ }
24
+ private func logState(_ stage: String) {
25
+ guard development else { return }
26
+ let info = ConsentInformation.shared
27
+ let elapsed = Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1000)
28
+ log("\(stage) elapsedMs=\(elapsed) consent=\(info.consentStatus) status=\(info.consentStatus.rawValue) form=\(info.formStatus) formStatus=\(info.formStatus.rawValue) privacyStatus=\(info.privacyOptionsRequirementStatus.rawValue) canRequestAds=\(info.canRequestAds) appState=\(UIApplication.shared.applicationState.rawValue)")
29
+ }
30
+ private func logError(_ stage: String, _ error: Error) {
31
+ guard development else { return }
32
+ let failure = error as NSError
33
+ // Do not dump userInfo, consent strings, identifiers or network payloads.
34
+ log("\(stage) domain=\(failure.domain) code=\(failure.code) description=\(failure.localizedDescription)")
35
+ if let underlying = failure.userInfo[NSUnderlyingErrorKey] as? NSError {
36
+ log("\(stage) underlyingDomain=\(underlying.domain) underlyingCode=\(underlying.code) description=\(underlying.localizedDescription)")
37
+ }
38
+ logState("\(stage) state")
39
+ }
40
+ private func root() -> UIViewController? {
41
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
42
+ var controller = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }?.rootViewController
43
+ while let presented = controller?.presentedViewController { controller = presented }
44
+ return controller
45
+ }
46
+ private func active(_ token: Int) -> Bool { !disposed && events != nil && token == generation }
47
+ private func end(_ event: String) {
48
+ logState("end event=\(event)")
49
+ timeout?.cancel(); timeout = nil; generation += 1
50
+ let pending = presentation; presentation = nil; pending?.resume(returning: false)
51
+ let callback = events; events = nil; ad = nil; callback?(event)
52
+ }
53
+ @objc public func continuePresentation(_ ready: Bool) {
54
+ let pending = presentation; presentation = nil
55
+ pending?.resume(returning: ready && !disposed && UIApplication.shared.applicationState == .active)
56
+ }
57
+ private func preparePresentation(_ token: Int) async -> Bool {
58
+ guard active(token) else { return false }
59
+ return await withCheckedContinuation { continuation in
60
+ presentation = continuation
61
+ events?("presenting")
62
+ }
63
+ }
64
+ private func deadline(_ seconds: UInt64, token: Int) {
65
+ timeout?.cancel()
66
+ timeout = Task { @MainActor [weak self] in
67
+ do { try await Task.sleep(nanoseconds: seconds * 1_000_000_000) } catch { return }
68
+ guard let self, self.active(token) else { return }
69
+ self.log("network timeout"); self.end("error:unavailable")
70
+ }
71
+ }
72
+ private func parameters() -> RequestParameters {
73
+ let parameters = RequestParameters()
74
+ // Explicit device-scoped diagnostics only; never applied in a Release binary.
75
+ let device = ProcessInfo.processInfo.environment["HY_UMP_TEST_DEVICE_ID"] ?? ""
76
+ if development && (simulator || !device.isEmpty) {
77
+ let debug = DebugSettings()
78
+ debug.testDeviceIdentifiers = device.isEmpty ? [] : [device]
79
+ if ProcessInfo.processInfo.environment["HY_UMP_EEA"] == "1" { debug.geography = .EEA }
80
+ parameters.debugSettings = debug
81
+ }
82
+ log("parameters simulator=\(simulator) registeredTestDeviceCount=\(parameters.debugSettings?.testDeviceIdentifiers?.count ?? 0) geography=\(parameters.debugSettings?.geography.rawValue ?? 0) underAge=\(parameters.isTaggedForUnderAgeOfConsent)")
83
+ return parameters
84
+ }
85
+ @objc(perform:unit:events:) public func perform(_ action: String, unit: String, events callback: @escaping (String) -> Void) {
86
+ guard !disposed, events == nil, let controller = root() else { callback("error:unavailable"); return }
87
+ guard ["consent", "refreshPrivacy", "presentConsent", "privacy", "show"].contains(action) else { callback("error:unavailable"); return }
88
+ events = callback; generation += 1
89
+ startedAt = ProcessInfo.processInfo.systemUptime
90
+ let token = generation
91
+ if development && ["consent", "refreshPrivacy"].contains(action) && ProcessInfo.processInfo.environment["HY_UMP_RESET"] == "1" {
92
+ ConsentInformation.shared.reset()
93
+ log("reset test consent")
94
+ }
95
+ Task { @MainActor in
96
+ let appID = Bundle.main.object(forInfoDictionaryKey: "GADApplicationIdentifier") as? String ?? "missing"
97
+ let adsVersion = MobileAds.shared.versionNumber
98
+ log("begin \(action) bundle=\(Bundle.main.bundleIdentifier ?? "missing") appID=\(appID) ump=\(UserMessagingPlatform.Version) gma=\(adsVersion.majorVersion).\(adsVersion.minorVersion).\(adsVersion.patchVersion) simulator=\(simulator)")
99
+ logState("before update")
100
+ // Startup already refreshed consent with input active. Do not repeat
101
+ // that network request after acquiring the form-presentation pause.
102
+ if action != "presentConsent" {
103
+ deadline(20, token: token)
104
+ do {
105
+ try await ConsentInformation.shared.requestConsentInfoUpdate(with: parameters())
106
+ } catch {
107
+ logError("update failed", error)
108
+ guard active(token) else { return }
109
+ // Only the ad path may fall back to a still-valid previous consent state.
110
+ if action != "show" || !ConsentInformation.shared.canRequestAds { end("error:unavailable"); return }
111
+ }
112
+ }
113
+ guard active(token) else { return }
114
+ timeout?.cancel(); timeout = nil
115
+ logState("updated")
116
+ if action == "refreshPrivacy" { end("closed"); return }
117
+ guard UIApplication.shared.applicationState == .active else { end("error:unavailable"); return }
118
+ do {
119
+ // No timeout while a user is reading or interacting with the consent form.
120
+ if action == "privacy" {
121
+ guard privacyRequired else { end("closed"); return }
122
+ guard await preparePresentation(token), active(token) else { if active(token) { end("error:unavailable") }; return }
123
+ try await ConsentForm.presentPrivacyOptionsForm(from: controller)
124
+ events?("presentation-closed")
125
+ } else if consentRequired {
126
+ // Load while the game's loading indicator is still animating.
127
+ deadline(20, token: token)
128
+ let form = try await ConsentForm.load()
129
+ guard active(token) else { return }
130
+ timeout?.cancel(); timeout = nil
131
+ guard await preparePresentation(token), active(token) else { if active(token) { end("error:unavailable") }; return }
132
+ try await form.present(from: controller)
133
+ events?("presentation-closed")
134
+ }
135
+ } catch {
136
+ logError("form failed", error)
137
+ guard active(token) else { return }
138
+ end("error:unavailable"); return
139
+ }
140
+ guard active(token) else { return }
141
+ logState("completed \(action)")
142
+ if action != "show" { end("closed"); return }
143
+ guard ConsentInformation.shared.canRequestAds else { end("error:unavailable"); return }
144
+ deadline(45, token: token)
145
+ await MobileAds.shared.start()
146
+ guard active(token) else { return }
147
+ do {
148
+ let request = Request()
149
+ let extras = Extras(); extras.additionalParameters = ["npa": "1"]; request.register(extras)
150
+ let loaded = try await RewardedAd.load(with: unit, request: request)
151
+ guard active(token) else { return }
152
+ timeout?.cancel(); timeout = nil
153
+ ad = loaded; loaded.fullScreenContentDelegate = self
154
+ guard UIApplication.shared.applicationState == .active else { end("error:unavailable"); return }
155
+ guard await preparePresentation(token), active(token) else { if active(token) { end("error:unavailable") }; return }
156
+ loaded.present(from: controller) { [weak self] in
157
+ guard let self, self.active(token) else { return }
158
+ self.events?("earned")
159
+ }
160
+ } catch {
161
+ logError("ad failed", error)
162
+ if active(token) { end((error as NSError).code == 2 ? "error:offline" : "error:unavailable") }
163
+ }
164
+ }
165
+ }
166
+ public func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) { end("closed") }
167
+ public func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { end("error:unavailable") }
168
+ @objc public func dispose() { disposed = true; end("error:unavailable") }
169
+ }
@@ -0,0 +1,6 @@
1
+ /** Supply the structured clone operation used by Engine's save service on older native runtimes. */
2
+ export function installNativeSaveRuntime(clone: typeof structuredClone): void {
3
+ if (typeof globalThis.structuredClone !== 'function') {
4
+ Object.defineProperty(globalThis, 'structuredClone', { configurable: true, writable: true, value: clone });
5
+ }
6
+ }
@@ -0,0 +1,12 @@
1
+ import * as settings from '@nativescript/core/application-settings';
2
+ /** Scoped persistent Storage port for Engine's injectable LocalStorageSaveBackend. */
3
+ export class NativeSettingsStorage implements Storage {
4
+ constructor(private readonly prefix = 'haiyue-game:') {}
5
+ private keys(): string[] { return settings.getAllKeys().filter(key => key.startsWith(this.prefix)).sort(); }
6
+ get length(): number { return this.keys().length; }
7
+ key(index: number): string | null { return this.keys()[index]?.slice(this.prefix.length) ?? null; }
8
+ getItem(key: string): string | null { return settings.hasKey(this.prefix + key) ? settings.getString(this.prefix + key) : null; }
9
+ setItem(key: string, value: string): void { settings.setString(this.prefix + key, String(value)); settings.flush(); }
10
+ removeItem(key: string): void { settings.remove(this.prefix + key); settings.flush(); }
11
+ clear(): void { for (const key of this.keys()) settings.remove(key); settings.flush(); }
12
+ }
package/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { NativeRenderHost, type NativeRenderHostOptions, type NativeHostInput } from './bridge/lifecycle/host';
2
+ export { NativeSurface, NativeSurfaceUnavailableError, type NativeCanvasInput } from './bridge/render/surface';
3
+ export { NativeTouchInput, type NativeTouchSample } from './bridge/input/native-touch';
4
+ export { NativeDeviceMotion, type NativeDeviceMotionOptions, type NativeMotionSample, type MotionScreenRotation } from './bridge/motion/device-motion';
5
+ export { NativeHaptics, type NativeImpact } from './bridge/feedback/haptics';
6
+ export { NativePcmAudioBank, type NativePcmSound, type NativePcmPlay } from './bridge/audio/pcm-bank';
7
+ export { NativeOrientationController } from './bridge/display/orientation';
8
+ export { type OrientationPolicy } from './bridge/display/orientation-policy';
9
+ export { NativeCanvasTextures } from './bridge/render/canvas-textures';
10
+ export { NativeSettingsStorage } from './bridge/storage/settings-storage';
11
+ export { installNativeSaveRuntime } from './bridge/storage/clone-runtime';
12
+ export { readNativeBytes } from './bridge/files/read-bytes';
13
+ export { savePhoto } from './bridge/media/save-photo';
14
+ export { NativeEngineLaunchPage } from './bridge/branding/launch-page';
15
+ export { NativeEngineSplash, type NativeEngineSplashOptions } from './bridge/branding/engine-splash';
16
+ export { NativeDemandFrames } from './bridge/lifecycle/demand-frames';
17
+ export { PresentationPause } from './bridge/lifecycle/presentation-pause';
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@haiyue/native",
3
+ "version": "0.1.0",
4
+ "description": "NativeScript iOS and Android adapters for Haiyue WebGPU rendering, input, motion, haptics and lifecycle",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "index.ts",
8
+ "types": "index.ts",
9
+ "exports": {
10
+ ".": "./index.ts",
11
+ "./motion": {
12
+ "types": "./bridge/motion/device-motion.ts",
13
+ "default": "./bridge/motion/device-motion"
14
+ },
15
+ "./feedback": {
16
+ "types": "./bridge/feedback/haptics.ts",
17
+ "default": "./bridge/feedback/haptics"
18
+ },
19
+ "./audio": {
20
+ "types": "./bridge/audio/pcm-bank.ts",
21
+ "default": "./bridge/audio/pcm-bank"
22
+ },
23
+ "./orientation": {
24
+ "types": "./bridge/display/orientation.ts",
25
+ "default": "./bridge/display/orientation"
26
+ },
27
+ "./media": {
28
+ "types": "./bridge/media/save-photo.ts",
29
+ "default": "./bridge/media/save-photo"
30
+ },
31
+ "./branding": "./bridge/branding/launch-page.ts",
32
+ "./branding/webpack": "./bridge/branding/webpack.cjs",
33
+ "./rewards": "./bridge/rewards/controller.ts",
34
+ "./rewards/admob": "./bridge/rewards/admob.ts",
35
+ "./bridge/*": "./bridge/*",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "index.ts",
40
+ "bridge/**/*.ts",
41
+ "bridge/branding/webpack.cjs",
42
+ "bridge/branding/assets/haiyue-moon.png",
43
+ "bridge/rewards/native/",
44
+ "provenance.json",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/HaiyueStudio/Native.git",
51
+ "directory": "npm"
52
+ },
53
+ "homepage": "https://github.com/HaiyueStudio/Native/tree/main/npm",
54
+ "bugs": {
55
+ "url": "https://github.com/HaiyueStudio/Native/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "registry": "https://registry.npmjs.org"
60
+ },
61
+ "engines": {
62
+ "node": ">=22"
63
+ },
64
+ "peerDependencies": {
65
+ "@haiyue/engine": "0.1.0",
66
+ "@nativescript/canvas": "2.1.18",
67
+ "@nativescript/core": "9.1.1"
68
+ },
69
+ "devDependencies": {
70
+ "@nativescript/types": "9.0.0",
71
+ "@nativescript/webpack": "5.0.38",
72
+ "@webgpu/types": "0.1.64",
73
+ "typescript": "5.7.3"
74
+ },
75
+ "scripts": {
76
+ "build": "node pack.mjs",
77
+ "typecheck": "tsc --noEmit",
78
+ "test": "node --test test/*.test.mjs"
79
+ },
80
+ "keywords": [
81
+ "webgpu",
82
+ "nativescript",
83
+ "ios",
84
+ "android",
85
+ "haiyue"
86
+ ]
87
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@haiyue/native",
3
+ "version": "0.1.0",
4
+ "sourceTag": "native-v0.1.0",
5
+ "sourceCommit": "609d5669bee2a758e2beafffb87d01682232bb8f",
6
+ "manifestSha256": "f4fa432c6140b7d49ad05b49dceabf36dc3477c24242e628f3e41d814ef5e8a9",
7
+ "files": {
8
+ "bridge/audio/pcm-bank.android.ts": "7be6c930c68d3965bf8d5effffbb9aa7186e72e6141b6b1e69fcd4d35fe141dd",
9
+ "bridge/audio/pcm-bank.ios.ts": "c2fb941f0e3d65ca77f85e32b6cd07884fef77ac73b8d328eb5651aa9f40e829",
10
+ "bridge/audio/pcm-bank.ts": "9e3d10e8179e2c23cfec5e03f9dffd675aaea8dd34a986b56072fcac1913407d",
11
+ "bridge/branding/assets/haiyue-moon.png": "697577528fcc8585f9c452bb0579c077f8f0b8c3588e358864d5b21ead7b3f10",
12
+ "bridge/branding/engine-splash.ts": "8ddffedf5ad150886d926054b861e63f7d6dcd3ecaa6907cc20a1af61d628d54",
13
+ "bridge/branding/launch-page.ts": "fa530dd1b96dadbb7ab1aadd0667e407901aacd5f4dab44e159b49d3264af16e",
14
+ "bridge/branding/webpack.cjs": "5747a09607b5aac51b334cac28e1642d9c504e6ab982182c90ba203e940f841d",
15
+ "bridge/display/orientation-policy.ts": "6e876797b48e186bcab6b2b62d20ad015974c1af1ef982bfd8beaf8db5bad46e",
16
+ "bridge/display/orientation.android.ts": "f883b739001e0f13c11f84fac8f4ad4d74d200b63e57e85798ae6613d693882b",
17
+ "bridge/display/orientation.ios.ts": "f5f77f34b22f15f59f877eb5e958a416e4b5619242ecbc085ede2f03518b3f92",
18
+ "bridge/display/orientation.ts": "101805765d6019fdf8e92427b46f9d7c2c7358d58e85b1699d8d30f498e64f6b",
19
+ "bridge/feedback/haptics.android.ts": "662c2b687c8da0732b18a48c1ae70984fdd974255c497a83758f51c8946875cf",
20
+ "bridge/feedback/haptics.ios.ts": "90da0bb50ecae2f5827acf558fab182cec9a23026aabe707bbda29990ad6402a",
21
+ "bridge/feedback/haptics.ts": "66fe8338e9db018a9c7988624fac9521a6b6fdc57f9775fe6016fd01b2a053d6",
22
+ "bridge/files/read-bytes.ts": "44b1af3eb85eff39038ea3d44e86a05875b8c4b35269f0af6ab9334014696c03",
23
+ "bridge/input/native-touch.android.ts": "a64d6c0346bafc5e355393ac8da958baa0c6c9cbf3da96be392778ea7f6b9f4d",
24
+ "bridge/input/native-touch.ios.ts": "b9f4642773627e8b7a0a6fd53c69c10c016623f6497a2deba1a5e360d17f461a",
25
+ "bridge/input/native-touch.ts": "1ff46fd8e391e3ab08d665201e866f5810d3c6cc9228559a59465252684832f5",
26
+ "bridge/input/pointer-target.ts": "a0372f870e60bbf4167c37bbfe301407544cd62c699ec725f38af450c7d8aafa",
27
+ "bridge/input/touch-identity.ts": "98174d6df6e9e9b2d4c15004f3e741394acfe89546ac2bb5602abd7f7f8e56f5",
28
+ "bridge/lifecycle/demand-frames.ts": "dc0dabdeb91357c9596e2bd3998e5c4f1890056238bac9ca729ab22318115eeb",
29
+ "bridge/lifecycle/frame-performance.ts": "6051bd3ad1ea25accfcfc7ac740f0b56dab60557191e95c7f7e7d03fc7d4bcf1",
30
+ "bridge/lifecycle/frame-scheduler.ts": "24eba67e040495c7d11e37f6abf69339db089871391d175e1cacb09a6f646ff8",
31
+ "bridge/lifecycle/host.ts": "63694646aabd874fc4844e4661abac33b96829abfb603763fa2177e03562c90f",
32
+ "bridge/lifecycle/launch-flags.ts": "c271d26843b7f8fc2790358a9effe7f291839f6332dcb9d08355aa51e46ebe9a",
33
+ "bridge/lifecycle/presentation-pause.ts": "abf5c76a06f228eb3f811a53d5c5fcce716a83f1966f972b48d62dd5589d14ff",
34
+ "bridge/lifecycle/runtime.ts": "80fdf306f37ce725c34ea1f32a4efb270019d115d64664f452a2ceddce48e2d1",
35
+ "bridge/media/save-photo.android.ts": "774710166005503b3cdbbd82ccd2d8441f72eca8fff46cd030aa6b94b9949507",
36
+ "bridge/media/save-photo.ios.ts": "197bd8178509bc553ef359e9fa9ef333c20102af2cba6abd459631aa942ae334",
37
+ "bridge/media/save-photo.ts": "ed7ea4e23ac3028f5be8e6d866ac809a5ecc32d9f799329623d6457094faf479",
38
+ "bridge/motion/android-reading.ts": "b63543f70f9ab2b9e0fd33a9c5da0de8eeffffe19f6e046f04450e79a95b474d",
39
+ "bridge/motion/device-motion.android.ts": "81e2eee84f1a55c0166a2628ea1375a1e62c79593083cdf80474ddf05569ef09",
40
+ "bridge/motion/device-motion.ios.ts": "ff16f0e6d23a05e36d0a909d990d7507c0c0c6fadcf17df23d35d6ef44332d00",
41
+ "bridge/motion/device-motion.ts": "f2806d66f328e5b2a090ad8a49c50348f90c334efb873e4627c2845d9b9f9d37",
42
+ "bridge/motion/motion-sample.ts": "0308435ec6e2f3a0ebaa6ab7b63110fe7a9eed25e75b18b8873a719ed996339e",
43
+ "bridge/render/canvas-textures.ios.ts": "3bf00a5d2df470c5e658b7067b80c572160a915ef35e5421555c1cdfd066f695",
44
+ "bridge/render/canvas-textures.ts": "bf5723aef9abd87ef6335d8eb81bb9605d25f325edbc4512dfdf04bd65859e3b",
45
+ "bridge/render/device-descriptor.ts": "4c5afc4e61a778224f3e0db06573c6e0dfe86adc6fce7bf0f2a23f4a2991226f",
46
+ "bridge/render/frame-capture.android.ts": "1373a9494b6392d81f52f3da59e8e5246cc1f6049328e1beac8a7f175bfb2edd",
47
+ "bridge/render/frame-capture.ios.ts": "933bf4dca2a1797d2b95c5318d56a5e48c1fe68932998869ac833bcc75096825",
48
+ "bridge/render/frame-capture.ts": "c8656c56c5cbc982e5927067ea84344832ab0f2f1ca48c21628cda5bf02d8c57",
49
+ "bridge/render/queue-fence.android.ts": "a1100c6382c5875a6d2e7019838c0dc6dd2dd2a769d4920195d507e66a5735f5",
50
+ "bridge/render/queue-fence.ts": "2b29e9159e4a3f4b2610224e4f90c66c5c47d32ee2d283f1cd63baae50aed460",
51
+ "bridge/render/surface.ts": "4f7f06d3c00aa48b21bf4572d8d100487dc964f658ff85a16509339bed531adb",
52
+ "bridge/render/view-capture.android.ts": "44c0ac5a339d27b9e6b2f6f50c52f255fa442a16a0677c615c84b0e4a3b073de",
53
+ "bridge/render/view-capture.ios.ts": "1467288df2683ebbb09a3354f0b69862e7d7aa32ed09aeb4426144c2831fc8a8",
54
+ "bridge/render/view-capture.ts": "e757b53a2d9aa9813df4a10df5fecd084cba0b130dbd5571285451f055b83ef7",
55
+ "bridge/render/view-rect.android.ts": "314fa775526945e71ef9441ae073d9a5e24844bd686b50e23ab682dd582e35d9",
56
+ "bridge/render/view-rect.ios.ts": "ae1186afde51d4053e3e609feb64aba1da585984696dfa57e90a9aa821a6d7ed",
57
+ "bridge/render/view-rect.ts": "b92b7bb11c7cdd8395f51fe1a1b243e9147ab401788c16a7068f10e405332150",
58
+ "bridge/render/webgpu-constants.ts": "279bafa27ac5c7ddb320c2e14440e6db25f9cb6ca79ea3cf21fd2a49f5266426",
59
+ "bridge/rewards/admob.ts": "dbbc95ed922e71d33ff89afa6759252316c0f8caf64c1cad13ea55f2863d40ab",
60
+ "bridge/rewards/controller.ts": "2b28e742d2f10df0930145db765a561a47ba32a98bf97a8fd187bb4ad3dfa9d2",
61
+ "bridge/rewards/native/android/org/haiyue/rewards/HYRewardedAds.java": "d29fd1e0b30af7b1b3211fe84c8dba7763905cb8a47429a44bbbdd9f792f9dc3",
62
+ "bridge/rewards/native/ios/HYRewardedAds.swift": "4d081876cbc60c19321cf61df98ae83232969bd9d75c558e32764e57daf2c2a1",
63
+ "bridge/storage/clone-runtime.ts": "9bbd3693444c26473dac2b8060d107bd2bd54a455a91c163c391bfdc4dd66abb",
64
+ "bridge/storage/settings-storage.ts": "cdf89d04154304911553c3e0b21591a95d551b2f92a9926cb0ffba057e9b975a"
65
+ }
66
+ }