@driftengine/package 3.61.0 → 3.61.2

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 (42) hide show
  1. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  2. package/android/.gradle/8.9/checksums/md5-checksums.bin +0 -0
  3. package/android/.gradle/8.9/checksums/sha1-checksums.bin +0 -0
  4. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  5. package/android/.gradle/8.9/executionHistory/executionHistory.lock +0 -0
  6. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  7. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  8. package/android/.gradle/8.9/gc.properties +0 -0
  9. package/android/.gradle/9.2.0/checksums/checksums.lock +0 -0
  10. package/android/.gradle/9.2.0/checksums/md5-checksums.bin +0 -0
  11. package/android/.gradle/9.2.0/checksums/sha1-checksums.bin +0 -0
  12. package/android/.gradle/9.2.0/fileChanges/last-build.bin +0 -0
  13. package/android/.gradle/9.2.0/fileHashes/fileHashes.bin +0 -0
  14. package/android/.gradle/9.2.0/fileHashes/fileHashes.lock +0 -0
  15. package/android/.gradle/9.2.0/gc.properties +0 -0
  16. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  17. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  18. package/android/.gradle/vcs-1/gc.properties +0 -0
  19. package/android/app/build.gradle.kts +91 -0
  20. package/android/app/src/main/AndroidManifest.xml +33 -0
  21. package/android/app/src/main/java/dev/driftengine/host/HostBridge.java +334 -0
  22. package/android/app/src/main/java/dev/driftengine/host/MainActivity.java +347 -0
  23. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  24. package/android/app/src/main/res/values/strings.xml +11 -0
  25. package/android/build/reports/problems/problems-report.html +659 -0
  26. package/android/build.gradle.kts +3 -0
  27. package/android/gradle.properties +4 -0
  28. package/android/settings.gradle.kts +24 -0
  29. package/assets/icon.png +0 -0
  30. package/assets/lockup.svg +45 -0
  31. package/assets/mark-plain.svg +23 -0
  32. package/assets/mark-small.svg +22 -0
  33. package/assets/mark.svg +24 -0
  34. package/assets/originProbe.html +117 -0
  35. package/assets/splash.html +119 -0
  36. package/ios/DriftHost/AppDelegate.swift +23 -0
  37. package/ios/DriftHost/HostBridge.swift +159 -0
  38. package/ios/DriftHost/SchemeHandler.swift +130 -0
  39. package/ios/DriftHost/ViewController.swift +126 -0
  40. package/ios/project.yml +57 -0
  41. package/package.json +7 -2
  42. package/scripts/build-macos.sh +71 -0
