@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 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
@@ -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.61.2",
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.61.2",
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
+ }