@driftengine/package 3.61.2 → 3.62.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.
- package/README.md +36 -1
- package/android/app/src/main/java/dev/driftengine/host/MainActivity.java +24 -0
- package/dist/android.js +24 -0
- package/dist/cli.js +13 -0
- package/dist/manifest.d.ts +19 -12
- package/dist/manifest.js +29 -0
- package/dist/mobile/androidManifest.d.ts +41 -0
- package/dist/mobile/androidManifest.js +92 -0
- package/package.json +5 -2
- package/src/android.ts +25 -0
- package/src/cli.ts +19 -0
- package/src/manifest.ts +39 -0
- package/src/mobile/androidManifest.ts +115 -0
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ Beside your `package.json`, in the project that produces the web build:
|
|
|
58
58
|
"features": { "clipExport": false, "gamepad": true },
|
|
59
59
|
"splash": { "show": true, "minMs": 1400 },
|
|
60
60
|
"targets": ["linux-x64", "win-x64", "mac-arm64"],
|
|
61
|
-
"steam": { "appId": null }
|
|
61
|
+
"steam": { "appId": null },
|
|
62
|
+
"android": { "permissions": [], "cleartextTraffic": false }
|
|
62
63
|
}
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -243,6 +244,40 @@ bridge is injected before the game's own scripts through `addDocumentStartJavaSc
|
|
|
243
244
|
mobile equivalent of the desktop preload, so `createHost` finds the same `__driftHost` it finds on
|
|
244
245
|
the desktop.
|
|
245
246
|
|
|
247
|
+
### An APK asks for nothing until a game says so
|
|
248
|
+
|
|
249
|
+
The manifest template declares one activity and **no permissions**, which is the right default: a
|
|
250
|
+
permission is visible in the store listing, and a packager that granted the network to every game
|
|
251
|
+
it ever built would be asking on behalf of games that never use it. A game that needs the network
|
|
252
|
+
says so:
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
"android": { "permissions": ["INTERNET"], "cleartextTraffic": false }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`"INTERNET"` and `"android.permission.INTERNET"` are both accepted, and the elements are written
|
|
259
|
+
into the copied Gradle project before Gradle runs — the same copy the icon is written into, so
|
|
260
|
+
nothing in this repository is touched by a consumer's build. `drift-package doctor` prints what was
|
|
261
|
+
asked for, and `aapt2 dump permissions <apk>` is how to confirm it landed.
|
|
262
|
+
|
|
263
|
+
**Without it every connection fails and nothing says why.** The process is not permitted to open a
|
|
264
|
+
socket, so the game reports that it could not connect and the server logs nothing at all, because
|
|
265
|
+
no packet leaves the phone. That is the same silence on both ends, and it is indistinguishable from
|
|
266
|
+
a wrong address.
|
|
267
|
+
|
|
268
|
+
### Reaching a relay that has no certificate
|
|
269
|
+
|
|
270
|
+
`cleartextTraffic` is the second half, and it is false by default. The game is served from an https
|
|
271
|
+
origin, so a WebView will not open `ws://` from it: a WebView defaults to `MIXED_CONTENT_NEVER_ALLOW`,
|
|
272
|
+
which is stricter than a browser tab, where the same connection succeeds with a deprecation warning
|
|
273
|
+
(measured on Chrome 151 against a LAN address). Setting it true writes
|
|
274
|
+
`android:usesCleartextTraffic="true"` **and** is what `MainActivity` reads back at runtime, through
|
|
275
|
+
`NetworkSecurityPolicy`, to allow mixed content in the WebView. One switch, so the platform and the
|
|
276
|
+
renderer cannot disagree — which they would if only the manifest moved.
|
|
277
|
+
|
|
278
|
+
Set it only for the case it exists for: a relay on a LAN, which cannot hold a certificate for an
|
|
279
|
+
address that is not a name. A public relay should be `wss://` and this should stay false.
|
|
280
|
+
|
|
246
281
|
**Signing is not optional here and there is no unsigned option to offer**: Android refuses to
|
|
247
282
|
install an unsigned APK at all. Without a keystore the packager generates one and says so on every
|
|
248
283
|
build. That APK installs on a device with developer mode on; it cannot go to the Play Store.
|
|
@@ -12,9 +12,11 @@ import android.view.WindowInsetsController;
|
|
|
12
12
|
import android.view.WindowManager;
|
|
13
13
|
import android.widget.Toast;
|
|
14
14
|
import android.os.Message;
|
|
15
|
+
import android.security.NetworkSecurityPolicy;
|
|
15
16
|
import android.webkit.WebChromeClient;
|
|
16
17
|
import android.webkit.WebResourceRequest;
|
|
17
18
|
import android.webkit.WebResourceResponse;
|
|
19
|
+
import android.webkit.WebSettings;
|
|
18
20
|
import android.webkit.WebView;
|
|
19
21
|
import android.webkit.WebViewClient;
|
|
20
22
|
import android.webkit.URLUtil;
|
|
@@ -91,6 +93,28 @@ public final class MainActivity extends Activity {
|
|
|
91
93
|
error — which is indistinguishable from a broken link. With it, the request arrives at
|
|
92
94
|
onCreateWindow below and is sent to the person's browser. */
|
|
93
95
|
web.getSettings().setSupportMultipleWindows(true);
|
|
96
|
+
/*
|
|
97
|
+
* **Mixed content follows the manifest's cleartext policy, read back from the platform.**
|
|
98
|
+
*
|
|
99
|
+
* The game is served from an https origin (see the class comment), so it is a secure context,
|
|
100
|
+
* and a WebView defaults to MIXED_CONTENT_NEVER_ALLOW — stricter than a browser tab, which
|
|
101
|
+
* permits an insecure WebSocket from a secure page with a deprecation warning. A relay on a
|
|
102
|
+
* LAN cannot hold a certificate, so a game that wants one asks for
|
|
103
|
+
* `"android": { "cleartextTraffic": true }` and gets `usesCleartextTraffic="true"` in its
|
|
104
|
+
* manifest. Without this line that flag would be half a permission: Android would allow the
|
|
105
|
+
* socket and the renderer would still refuse it, which is the kind of disagreement nobody can
|
|
106
|
+
* debug from either side.
|
|
107
|
+
*
|
|
108
|
+
* **Read from NetworkSecurityPolicy rather than passed in as a build property**, so there is
|
|
109
|
+
* one source of truth. The policy reflects `usesCleartextTraffic` and any network security
|
|
110
|
+
* config a consumer adds later, so the two cannot drift apart and a scoped config keeps
|
|
111
|
+
* working without this file learning about it.
|
|
112
|
+
*
|
|
113
|
+
* A build that asks for nothing keeps NEVER_ALLOW, which is exactly what it had before.
|
|
114
|
+
*/
|
|
115
|
+
if (NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted()) {
|
|
116
|
+
web.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
|
117
|
+
}
|
|
94
118
|
web.setWebViewClient(new LocalContentClient(loader, this));
|
|
95
119
|
web.setWebChromeClient(new ExternalWindowClient(this));
|
|
96
120
|
/*
|
package/dist/android.js
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { resourceDir } from './resources.js';
|
|
7
7
|
import { missingReferences } from './mobile/assetCheck.js';
|
|
8
|
+
import { withAndroidManifest } from './mobile/androidManifest.js';
|
|
8
9
|
import { versionCodeFor } from './mobile/versionCode.js';
|
|
9
10
|
import { androidToolchain, toolchainHome } from './toolchain.js';
|
|
10
11
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -61,6 +62,29 @@ export async function buildAndroid(manifest, projectDir, outDir, options = {}) {
|
|
|
61
62
|
target: 'es2022',
|
|
62
63
|
logLevel: 'warning',
|
|
63
64
|
});
|
|
65
|
+
/*
|
|
66
|
+
* **The manifest is rewritten in the copy, before Gradle reads it.** The same place and the same
|
|
67
|
+
* reason as the icon below: the template is staged into `out/`, so a consumer's permissions never
|
|
68
|
+
* touch this repository's tree. See `mobile/androidManifest.ts` for what an empty request does,
|
|
69
|
+
* which is nothing at all.
|
|
70
|
+
*/
|
|
71
|
+
const manifestPath = join(projectRoot, 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
72
|
+
const templateXml = await readFile(manifestPath, 'utf8');
|
|
73
|
+
const withPermissions = withAndroidManifest(templateXml, manifest.android);
|
|
74
|
+
if (withPermissions !== templateXml) {
|
|
75
|
+
await writeFile(manifestPath, withPermissions, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
if (manifest.android.permissions.length > 0) {
|
|
78
|
+
console.log(`[drift-package] android: permissions ${manifest.android.permissions.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
if (manifest.android.cleartextTraffic) {
|
|
81
|
+
/*
|
|
82
|
+
* Said out loud because it is a security posture rather than a setting: the build now permits
|
|
83
|
+
* `http://` and `ws://`, and `MainActivity` reads the same flag to allow mixed content in the
|
|
84
|
+
* WebView. A consumer who did not mean it should see it in the build log.
|
|
85
|
+
*/
|
|
86
|
+
console.log('[drift-package] android: cleartext traffic permitted (http:// and ws:// allowed)');
|
|
87
|
+
}
|
|
64
88
|
if (manifest.icon !== null) {
|
|
65
89
|
/* One PNG at one density: Android scales it for the rest, and a game that wants a full set
|
|
66
90
|
supplies its own `res/` through its own build rather than through this. */
|
package/dist/cli.js
CHANGED
|
@@ -102,6 +102,19 @@ async function doctor(cwd, options = {}) {
|
|
|
102
102
|
console.log(` ${target}: signing ${signing.mode} — ${signing.reason}`);
|
|
103
103
|
}
|
|
104
104
|
console.log(` splash: ${manifest.splash.show ? `the engine badge, held ${manifest.splash.minMs} ms` : 'off'}`);
|
|
105
|
+
/*
|
|
106
|
+
* **Printed the way the signing mode is, and for the same reason.** An APK's permissions are the
|
|
107
|
+
* difference between a game that can reach a relay and one whose every connection fails with
|
|
108
|
+
* nothing logged at either end, and they are invisible until somebody runs `aapt2` on the built
|
|
109
|
+
* artifact. Saying it here puts it in front of whoever is about to build.
|
|
110
|
+
*/
|
|
111
|
+
if (manifest.targets.includes('android')) {
|
|
112
|
+
const asked = manifest.android.permissions;
|
|
113
|
+
console.log(` android: permissions ${asked.length > 0 ? asked.join(', ') : 'none — the APK cannot open a socket'}`);
|
|
114
|
+
console.log(` android: cleartext ${manifest.android.cleartextTraffic
|
|
115
|
+
? 'permitted — http:// and ws:// are allowed, and mixed content with them'
|
|
116
|
+
: 'refused — a relay must be wss://'}`);
|
|
117
|
+
}
|
|
105
118
|
/*
|
|
106
119
|
* **What each target still needs, and the command that fetches it.** A `doctor` that only
|
|
107
120
|
* validated a manifest would pass on a machine with no Java and no SDK, and the failure would
|
package/dist/manifest.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `drift.package.json`, parsed once and trusted afterwards.
|
|
3
|
-
*
|
|
4
|
-
* **Every rejection names its field.** A packaging manifest is written by hand, usually once, and
|
|
5
|
-
* a wrong value surfaces at signing or at first launch on a machine the author does not have. The
|
|
6
|
-
* cost of validating here is a few dozen lines; the cost of not validating is a build.
|
|
7
|
-
*
|
|
8
|
-
* **What is defaulted and what is not** is the only interesting decision in this file. Window size
|
|
9
|
-
* and features default, because a short manifest is easier to read and a wrong window is visible
|
|
10
|
-
* immediately. The id, the entry, the targets and the backend policy do not, because each of them
|
|
11
|
-
* is silent when wrong: the wrong id writes saves to a directory nobody looks in.
|
|
12
|
-
*/
|
|
13
1
|
export type WebGpuPolicy = 'prefer' | 'require' | 'off';
|
|
14
2
|
export type Target = 'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'android' | 'ios';
|
|
15
3
|
/** The two that are a phone rather than a desktop, and differ in almost every way that matters. */
|
|
@@ -40,6 +28,25 @@ export interface PackageManifest {
|
|
|
40
28
|
readonly show: boolean;
|
|
41
29
|
readonly minMs: number;
|
|
42
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* What the Android build asks the platform for. Nothing, unless a game says otherwise.
|
|
33
|
+
*
|
|
34
|
+
* **`permissions` is empty by default and that default is the feature**: the manifest template
|
|
35
|
+
* declares none, so a game asks for what it needs and the asking is visible in the store listing
|
|
36
|
+
* rather than granted to every game the packager ever builds. `"INTERNET"` and
|
|
37
|
+
* `"android.permission.INTERNET"` are both accepted.
|
|
38
|
+
*
|
|
39
|
+
* **`cleartextTraffic` is false by default and is the other half of reaching a relay.** The game
|
|
40
|
+
* is served from `https://appassets.androidplatform.net`, which is a secure context, so Blink
|
|
41
|
+
* refuses `ws://` from it as mixed content whatever Android's own policy says. Setting this true
|
|
42
|
+
* sets `android:usesCleartextTraffic` *and* is what `MainActivity` reads at runtime to allow
|
|
43
|
+
* mixed content in the WebView, so the platform and the renderer cannot disagree about it. A
|
|
44
|
+
* relay on a LAN cannot hold a certificate, which is the case it exists for.
|
|
45
|
+
*/
|
|
46
|
+
readonly android: {
|
|
47
|
+
readonly permissions: readonly string[];
|
|
48
|
+
readonly cleartextTraffic: boolean;
|
|
49
|
+
};
|
|
43
50
|
/**
|
|
44
51
|
* Who an installer says this is from.
|
|
45
52
|
*
|
package/dist/manifest.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `drift.package.json`, parsed once and trusted afterwards.
|
|
3
|
+
*
|
|
4
|
+
* **Every rejection names its field.** A packaging manifest is written by hand, usually once, and
|
|
5
|
+
* a wrong value surfaces at signing or at first launch on a machine the author does not have. The
|
|
6
|
+
* cost of validating here is a few dozen lines; the cost of not validating is a build.
|
|
7
|
+
*
|
|
8
|
+
* **What is defaulted and what is not** is the only interesting decision in this file. Window size
|
|
9
|
+
* and features default, because a short manifest is easier to read and a wrong window is visible
|
|
10
|
+
* immediately. The id, the entry, the targets and the backend policy do not, because each of them
|
|
11
|
+
* is silent when wrong: the wrong id writes saves to a directory nobody looks in.
|
|
12
|
+
*/
|
|
13
|
+
import { checkPermission, fullPermissionName } from './mobile/androidManifest.js';
|
|
1
14
|
/** The two that are a phone rather than a desktop, and differ in almost every way that matters. */
|
|
2
15
|
export const MOBILE_TARGETS = ['android', 'ios'];
|
|
3
16
|
const POLICIES = ['prefer', 'require', 'off'];
|
|
@@ -54,6 +67,18 @@ export function parseManifest(raw) {
|
|
|
54
67
|
const featuresRaw = (source.features ?? {});
|
|
55
68
|
const steamRaw = (source.steam ?? {});
|
|
56
69
|
const splashRaw = (source.splash ?? {});
|
|
70
|
+
const androidRaw = (source.android ?? {});
|
|
71
|
+
const permissionsRaw = androidRaw.permissions ?? [];
|
|
72
|
+
if (!Array.isArray(permissionsRaw)) {
|
|
73
|
+
throw new Error('drift.package.json: "android.permissions" must be an array of strings');
|
|
74
|
+
}
|
|
75
|
+
const permissions = permissionsRaw.map((entry) => {
|
|
76
|
+
if (typeof entry !== 'string') {
|
|
77
|
+
throw new Error(`drift.package.json: "android.permissions" holds ${typeof entry}, expected strings`);
|
|
78
|
+
}
|
|
79
|
+
checkPermission(entry);
|
|
80
|
+
return fullPermissionName(entry);
|
|
81
|
+
});
|
|
57
82
|
const appId = steamRaw.appId;
|
|
58
83
|
return {
|
|
59
84
|
id,
|
|
@@ -81,6 +106,10 @@ export function parseManifest(raw) {
|
|
|
81
106
|
? source.publisher
|
|
82
107
|
: (id.split('.')[1] ?? id),
|
|
83
108
|
icon: typeof source.icon === 'string' && source.icon.length > 0 ? source.icon : null,
|
|
109
|
+
android: {
|
|
110
|
+
permissions,
|
|
111
|
+
cleartextTraffic: androidRaw.cleartextTraffic === true,
|
|
112
|
+
},
|
|
84
113
|
splash: {
|
|
85
114
|
show: splashRaw.show !== false,
|
|
86
115
|
minMs: typeof splashRaw.minMs === 'number'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** What a game asks the Android manifest for, written into the copied template before Gradle runs. */
|
|
2
|
+
/** The `android` block of `drift.package.json`, already parsed. See `manifest.ts`. */
|
|
3
|
+
export interface AndroidOptions {
|
|
4
|
+
/** Fully-qualified permission names, in the order they were asked for. */
|
|
5
|
+
readonly permissions: readonly string[];
|
|
6
|
+
/** Whether the app may open `http://` and `ws://` at all. */
|
|
7
|
+
readonly cleartextTraffic: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Nothing asked for: what every build got before this existed, and still gets. */
|
|
10
|
+
export declare const NO_ANDROID_OPTIONS: AndroidOptions;
|
|
11
|
+
/**
|
|
12
|
+
* `INTERNET` and `android.permission.INTERNET` are the same thing, written two ways.
|
|
13
|
+
*
|
|
14
|
+
* A consumer writes the short name because that is what the permission is called everywhere except
|
|
15
|
+
* in the XML, and the long one because that is what the XML says. Both are accepted and the long
|
|
16
|
+
* form is what is written, since a short name in the manifest is a permission Android does not
|
|
17
|
+
* recognise and silently does not grant — the same silence this whole field exists to end.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fullPermissionName(name: string): string;
|
|
20
|
+
/** Refuse a name that cannot go into the XML, at parse time where a consumer can fix it. */
|
|
21
|
+
export declare function checkPermission(name: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* The template manifest with a game's permissions in it, and its cleartext policy set.
|
|
24
|
+
*
|
|
25
|
+
* **Written into the copy, not into the template**, the way the icon already is: `buildAndroid`
|
|
26
|
+
* stages `packages/package/android/` into `out/android/project` and edits it there, so this
|
|
27
|
+
* repository's own tree is never the thing a consumer's build modifies.
|
|
28
|
+
*
|
|
29
|
+
* **Why this exists at all.** The template declares one activity and no permissions, deliberately:
|
|
30
|
+
* a game that needs the network should ask for it, and the asking should be visible in the store
|
|
31
|
+
* listing rather than granted to everybody by default. That default was right and there was no way
|
|
32
|
+
* to depart from it — the template is copied wholesale, none of the eight `-Pdrift*` properties
|
|
33
|
+
* reaches the manifest, and the only door was `--resources`, which moves the desktop shell and the
|
|
34
|
+
* iOS spec along with the Gradle project. So an online game shipped an APK whose process is not
|
|
35
|
+
* permitted to open a socket: every connection fails, the relay logs nothing because no packet
|
|
36
|
+
* leaves, and neither end can say why. Reported from outside exactly that way.
|
|
37
|
+
*
|
|
38
|
+
* Returns the source unchanged when nothing is asked for, which is what keeps a game that has never
|
|
39
|
+
* heard of this byte-identical to the build before it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withAndroidManifest(xml: string, options: AndroidOptions): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** What a game asks the Android manifest for, written into the copied template before Gradle runs. */
|
|
2
|
+
/** Nothing asked for: what every build got before this existed, and still gets. */
|
|
3
|
+
export const NO_ANDROID_OPTIONS = Object.freeze({
|
|
4
|
+
permissions: Object.freeze([]),
|
|
5
|
+
cleartextTraffic: false,
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* `INTERNET` and `android.permission.INTERNET` are the same thing, written two ways.
|
|
9
|
+
*
|
|
10
|
+
* A consumer writes the short name because that is what the permission is called everywhere except
|
|
11
|
+
* in the XML, and the long one because that is what the XML says. Both are accepted and the long
|
|
12
|
+
* form is what is written, since a short name in the manifest is a permission Android does not
|
|
13
|
+
* recognise and silently does not grant — the same silence this whole field exists to end.
|
|
14
|
+
*/
|
|
15
|
+
export function fullPermissionName(name) {
|
|
16
|
+
return name.includes('.') ? name : `android.permission.${name}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What a permission name may contain.
|
|
20
|
+
*
|
|
21
|
+
* Checked because the name is interpolated into an XML attribute: a quote or a bracket in it ends
|
|
22
|
+
* the attribute and produces a manifest that either fails to parse or parses into something nobody
|
|
23
|
+
* wrote. Android's own names are dotted upper snake case, and a custom one from a library follows
|
|
24
|
+
* the same shape.
|
|
25
|
+
*/
|
|
26
|
+
const PERMISSION = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
|
|
27
|
+
/** Refuse a name that cannot go into the XML, at parse time where a consumer can fix it. */
|
|
28
|
+
export function checkPermission(name) {
|
|
29
|
+
if (!PERMISSION.test(name)) {
|
|
30
|
+
throw new Error(`drift.package.json: "${name}" is not a permission name — expected something like ` +
|
|
31
|
+
`"INTERNET" or "android.permission.INTERNET", letters, digits, underscores and dots only.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The template manifest with a game's permissions in it, and its cleartext policy set.
|
|
36
|
+
*
|
|
37
|
+
* **Written into the copy, not into the template**, the way the icon already is: `buildAndroid`
|
|
38
|
+
* stages `packages/package/android/` into `out/android/project` and edits it there, so this
|
|
39
|
+
* repository's own tree is never the thing a consumer's build modifies.
|
|
40
|
+
*
|
|
41
|
+
* **Why this exists at all.** The template declares one activity and no permissions, deliberately:
|
|
42
|
+
* a game that needs the network should ask for it, and the asking should be visible in the store
|
|
43
|
+
* listing rather than granted to everybody by default. That default was right and there was no way
|
|
44
|
+
* to depart from it — the template is copied wholesale, none of the eight `-Pdrift*` properties
|
|
45
|
+
* reaches the manifest, and the only door was `--resources`, which moves the desktop shell and the
|
|
46
|
+
* iOS spec along with the Gradle project. So an online game shipped an APK whose process is not
|
|
47
|
+
* permitted to open a socket: every connection fails, the relay logs nothing because no packet
|
|
48
|
+
* leaves, and neither end can say why. Reported from outside exactly that way.
|
|
49
|
+
*
|
|
50
|
+
* Returns the source unchanged when nothing is asked for, which is what keeps a game that has never
|
|
51
|
+
* heard of this byte-identical to the build before it.
|
|
52
|
+
*/
|
|
53
|
+
export function withAndroidManifest(xml, options) {
|
|
54
|
+
const permissions = options.permissions.map(fullPermissionName);
|
|
55
|
+
let out = xml;
|
|
56
|
+
if (permissions.length > 0) {
|
|
57
|
+
/* The element, not any tag that starts with its name: `[\s>]` is what separates
|
|
58
|
+
`<application` from an `<applicationInfo` somebody adds later. */
|
|
59
|
+
const at = out.search(/<application[\s>]/);
|
|
60
|
+
if (at === -1) {
|
|
61
|
+
throw new Error('the Android manifest template has no <application> element to precede');
|
|
62
|
+
}
|
|
63
|
+
/*
|
|
64
|
+
* Before `<application>` and at its indentation. The schema requires `uses-permission` to be a
|
|
65
|
+
* child of `manifest` rather than of `application`, and Android's own tooling orders them
|
|
66
|
+
* first; `aapt2` accepts either order, and a reader of the built manifest should not have to
|
|
67
|
+
* hunt for them.
|
|
68
|
+
*/
|
|
69
|
+
const indent = indentOf(out, at);
|
|
70
|
+
const block = permissions
|
|
71
|
+
.map((name) => `${indent}<uses-permission android:name="${name}" />`)
|
|
72
|
+
.join('\n');
|
|
73
|
+
out = `${out.slice(0, at - indent.length)}${block}\n\n${indent}${out.slice(at)}`;
|
|
74
|
+
}
|
|
75
|
+
/*
|
|
76
|
+
* The attribute is rewritten rather than appended, because the template already carries it with
|
|
77
|
+
* `false`. Two of them is a build error, and a second one appended after the first is the shape
|
|
78
|
+
* that produces it.
|
|
79
|
+
*/
|
|
80
|
+
const cleartext = options.cleartextTraffic ? 'true' : 'false';
|
|
81
|
+
const replaced = out.replace(/android:usesCleartextTraffic="(?:true|false)"/, `android:usesCleartextTraffic="${cleartext}"`);
|
|
82
|
+
if (replaced === out && options.cleartextTraffic) {
|
|
83
|
+
throw new Error('the Android manifest template no longer declares android:usesCleartextTraffic, so asking ' +
|
|
84
|
+
'for cleartext traffic would do nothing');
|
|
85
|
+
}
|
|
86
|
+
return replaced;
|
|
87
|
+
}
|
|
88
|
+
/** The whitespace at the start of the line `at` falls on, so an inserted element lines up. */
|
|
89
|
+
function indentOf(xml, at) {
|
|
90
|
+
const lineStart = xml.lastIndexOf('\n', at) + 1;
|
|
91
|
+
return xml.slice(lineStart, at);
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/package",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.62.0",
|
|
4
4
|
"description": "Builds an installable application from a game built on the engine",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"drift-package": "bin/drift-package.mjs"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@driftengine/core": "3.
|
|
38
|
+
"@driftengine/core": "3.62.0",
|
|
39
39
|
"steamworks.js": ">=0.4.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
@@ -74,5 +74,8 @@
|
|
|
74
74
|
},
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"prepack": "node ../../scripts/build.mjs @driftengine/package"
|
|
77
80
|
}
|
|
78
81
|
}
|
package/src/android.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { PackageManifest } from './manifest.ts';
|
|
|
8
8
|
import { resourceDir } from './resources.ts';
|
|
9
9
|
import type { ResourceOptions } from './resources.ts';
|
|
10
10
|
import { missingReferences } from './mobile/assetCheck.ts';
|
|
11
|
+
import { withAndroidManifest } from './mobile/androidManifest.ts';
|
|
11
12
|
import { versionCodeFor } from './mobile/versionCode.ts';
|
|
12
13
|
import { androidToolchain, toolchainHome } from './toolchain.ts';
|
|
13
14
|
|
|
@@ -77,6 +78,30 @@ export async function buildAndroid(
|
|
|
77
78
|
logLevel: 'warning',
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
/*
|
|
82
|
+
* **The manifest is rewritten in the copy, before Gradle reads it.** The same place and the same
|
|
83
|
+
* reason as the icon below: the template is staged into `out/`, so a consumer's permissions never
|
|
84
|
+
* touch this repository's tree. See `mobile/androidManifest.ts` for what an empty request does,
|
|
85
|
+
* which is nothing at all.
|
|
86
|
+
*/
|
|
87
|
+
const manifestPath = join(projectRoot, 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
88
|
+
const templateXml = await readFile(manifestPath, 'utf8');
|
|
89
|
+
const withPermissions = withAndroidManifest(templateXml, manifest.android);
|
|
90
|
+
if (withPermissions !== templateXml) {
|
|
91
|
+
await writeFile(manifestPath, withPermissions, 'utf8');
|
|
92
|
+
}
|
|
93
|
+
if (manifest.android.permissions.length > 0) {
|
|
94
|
+
console.log(`[drift-package] android: permissions ${manifest.android.permissions.join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
if (manifest.android.cleartextTraffic) {
|
|
97
|
+
/*
|
|
98
|
+
* Said out loud because it is a security posture rather than a setting: the build now permits
|
|
99
|
+
* `http://` and `ws://`, and `MainActivity` reads the same flag to allow mixed content in the
|
|
100
|
+
* WebView. A consumer who did not mean it should see it in the build log.
|
|
101
|
+
*/
|
|
102
|
+
console.log('[drift-package] android: cleartext traffic permitted (http:// and ws:// allowed)');
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
if (manifest.icon !== null) {
|
|
81
106
|
/* One PNG at one density: Android scales it for the rest, and a game that wants a full set
|
|
82
107
|
supplies its own `res/` through its own build rather than through this. */
|
package/src/cli.ts
CHANGED
|
@@ -106,6 +106,25 @@ async function doctor(cwd: string, options: ResourceOptions = {}): Promise<Packa
|
|
|
106
106
|
console.log(
|
|
107
107
|
` splash: ${manifest.splash.show ? `the engine badge, held ${manifest.splash.minMs} ms` : 'off'}`,
|
|
108
108
|
);
|
|
109
|
+
/*
|
|
110
|
+
* **Printed the way the signing mode is, and for the same reason.** An APK's permissions are the
|
|
111
|
+
* difference between a game that can reach a relay and one whose every connection fails with
|
|
112
|
+
* nothing logged at either end, and they are invisible until somebody runs `aapt2` on the built
|
|
113
|
+
* artifact. Saying it here puts it in front of whoever is about to build.
|
|
114
|
+
*/
|
|
115
|
+
if (manifest.targets.includes('android')) {
|
|
116
|
+
const asked = manifest.android.permissions;
|
|
117
|
+
console.log(
|
|
118
|
+
` android: permissions ${asked.length > 0 ? asked.join(', ') : 'none — the APK cannot open a socket'}`,
|
|
119
|
+
);
|
|
120
|
+
console.log(
|
|
121
|
+
` android: cleartext ${
|
|
122
|
+
manifest.android.cleartextTraffic
|
|
123
|
+
? 'permitted — http:// and ws:// are allowed, and mixed content with them'
|
|
124
|
+
: 'refused — a relay must be wss://'
|
|
125
|
+
}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
109
128
|
|
|
110
129
|
/*
|
|
111
130
|
* **What each target still needs, and the command that fetches it.** A `doctor` that only
|
package/src/manifest.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* immediately. The id, the entry, the targets and the backend policy do not, because each of them
|
|
11
11
|
* is silent when wrong: the wrong id writes saves to a directory nobody looks in.
|
|
12
12
|
*/
|
|
13
|
+
import { checkPermission, fullPermissionName } from './mobile/androidManifest.ts';
|
|
14
|
+
|
|
13
15
|
export type WebGpuPolicy = 'prefer' | 'require' | 'off';
|
|
14
16
|
export type Target = 'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'android' | 'ios';
|
|
15
17
|
|
|
@@ -54,6 +56,25 @@ export interface PackageManifest {
|
|
|
54
56
|
readonly targets: readonly Target[];
|
|
55
57
|
readonly steam: { readonly appId: number | null };
|
|
56
58
|
readonly splash: { readonly show: boolean; readonly minMs: number };
|
|
59
|
+
/**
|
|
60
|
+
* What the Android build asks the platform for. Nothing, unless a game says otherwise.
|
|
61
|
+
*
|
|
62
|
+
* **`permissions` is empty by default and that default is the feature**: the manifest template
|
|
63
|
+
* declares none, so a game asks for what it needs and the asking is visible in the store listing
|
|
64
|
+
* rather than granted to every game the packager ever builds. `"INTERNET"` and
|
|
65
|
+
* `"android.permission.INTERNET"` are both accepted.
|
|
66
|
+
*
|
|
67
|
+
* **`cleartextTraffic` is false by default and is the other half of reaching a relay.** The game
|
|
68
|
+
* is served from `https://appassets.androidplatform.net`, which is a secure context, so Blink
|
|
69
|
+
* refuses `ws://` from it as mixed content whatever Android's own policy says. Setting this true
|
|
70
|
+
* sets `android:usesCleartextTraffic` *and* is what `MainActivity` reads at runtime to allow
|
|
71
|
+
* mixed content in the WebView, so the platform and the renderer cannot disagree about it. A
|
|
72
|
+
* relay on a LAN cannot hold a certificate, which is the case it exists for.
|
|
73
|
+
*/
|
|
74
|
+
readonly android: {
|
|
75
|
+
readonly permissions: readonly string[];
|
|
76
|
+
readonly cleartextTraffic: boolean;
|
|
77
|
+
};
|
|
57
78
|
/**
|
|
58
79
|
* Who an installer says this is from.
|
|
59
80
|
*
|
|
@@ -116,6 +137,20 @@ export function parseManifest(raw: unknown): PackageManifest {
|
|
|
116
137
|
const featuresRaw = (source.features ?? {}) as Record<string, unknown>;
|
|
117
138
|
const steamRaw = (source.steam ?? {}) as Record<string, unknown>;
|
|
118
139
|
const splashRaw = (source.splash ?? {}) as Record<string, unknown>;
|
|
140
|
+
const androidRaw = (source.android ?? {}) as Record<string, unknown>;
|
|
141
|
+
const permissionsRaw = androidRaw.permissions ?? [];
|
|
142
|
+
if (!Array.isArray(permissionsRaw)) {
|
|
143
|
+
throw new Error('drift.package.json: "android.permissions" must be an array of strings');
|
|
144
|
+
}
|
|
145
|
+
const permissions = permissionsRaw.map((entry) => {
|
|
146
|
+
if (typeof entry !== 'string') {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`drift.package.json: "android.permissions" holds ${typeof entry}, expected strings`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
checkPermission(entry);
|
|
152
|
+
return fullPermissionName(entry);
|
|
153
|
+
});
|
|
119
154
|
const appId = steamRaw.appId;
|
|
120
155
|
|
|
121
156
|
return {
|
|
@@ -146,6 +181,10 @@ export function parseManifest(raw: unknown): PackageManifest {
|
|
|
146
181
|
? source.publisher
|
|
147
182
|
: (id.split('.')[1] ?? id),
|
|
148
183
|
icon: typeof source.icon === 'string' && source.icon.length > 0 ? source.icon : null,
|
|
184
|
+
android: {
|
|
185
|
+
permissions,
|
|
186
|
+
cleartextTraffic: androidRaw.cleartextTraffic === true,
|
|
187
|
+
},
|
|
149
188
|
splash: {
|
|
150
189
|
show: splashRaw.show !== false,
|
|
151
190
|
minMs:
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** What a game asks the Android manifest for, written into the copied template before Gradle runs. */
|
|
2
|
+
|
|
3
|
+
/** The `android` block of `drift.package.json`, already parsed. See `manifest.ts`. */
|
|
4
|
+
export interface AndroidOptions {
|
|
5
|
+
/** Fully-qualified permission names, in the order they were asked for. */
|
|
6
|
+
readonly permissions: readonly string[];
|
|
7
|
+
/** Whether the app may open `http://` and `ws://` at all. */
|
|
8
|
+
readonly cleartextTraffic: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Nothing asked for: what every build got before this existed, and still gets. */
|
|
12
|
+
export const NO_ANDROID_OPTIONS: AndroidOptions = Object.freeze({
|
|
13
|
+
permissions: Object.freeze([]) as readonly string[],
|
|
14
|
+
cleartextTraffic: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `INTERNET` and `android.permission.INTERNET` are the same thing, written two ways.
|
|
19
|
+
*
|
|
20
|
+
* A consumer writes the short name because that is what the permission is called everywhere except
|
|
21
|
+
* in the XML, and the long one because that is what the XML says. Both are accepted and the long
|
|
22
|
+
* form is what is written, since a short name in the manifest is a permission Android does not
|
|
23
|
+
* recognise and silently does not grant — the same silence this whole field exists to end.
|
|
24
|
+
*/
|
|
25
|
+
export function fullPermissionName(name: string): string {
|
|
26
|
+
return name.includes('.') ? name : `android.permission.${name}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a permission name may contain.
|
|
31
|
+
*
|
|
32
|
+
* Checked because the name is interpolated into an XML attribute: a quote or a bracket in it ends
|
|
33
|
+
* the attribute and produces a manifest that either fails to parse or parses into something nobody
|
|
34
|
+
* wrote. Android's own names are dotted upper snake case, and a custom one from a library follows
|
|
35
|
+
* the same shape.
|
|
36
|
+
*/
|
|
37
|
+
const PERMISSION = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
|
|
38
|
+
|
|
39
|
+
/** Refuse a name that cannot go into the XML, at parse time where a consumer can fix it. */
|
|
40
|
+
export function checkPermission(name: string): void {
|
|
41
|
+
if (!PERMISSION.test(name)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`drift.package.json: "${name}" is not a permission name — expected something like ` +
|
|
44
|
+
`"INTERNET" or "android.permission.INTERNET", letters, digits, underscores and dots only.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The template manifest with a game's permissions in it, and its cleartext policy set.
|
|
51
|
+
*
|
|
52
|
+
* **Written into the copy, not into the template**, the way the icon already is: `buildAndroid`
|
|
53
|
+
* stages `packages/package/android/` into `out/android/project` and edits it there, so this
|
|
54
|
+
* repository's own tree is never the thing a consumer's build modifies.
|
|
55
|
+
*
|
|
56
|
+
* **Why this exists at all.** The template declares one activity and no permissions, deliberately:
|
|
57
|
+
* a game that needs the network should ask for it, and the asking should be visible in the store
|
|
58
|
+
* listing rather than granted to everybody by default. That default was right and there was no way
|
|
59
|
+
* to depart from it — the template is copied wholesale, none of the eight `-Pdrift*` properties
|
|
60
|
+
* reaches the manifest, and the only door was `--resources`, which moves the desktop shell and the
|
|
61
|
+
* iOS spec along with the Gradle project. So an online game shipped an APK whose process is not
|
|
62
|
+
* permitted to open a socket: every connection fails, the relay logs nothing because no packet
|
|
63
|
+
* leaves, and neither end can say why. Reported from outside exactly that way.
|
|
64
|
+
*
|
|
65
|
+
* Returns the source unchanged when nothing is asked for, which is what keeps a game that has never
|
|
66
|
+
* heard of this byte-identical to the build before it.
|
|
67
|
+
*/
|
|
68
|
+
export function withAndroidManifest(xml: string, options: AndroidOptions): string {
|
|
69
|
+
const permissions = options.permissions.map(fullPermissionName);
|
|
70
|
+
let out = xml;
|
|
71
|
+
|
|
72
|
+
if (permissions.length > 0) {
|
|
73
|
+
/* The element, not any tag that starts with its name: `[\s>]` is what separates
|
|
74
|
+
`<application` from an `<applicationInfo` somebody adds later. */
|
|
75
|
+
const at = out.search(/<application[\s>]/);
|
|
76
|
+
if (at === -1) {
|
|
77
|
+
throw new Error('the Android manifest template has no <application> element to precede');
|
|
78
|
+
}
|
|
79
|
+
/*
|
|
80
|
+
* Before `<application>` and at its indentation. The schema requires `uses-permission` to be a
|
|
81
|
+
* child of `manifest` rather than of `application`, and Android's own tooling orders them
|
|
82
|
+
* first; `aapt2` accepts either order, and a reader of the built manifest should not have to
|
|
83
|
+
* hunt for them.
|
|
84
|
+
*/
|
|
85
|
+
const indent = indentOf(out, at);
|
|
86
|
+
const block = permissions
|
|
87
|
+
.map((name) => `${indent}<uses-permission android:name="${name}" />`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
out = `${out.slice(0, at - indent.length)}${block}\n\n${indent}${out.slice(at)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* The attribute is rewritten rather than appended, because the template already carries it with
|
|
94
|
+
* `false`. Two of them is a build error, and a second one appended after the first is the shape
|
|
95
|
+
* that produces it.
|
|
96
|
+
*/
|
|
97
|
+
const cleartext = options.cleartextTraffic ? 'true' : 'false';
|
|
98
|
+
const replaced = out.replace(
|
|
99
|
+
/android:usesCleartextTraffic="(?:true|false)"/,
|
|
100
|
+
`android:usesCleartextTraffic="${cleartext}"`,
|
|
101
|
+
);
|
|
102
|
+
if (replaced === out && options.cleartextTraffic) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'the Android manifest template no longer declares android:usesCleartextTraffic, so asking ' +
|
|
105
|
+
'for cleartext traffic would do nothing',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return replaced;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The whitespace at the start of the line `at` falls on, so an inserted element lines up. */
|
|
112
|
+
function indentOf(xml: string, at: number): string {
|
|
113
|
+
const lineStart = xml.lastIndexOf('\n', at) + 1;
|
|
114
|
+
return xml.slice(lineStart, at);
|
|
115
|
+
}
|