@@ -0,0 +1,347 @@
1
+ package dev.driftengine.host;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Build;
5
+ import android.os.Bundle;
6
+ import android.os.SystemClock;
7
+ import android.util.Log;
8
+ import android.view.View;
9
+ import android.view.Window;
10
+ import android.view.WindowInsets;
11
+ import android.view.WindowInsetsController;
12
+ import android.view.WindowManager;
13
+ import android.widget.Toast;
14
+ import android.os.Message;
15
+ import android.webkit.WebChromeClient;
16
+ import android.webkit.WebResourceRequest;
17
+ import android.webkit.WebResourceResponse;
18
+ import android.webkit.WebView;
19
+ import android.webkit.WebViewClient;
20
+ import android.webkit.URLUtil;
21
+
22
+ import androidx.webkit.WebViewAssetLoader;
23
+ import androidx.webkit.WebViewCompat;
24
+ import androidx.webkit.WebViewFeature;
25
+
26
+ import java.io.ByteArrayOutputStream;
27
+ import java.io.InputStream;
28
+ import java.util.Collections;
29
+
30
+ /**
31
+ * One activity, one WebView, and nothing else.
32
+ *
33
+ * <p><b>The game is served from an https origin, not from file://.</b> ES modules, {@code fetch}
34
+ * and workers all need an origin and {@code file://} is not one — a game loaded that way fails to
35
+ * import its own modules, which reads as a broken build rather than as a missing origin.
36
+ * {@link WebViewAssetLoader} maps {@code https://appassets.androidplatform.net/assets/} onto the
37
+ * APK's assets, and an https origin is a secure context by definition, so
38
+ * {@code crossOriginIsolated} and the APIs that depend on it are available without a loopback
39
+ * server listening inside a game.
40
+ *
41
+ * <p><b>The bridge is injected before the game's own scripts run.</b> That is what
42
+ * {@code addDocumentStartJavaScript} is for, and it is the mobile equivalent of the desktop
43
+ * shell's preload. Where the WebView is too old to support it the game still runs — it simply
44
+ * finds no bridge and uses the browser implementations of every capability, which is the same
45
+ * thing it does in a tab.
46
+ *
47
+ * <p>Written in Java rather than Kotlin deliberately: the host is two files of glue, it needs no
48
+ * language feature Kotlin has, and dropping the Kotlin plugin removes a toolchain download from
49
+ * every machine that ever builds this.
50
+ */
51
+ public final class MainActivity extends Activity {
52
+ private static final String TAG = "drift";
53
+ private static final String ORIGIN = "https://appassets.androidplatform.net";
54
+
55
+ /*
56
+ * In a directory of its own, because the game's own files sit at the asset root: a shim called
57
+ * `drift-shim.js` there would be one filename away from being overwritten by a game that has one.
58
+ */
59
+ private static final String SHIM_ASSET = "drift-host/shim.js";
60
+
61
+ /** How long a first back press counts for. Two seconds is the convention and it feels right. */
62
+ private static final long DOUBLE_BACK_MS = 2000L;
63
+
64
+ private WebView web;
65
+ private HostBridge bridge;
66
+ private long lastBackAt = 0L;
67
+
68
+ @Override
69
+ protected void onCreate(Bundle state) {
70
+ super.onCreate(state);
71
+
72
+ /*
73
+ * **Mounted at the root, not at a subdirectory, and that is not a detail.** A web build
74
+ * references its own files absolutely — `/assets/index-abc.js` is what every Vite build emits
75
+ * — so a game served from `/assets/www/` has every one of those resolve to nothing: the HTML
76
+ * loads, the CSS and the script do not, and the page appears as unstyled text with no console
77
+ * open to say why. Measured on a real device before it was fixed.
78
+ */
79
+ WebViewAssetLoader loader =
80
+ new WebViewAssetLoader.Builder()
81
+ .addPathHandler("/", new WebViewAssetLoader.AssetsPathHandler(this))
82
+ .build();
83
+
84
+ web = new WebView(this);
85
+ web.getSettings().setJavaScriptEnabled(true);
86
+ web.getSettings().setDomStorageEnabled(true);
87
+ /* A game's own audio is not an autoplaying advert, and the engine's mixer starts on the first
88
+ input anyway — so the gesture requirement only ever delays the first sound by one tap. */
89
+ web.getSettings().setMediaPlaybackRequiresUserGesture(false);
90
+ /* Without this a `target="_blank"` link does nothing at all — no navigation, no window, no
91
+ error — which is indistinguishable from a broken link. With it, the request arrives at
92
+ onCreateWindow below and is sent to the person's browser. */
93
+ web.getSettings().setSupportMultipleWindows(true);
94
+ web.setWebViewClient(new LocalContentClient(loader, this));
95
+ web.setWebChromeClient(new ExternalWindowClient(this));
96
+ /*
97
+ * **A WebView with no download listener drops a download silently**, and that is what a clip
98
+ * export is: `offerClip` ends in an anchor with a `download` attribute pointing at a blob URL,
99
+ * which every browser understands and this does not. The player taps save and nothing happens
100
+ * at all — no dialog, no file, no error. Caught here and completed by the page, which is the
101
+ * only thing that can read its own blob.
102
+ */
103
+ web.setDownloadListener(
104
+ (url, userAgent, contentDisposition, mimeType, contentLength) ->
105
+ startDownload(url, contentDisposition, mimeType));
106
+
107
+ bridge = new HostBridge(this, web);
108
+ web.addJavascriptInterface(bridge, "__driftHostNative");
109
+
110
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
111
+ String shim = readAsset(SHIM_ASSET);
112
+ if (shim != null) {
113
+ WebViewCompat.addDocumentStartJavaScript(web, shim, Collections.singleton(ORIGIN));
114
+ }
115
+ } else {
116
+ /* Loud rather than silent: the game will run and will believe it is in a plain browser,
117
+ which is a defined state and a surprising one to debug from the outside. */
118
+ Log.w(TAG, "this WebView cannot run a document-start script; the host bridge is absent");
119
+ }
120
+
121
+ setContentView(web);
122
+ /* The screen stays on: a game is watched rather than read, and a player holding a controller
123
+ touches nothing for minutes at a time. */
124
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
125
+ applyImmersive(true);
126
+
127
+ web.loadUrl(ORIGIN + "/index.html");
128
+ }
129
+
130
+ /**
131
+ * Hide or show the system bars. Immersive is the state a game starts in.
132
+ *
133
+ * <p>Not called {@code setImmersive}: {@link Activity} already has one, with a different meaning
134
+ * and a wider visibility, and overriding it by accident is a compile error at best.
135
+ */
136
+ void applyImmersive(boolean on) {
137
+ Window window = getWindow();
138
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
139
+ window.setDecorFitsSystemWindows(!on);
140
+ WindowInsetsController controller = window.getInsetsController();
141
+ if (controller != null) {
142
+ if (on) {
143
+ controller.hide(WindowInsets.Type.systemBars());
144
+ controller.setSystemBarsBehavior(
145
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
146
+ } else {
147
+ controller.show(WindowInsets.Type.systemBars());
148
+ }
149
+ }
150
+ return;
151
+ }
152
+ /* API 26 to 29. Deprecated on newer releases and the only thing that works on these. */
153
+ int flags =
154
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE
155
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
156
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
157
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
158
+ | View.SYSTEM_UI_FLAG_FULLSCREEN
159
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
160
+ window.getDecorView().setSystemUiVisibility(on ? flags : View.SYSTEM_UI_FLAG_VISIBLE);
161
+ }
162
+
163
+ @Override
164
+ protected void onResume() {
165
+ super.onResume();
166
+ pushFocus(true);
167
+ }
168
+
169
+ @Override
170
+ protected void onPause() {
171
+ /* Told on the way out rather than the way back, so a game can pause its simulation and mute
172
+ its mixer while the person is somewhere else. */
173
+ pushFocus(false);
174
+ super.onPause();
175
+ }
176
+
177
+ /**
178
+ * Back is offered to the game before it is allowed to close anything.
179
+ *
180
+ * <p><b>Closing outright is how a run is lost by a thumb.</b> There is no window manager on this
181
+ * platform asking whether you meant it, and a game does not get a chance to save. So the gesture
182
+ * goes to the page first — {@code back()} in the shim answers a registered quit handler, or
183
+ * failing that turns it into the Escape key a pause menu is almost always bound to — and only
184
+ * when the page says it did nothing does the host consider closing.
185
+ *
186
+ * <p><b>And then it asks twice.</b> Two presses inside two seconds, with a line on screen
187
+ * between them: the convention every Android game uses, because the alternative is a single
188
+ * gesture that ends a session with no way back.
189
+ *
190
+ * <p>It walks no history. In a single-page game that history is the game's own routing, and
191
+ * stepping back through it is never what the gesture meant.
192
+ */
193
+ @Override
194
+ public void onBackPressed() {
195
+ if (web == null) {
196
+ finish();
197
+ return;
198
+ }
199
+ web.evaluateJavascript(
200
+ "String(Boolean(globalThis.__driftHostEvents && globalThis.__driftHostEvents.back()))",
201
+ value -> {
202
+ if ("\"true\"".equals(value) || "true".equals(value)) return;
203
+ confirmExit();
204
+ });
205
+ }
206
+
207
+ /** Two presses inside {@link #DOUBLE_BACK_MS}, with a line on screen between them. */
208
+ private void confirmExit() {
209
+ long now = SystemClock.elapsedRealtime();
210
+ if (now - lastBackAt < DOUBLE_BACK_MS) {
211
+ finish();
212
+ return;
213
+ }
214
+ lastBackAt = now;
215
+ Toast.makeText(this, R.string.press_back_again, Toast.LENGTH_SHORT).show();
216
+ }
217
+
218
+ private void pushFocus(boolean focused) {
219
+ if (web == null) return;
220
+ web.evaluateJavascript(
221
+ "globalThis.__driftHostEvents && globalThis.__driftHostEvents.focus(" + focused + ")", null);
222
+ }
223
+
224
+ /**
225
+ * Send a link to the person's own browser, if it is one of the three schemes that may leave.
226
+ *
227
+ * <p>The same rule and the same three schemes as the desktop shell's {@code links.ts}: a
228
+ * {@code file:} or a handler-registered scheme would hand the operating system a document or an
229
+ * application with an argument, chosen by a page.
230
+ */
231
+ void openExternal(String url) {
232
+ if (url == null) return;
233
+ String lower = url.toLowerCase();
234
+ boolean allowed =
235
+ lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:");
236
+ if (!allowed) return;
237
+ if (bridge != null) bridge.openExternal(url);
238
+ }
239
+
240
+ /**
241
+ * Complete a download the WebView reported.
242
+ *
243
+ * <p>A `blob:` URL belongs to the page and cannot be read from here, so it goes back to the page
244
+ * and returns as bytes. Anything with a real scheme is somebody's server, and that is the
245
+ * browser's job rather than this application's.
246
+ */
247
+ private void startDownload(String url, String contentDisposition, String mimeType) {
248
+ if (url == null) return;
249
+ if (!url.startsWith("blob:") && !url.startsWith("data:")) {
250
+ openExternal(url);
251
+ return;
252
+ }
253
+ String name = guessFileName(url, contentDisposition, mimeType);
254
+ String script =
255
+ "globalThis.__driftHostEvents && globalThis.__driftHostEvents.saveBlob("
256
+ + jsString(url)
257
+ + ","
258
+ + jsString(name)
259
+ + ")";
260
+ web.evaluateJavascript(script, null);
261
+ }
262
+
263
+ /** What the file is called, in the order a browser decides it. */
264
+ private static String guessFileName(String url, String contentDisposition, String mimeType) {
265
+ try {
266
+ String guessed = URLUtil.guessFileName(url, contentDisposition, mimeType);
267
+ if (guessed != null && !guessed.isEmpty()) return guessed;
268
+ } catch (Exception ignored) {
269
+ // A blob URL carries no name, which is the common case and not an error.
270
+ }
271
+ return "download";
272
+ }
273
+
274
+ /** A JavaScript string literal, quoted and escaped, because this is built by concatenation. */
275
+ private static String jsString(String value) {
276
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
277
+ }
278
+
279
+ private String readAsset(String name) {
280
+ try (InputStream in = getAssets().open(name)) {
281
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
282
+ byte[] buffer = new byte[8192];
283
+ int read;
284
+ while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read);
285
+ return out.toString("UTF-8");
286
+ } catch (Exception cause) {
287
+ Log.e(TAG, "could not read " + name, cause);
288
+ return null;
289
+ }
290
+ }
291
+
292
+ @Override
293
+ protected void onDestroy() {
294
+ if (web != null) web.destroy();
295
+ super.onDestroy();
296
+ }
297
+
298
+ /** Serves the APK's assets through the loader, and sends everything else outside. */
299
+ private static final class LocalContentClient extends WebViewClient {
300
+ private final WebViewAssetLoader loader;
301
+ private final MainActivity host;
302
+
303
+ LocalContentClient(WebViewAssetLoader loader, MainActivity host) {
304
+ this.loader = loader;
305
+ this.host = host;
306
+ }
307
+
308
+ @Override
309
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
310
+ return loader.shouldInterceptRequest(request.getUrl());
311
+ }
312
+
313
+ /**
314
+ * A navigation away from the game opens in the person's browser instead.
315
+ *
316
+ * <p>Left inside the WebView it would replace the game with a web page that has no address
317
+ * bar and no way back — the same reason the desktop shell refuses it.
318
+ */
319
+ @Override
320
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
321
+ String url = request.getUrl().toString();
322
+ if (url.startsWith(ORIGIN)) return false;
323
+ host.openExternal(url);
324
+ return true;
325
+ }
326
+ }
327
+
328
+ /** `target="_blank"` and `window.open`, which arrive here rather than as a navigation. */
329
+ private static final class ExternalWindowClient extends WebChromeClient {
330
+ private final MainActivity host;
331
+
332
+ ExternalWindowClient(MainActivity host) {
333
+ this.host = host;
334
+ }
335
+
336
+ @Override
337
+ public boolean onCreateWindow(
338
+ WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) {
339
+ /* The URL is not in the message — it is on the element that was hit, which is where a
340
+ WebView keeps it for exactly this case. */
341
+ WebView.HitTestResult hit = view.getHitTestResult();
342
+ host.openExternal(hit == null ? null : hit.getExtra());
343
+ /* False: no window is created, and the game carries on where it was. */
344
+ return false;
345
+ }
346
+ }
347
+ }
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ No name here: `app_name` is written by `resValue` in the Gradle file, from the game's own
4
+ manifest. A default would be a second place for it to come from, and the wrong one would ship as
5
+ the label under the icon.
6
+ -->
7
+ <resources>
8
+ <!-- Shown between the two back presses that close a game which handled neither the gesture nor
9
+ the Escape key it becomes. -->
10
+ <string name="press_back_again">Press back again to exit</string>
11
+ </resources>