@driftengine/package 3.61.0 → 3.61.1

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 (41) 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 +6 -2
File without changes
File without changes
@@ -0,0 +1,2 @@
1
+ #Tue Sep 01 09:58:03 CEST 2026
2
+ gradle.version=8.9
File without changes
@@ -0,0 +1,91 @@
1
+ plugins {
2
+ id("com.android.application")
3
+ }
4
+
5
+ /*
6
+ * Every value a game supplies comes in as a Gradle property, passed by the packager from
7
+ * `drift.package.json`. Defaults are here so the template builds on its own — which is what makes
8
+ * it possible to check that it compiles without a consumer — and are visibly placeholders.
9
+ */
10
+ val driftAppId: String = (project.findProperty("driftAppId") as String?) ?: "dev.driftengine.host"
11
+ val driftAppName: String = (project.findProperty("driftAppName") as String?) ?: "DriftEngine Host"
12
+ val driftVersionName: String = (project.findProperty("driftVersionName") as String?) ?: "0.0.0"
13
+ val driftVersionCode: Int = ((project.findProperty("driftVersionCode") as String?) ?: "1").toInt()
14
+
15
+ /*
16
+ * Signing comes in as properties too, and never from a file in this template. Android will not
17
+ * install an unsigned APK at all, so there is no unsigned option to offer: the packager either
18
+ * passes a keystore somebody owns or one it generated, and it says which on every build.
19
+ */
20
+ val driftKeystore: String? = project.findProperty("driftKeystore") as String?
21
+
22
+ android {
23
+ namespace = "dev.driftengine.host"
24
+ compileSdk = 35
25
+
26
+ defaultConfig {
27
+ applicationId = driftAppId
28
+ /*
29
+ * 26 is Android 8.0. Below it the WebView is old enough that WebGL2 is not a safe assumption,
30
+ * and `addDocumentStartJavaScript` — which is how the bridge reaches the page before the
31
+ * game's own scripts — needs a WebView from 2020 anyway.
32
+ */
33
+ minSdk = 26
34
+ targetSdk = 35
35
+ versionCode = driftVersionCode
36
+ versionName = driftVersionName
37
+ resValue("string", "app_name", driftAppName)
38
+ }
39
+
40
+ signingConfigs {
41
+ if (driftKeystore != null) {
42
+ create("drift") {
43
+ storeFile = file(driftKeystore)
44
+ storePassword = (project.findProperty("driftKeystorePassword") as String?) ?: ""
45
+ keyAlias = (project.findProperty("driftKeyAlias") as String?) ?: "drift"
46
+ keyPassword = (project.findProperty("driftKeyPassword") as String?) ?: ""
47
+ }
48
+ }
49
+ }
50
+
51
+ buildTypes {
52
+ /*
53
+ * **Debuggable is off in both.** A release built here is the one a person installs, and a
54
+ * debuggable build lets anything on the device attach to the WebView and read the game's
55
+ * storage. `drift-package` builds the debug variant only so that the signing key can be the
56
+ * throwaway one below; it is not a development build in any other sense.
57
+ */
58
+ getByName("debug") {
59
+ isDebuggable = false
60
+ isMinifyEnabled = false
61
+ }
62
+ getByName("release") {
63
+ isDebuggable = false
64
+ isMinifyEnabled = false
65
+ /* Nothing to shrink: the host is two files, and the game is assets rather than code. */
66
+ if (driftKeystore != null) {
67
+ signingConfig = signingConfigs.getByName("drift")
68
+ }
69
+ }
70
+ }
71
+
72
+ compileOptions {
73
+ sourceCompatibility = JavaVersion.VERSION_17
74
+ targetCompatibility = JavaVersion.VERSION_17
75
+ }
76
+
77
+ /* The game's own bundle is already compressed where it matters, and the container is streamed
78
+ off disk by the loader — an APK that recompresses it would cost memory on every read. */
79
+ androidResources {
80
+ noCompress += listOf("drft", "ktx2", "webp", "png", "jpg", "ogg", "mp3", "wasm")
81
+ }
82
+ }
83
+
84
+ dependencies {
85
+ /*
86
+ * The one dependency, and both halves of it are load-bearing: `WebViewAssetLoader` is what
87
+ * serves the game from an https origin instead of file://, and `WebViewCompat`'s
88
+ * document-start script is the mobile equivalent of the desktop preload.
89
+ */
90
+ implementation("androidx.webkit:webkit:1.12.1")
91
+ }
@@ -0,0 +1,33 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ One activity and no permissions.
4
+
5
+ Nothing here asks for the network, storage, the camera or a location: the game is served from
6
+ the APK's own assets and its saves are a private preferences file. A game that needs the network
7
+ adds INTERNET here through its own build, and that addition is then visible in the store listing
8
+ rather than granted by default.
9
+ -->
10
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
11
+
12
+ <application
13
+ android:label="@string/app_name"
14
+ android:icon="@mipmap/ic_launcher"
15
+ android:hardwareAccelerated="true"
16
+ android:allowBackup="false"
17
+ android:usesCleartextTraffic="false"
18
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen">
19
+
20
+ <activity
21
+ android:name=".MainActivity"
22
+ android:exported="true"
23
+ android:launchMode="singleTask"
24
+ android:screenOrientation="unspecified"
25
+ android:resizeableActivity="true"
26
+ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density">
27
+ <intent-filter>
28
+ <action android:name="android.intent.action.MAIN" />
29
+ <category android:name="android.intent.category.LAUNCHER" />
30
+ </intent-filter>
31
+ </activity>
32
+ </application>
33
+ </manifest>
@@ -0,0 +1,334 @@
1
+ package dev.driftengine.host;
2
+
3
+ import android.content.ContentResolver;
4
+ import android.content.ContentValues;
5
+ import android.content.Context;
6
+ import android.content.Intent;
7
+ import android.content.SharedPreferences;
8
+ import android.net.Uri;
9
+ import android.os.Build;
10
+ import android.os.Environment;
11
+ import android.provider.MediaStore;
12
+ import android.util.Base64;
13
+ import android.util.Log;
14
+ import android.widget.Toast;
15
+ import android.util.DisplayMetrics;
16
+ import android.view.Display;
17
+ import android.webkit.JavascriptInterface;
18
+ import android.webkit.WebView;
19
+
20
+ import org.json.JSONArray;
21
+ import org.json.JSONObject;
22
+
23
+ import java.io.File;
24
+ import java.io.FileOutputStream;
25
+ import java.io.OutputStream;
26
+ import java.util.Map;
27
+
28
+ /**
29
+ * The half of the bridge that Android can actually expose.
30
+ *
31
+ * <p><b>Flat, synchronous, and strings only.</b> {@code @JavascriptInterface} can pass strings,
32
+ * numbers and booleans and nothing else — no promise, no object, no callback — so every compound
33
+ * answer here is JSON and {@code bridgeShim.ts} on the other side turns this into the same
34
+ * {@code DriftHostBridge} the desktop shell exposes. The engine sees one seam with three shells
35
+ * behind it rather than three seams.
36
+ *
37
+ * <p><b>Everything that touches a window runs on the UI thread.</b> A {@code @JavascriptInterface}
38
+ * method is called on a WebView worker thread, and touching a {@link android.view.Window} from
39
+ * there throws — which reaches the page as a rejected call rather than as the crash it is.
40
+ */
41
+ public final class HostBridge {
42
+ private static final String TAG = "drift";
43
+ private static final String STORE = "drift-store";
44
+
45
+ private final MainActivity activity;
46
+ private final WebView web;
47
+ private final SharedPreferences store;
48
+ private volatile boolean fullscreen = true;
49
+
50
+ /* One save at a time: a game exports a clip, not four at once, and a second `saveBegin`
51
+ abandons the first rather than interleaving two files into one stream. */
52
+ private OutputStream saveStream = null;
53
+ private Uri saveUri = null;
54
+ private File saveFile = null;
55
+ private String saveName = "";
56
+
57
+ HostBridge(MainActivity activity, WebView web) {
58
+ this.activity = activity;
59
+ this.web = web;
60
+ this.store = activity.getSharedPreferences(STORE, Context.MODE_PRIVATE);
61
+ }
62
+
63
+ @JavascriptInterface
64
+ public String snapshot() {
65
+ JSONObject out = new JSONObject();
66
+ try {
67
+ for (Map.Entry<String, ?> entry : store.getAll().entrySet()) {
68
+ Object value = entry.getValue();
69
+ if (value instanceof String) out.put(entry.getKey(), (String) value);
70
+ }
71
+ } catch (Exception ignored) {
72
+ /* A store that will not serialise is a store the game starts without, which is the same
73
+ degradation every other implementation of this seam makes. */
74
+ }
75
+ return out.toString();
76
+ }
77
+
78
+ @JavascriptInterface
79
+ public void write(String key, String value) {
80
+ store.edit().putString(key, value).apply();
81
+ }
82
+
83
+ @JavascriptInterface
84
+ public void remove(String key) {
85
+ store.edit().remove(key).apply();
86
+ }
87
+
88
+ @JavascriptInterface
89
+ public boolean isFullscreen() {
90
+ return fullscreen;
91
+ }
92
+
93
+ @JavascriptInterface
94
+ public void setFullscreen(boolean on) {
95
+ fullscreen = on;
96
+ activity.runOnUiThread(() -> activity.applyImmersive(on));
97
+ }
98
+
99
+ @JavascriptInterface
100
+ public String mode() {
101
+ return fullscreen ? "fullscreen" : "windowed";
102
+ }
103
+
104
+ @JavascriptInterface
105
+ public boolean setMode(String mode) {
106
+ setFullscreen(!"windowed".equals(mode));
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * The display's real refresh rate, which is the whole point of having a shell.
112
+ *
113
+ * <p>A browser cannot report this at all and answers null. A phone knows, and on a phone it is
114
+ * worth knowing: 60, 90 and 120 are all common on the same model line.
115
+ */
116
+ @JavascriptInterface
117
+ public float refreshHz() {
118
+ Display display = activity.getWindowManager().getDefaultDisplay();
119
+ return display == null ? 0f : display.getRefreshRate();
120
+ }
121
+
122
+ @JavascriptInterface
123
+ public String size() {
124
+ DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
125
+ JSONObject out = new JSONObject();
126
+ try {
127
+ /* Logical pixels, which is what the page measures itself in — the drawing surface in device
128
+ pixels is `width * density` and belongs to the renderer rather than to this. */
129
+ out.put("width", Math.round(metrics.widthPixels / metrics.density));
130
+ out.put("height", Math.round(metrics.heightPixels / metrics.density));
131
+ } catch (Exception ignored) {
132
+ // An unserialisable size is not a reason to fail a boot.
133
+ }
134
+ return out.toString();
135
+ }
136
+
137
+ /**
138
+ * Always false: a phone has no window to size.
139
+ *
140
+ * <p>Reporting the refusal is what lets a settings screen grey the control out, instead of
141
+ * offering a resolution list that silently does nothing. A game that wants fewer pixels on a
142
+ * phone changes its render scale, which is the engine's own {@code RenderQuality}.
143
+ */
144
+ @JavascriptInterface
145
+ public boolean setSize(int width, int height) {
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Whether {@code setSize} would do anything, asked without performing one.
151
+ *
152
+ * <p>The same answer and the same reason, so a settings screen can grey its control while it is
153
+ * drawing rather than by resizing a window to find out. It is answered here beside
154
+ * {@code setSize} on purpose: the day an Android host gains a resizable window — a foldable, a
155
+ * desktop mode — the two move together, and a constant in the JavaScript shim would not have.
156
+ */
157
+ @JavascriptInterface
158
+ public boolean canSetSize() {
159
+ return false;
160
+ }
161
+
162
+ @JavascriptInterface
163
+ public String displays() {
164
+ DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
165
+ JSONArray out = new JSONArray();
166
+ try {
167
+ JSONObject one = new JSONObject();
168
+ one.put("id", "0");
169
+ one.put("label", "built-in");
170
+ one.put("width", Math.round(metrics.widthPixels / metrics.density));
171
+ one.put("height", Math.round(metrics.heightPixels / metrics.density));
172
+ one.put("scale", metrics.density);
173
+ float hz = refreshHz();
174
+ one.put("refreshHz", hz > 0f ? (Object) hz : JSONObject.NULL);
175
+ one.put("primary", true);
176
+ out.put(one);
177
+ } catch (Exception ignored) {
178
+ // As above.
179
+ }
180
+ return out.toString();
181
+ }
182
+
183
+ /**
184
+ * True: an Android activity can end itself, and {@code finish()} is how.
185
+ *
186
+ * <p>A game reads this before it draws an Exit button. iOS answers false — applications there do
187
+ * not exit programmatically, and Apple's guidance is that they should not — so the same menu
188
+ * shows the button on one platform and not the other without knowing which it is on.
189
+ */
190
+ @JavascriptInterface
191
+ public boolean canQuit() {
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * Writing a file the player can find afterwards, in three calls.
197
+ *
198
+ * <p><b>This is what makes a clip export work at all here.</b> A game ends an export with an
199
+ * anchor whose href is a `blob:` URL — the thing every browser understands — and a WebView with
200
+ * no download listener drops it silently: the player taps save and nothing happens, with no
201
+ * error anywhere. The listener in {@link MainActivity} catches it, the page reads its own blob,
202
+ * and the bytes arrive here in chunks because the only thing this bridge can carry is a string.
203
+ *
204
+ * <p><b>MediaStore on API 29 and up, and no permission is asked for.</b> A file written through
205
+ * it lands in the device's Downloads and is visible to every gallery and file manager. Below 29
206
+ * that API cannot write outside the app without {@code WRITE_EXTERNAL_STORAGE}, and asking a
207
+ * player for storage permission to save their own clip is a prompt nobody should have to
208
+ * answer — so those devices get the app's own external files directory, which needs nothing and
209
+ * is still reachable through a file manager. Stated because the two are genuinely different
210
+ * places and somebody will go looking.
211
+ */
212
+ @JavascriptInterface
213
+ public boolean saveBegin(String name, String mime) {
214
+ saveAbort();
215
+ String safe = (name == null || name.isEmpty()) ? "download" : name.replaceAll("[/\\\\]", "_");
216
+ try {
217
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
218
+ ContentResolver resolver = activity.getContentResolver();
219
+ ContentValues values = new ContentValues();
220
+ values.put(MediaStore.Downloads.DISPLAY_NAME, safe);
221
+ values.put(MediaStore.Downloads.MIME_TYPE, mime == null ? "application/octet-stream" : mime);
222
+ /* Pending until the last chunk lands, so nothing indexes half a file. */
223
+ values.put(MediaStore.Downloads.IS_PENDING, 1);
224
+ saveUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
225
+ if (saveUri == null) return false;
226
+ saveStream = resolver.openOutputStream(saveUri);
227
+ } else {
228
+ File dir = activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
229
+ if (dir == null || (!dir.exists() && !dir.mkdirs())) return false;
230
+ saveFile = new File(dir, safe);
231
+ saveStream = new FileOutputStream(saveFile);
232
+ }
233
+ saveName = safe;
234
+ return saveStream != null;
235
+ } catch (Exception cause) {
236
+ Log.e(TAG, "could not start a save", cause);
237
+ saveAbort();
238
+ return false;
239
+ }
240
+ }
241
+
242
+ @JavascriptInterface
243
+ public boolean saveChunk(String base64) {
244
+ if (saveStream == null || base64 == null) return false;
245
+ try {
246
+ saveStream.write(Base64.decode(base64, Base64.DEFAULT));
247
+ return true;
248
+ } catch (Exception cause) {
249
+ Log.e(TAG, "could not write a chunk", cause);
250
+ saveAbort();
251
+ return false;
252
+ }
253
+ }
254
+
255
+ @JavascriptInterface
256
+ public String saveEnd() {
257
+ if (saveStream == null) return "";
258
+ String where;
259
+ try {
260
+ saveStream.flush();
261
+ saveStream.close();
262
+ if (saveUri != null) {
263
+ ContentValues done = new ContentValues();
264
+ done.put(MediaStore.Downloads.IS_PENDING, 0);
265
+ activity.getContentResolver().update(saveUri, done, null, null);
266
+ where = "Downloads/" + saveName;
267
+ } else {
268
+ where = saveFile == null ? saveName : saveFile.getAbsolutePath();
269
+ }
270
+ } catch (Exception cause) {
271
+ Log.e(TAG, "could not finish a save", cause);
272
+ saveAbort();
273
+ return "";
274
+ }
275
+ saveStream = null;
276
+ saveUri = null;
277
+ saveFile = null;
278
+ final String message = where;
279
+ /* Told rather than left to be discovered: a save with no feedback is a save the player
280
+ repeats, and then there are four copies of the same clip. */
281
+ activity.runOnUiThread(
282
+ () -> Toast.makeText(activity, "Saved to " + message, Toast.LENGTH_LONG).show());
283
+ return where;
284
+ }
285
+
286
+ @JavascriptInterface
287
+ public void saveAbort() {
288
+ try {
289
+ if (saveStream != null) saveStream.close();
290
+ } catch (Exception ignored) {
291
+ // Closing a stream that already failed is not worth reporting.
292
+ }
293
+ saveStream = null;
294
+ if (saveUri != null) {
295
+ try {
296
+ /* A pending entry left behind is a zero-byte row in the player's Downloads for ever. */
297
+ activity.getContentResolver().delete(saveUri, null, null);
298
+ } catch (Exception ignored) {
299
+ // Nothing better to do.
300
+ }
301
+ saveUri = null;
302
+ }
303
+ if (saveFile != null) {
304
+ // The half-written file is deleted for the same reason.
305
+ saveFile.delete();
306
+ saveFile = null;
307
+ }
308
+ }
309
+
310
+ @JavascriptInterface
311
+ public void requestQuit() {
312
+ activity.runOnUiThread(activity::finish);
313
+ }
314
+
315
+ /**
316
+ * Open a link in the person's own browser.
317
+ *
318
+ * <p>The same rule the desktop shell follows and for the same reasons: their browser has their
319
+ * session and a way back, and a WebView with no address bar showing somebody else's page is a
320
+ * browser nobody is maintaining. The scheme filter is on the JavaScript side, in {@code
321
+ * links.ts}, so both shells refuse the same set.
322
+ */
323
+ @JavascriptInterface
324
+ public void openExternal(String url) {
325
+ try {
326
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
327
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
328
+ activity.startActivity(intent);
329
+ } catch (Exception ignored) {
330
+ /* No application to handle it, or a malformed URL. Nothing opens, and a game that cannot
331
+ open a link is not a game that should stop. */
332
+ }
333
+ }
334
+ }