apphx 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +50 -182
  2. package/benchmarks/peer-context-worker.mjs +186 -0
  3. package/benchmarks/peer-context.mjs +150 -0
  4. package/dist/apple-types.js.map +1 -1
  5. package/dist/cli.js +370 -118
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.js +42 -4
  8. package/dist/config.js.map +1 -1
  9. package/dist/dependencies.js +455 -16
  10. package/dist/dependencies.js.map +1 -1
  11. package/dist/export-options.js +44 -2
  12. package/dist/export-options.js.map +1 -1
  13. package/dist/flutter.js +13 -25
  14. package/dist/flutter.js.map +1 -1
  15. package/dist/go-dependencies.js +389 -0
  16. package/dist/go-dependencies.js.map +1 -0
  17. package/dist/inbox.js +7 -8
  18. package/dist/inbox.js.map +1 -1
  19. package/dist/integrations.js +3 -3
  20. package/dist/isolation.js +190 -0
  21. package/dist/isolation.js.map +1 -0
  22. package/dist/macos.js +57 -0
  23. package/dist/macos.js.map +1 -0
  24. package/dist/mygo-build.js +55 -0
  25. package/dist/mygo-build.js.map +1 -0
  26. package/dist/policy.js +35 -0
  27. package/dist/policy.js.map +1 -0
  28. package/dist/preflight.js +1 -1
  29. package/dist/process.js +3 -3
  30. package/dist/project-profiles.js +163 -0
  31. package/dist/project-profiles.js.map +1 -0
  32. package/dist/proof.js +33 -0
  33. package/dist/proof.js.map +1 -0
  34. package/dist/remote.js +1 -1
  35. package/dist/runner.js +269 -287
  36. package/dist/runner.js.map +1 -1
  37. package/dist/scheduler.js +0 -3
  38. package/dist/scheduler.js.map +1 -1
  39. package/dist/schemas.js +156 -44
  40. package/dist/schemas.js.map +1 -1
  41. package/dist/store.js +166 -44
  42. package/dist/store.js.map +1 -1
  43. package/dist/vendor/obfuscator/dist/cli.js +72 -0
  44. package/dist/vendor/obfuscator/dist/cli.js.map +1 -0
  45. package/dist/vendor/obfuscator/dist/code.js +585 -0
  46. package/dist/vendor/obfuscator/dist/code.js.map +1 -0
  47. package/dist/vendor/obfuscator/dist/common.js +262 -0
  48. package/dist/vendor/obfuscator/dist/common.js.map +1 -0
  49. package/dist/vendor/obfuscator/dist/config.js +123 -0
  50. package/dist/vendor/obfuscator/dist/config.js.map +1 -0
  51. package/dist/vendor/obfuscator/dist/content.js +89 -0
  52. package/dist/vendor/obfuscator/dist/content.js.map +1 -0
  53. package/dist/vendor/obfuscator/dist/obfuscate.js +93 -0
  54. package/dist/vendor/obfuscator/dist/obfuscate.js.map +1 -0
  55. package/dist/vendor/obfuscator/dist/png.js +283 -0
  56. package/dist/vendor/obfuscator/dist/png.js.map +1 -0
  57. package/dist/vendor/obfuscator/dist/project.js +159 -0
  58. package/dist/vendor/obfuscator/dist/project.js.map +1 -0
  59. package/dist/vendor/obfuscator/dist/resources.js +335 -0
  60. package/dist/vendor/obfuscator/dist/resources.js.map +1 -0
  61. package/dist/vendor/obfuscator/package.json +22 -0
  62. package/dist/workspace.js +113 -0
  63. package/dist/workspace.js.map +1 -0
  64. package/evals/control.mjs +73 -0
  65. package/evals/expectations.mjs +37 -0
  66. package/evals/host.mjs +51 -0
  67. package/evals/oracle.mjs +80 -0
  68. package/evals/policy.mjs +268 -0
  69. package/examples/android-java.factory.json +18 -0
  70. package/examples/android-java.json +16 -0
  71. package/examples/existing-project.factory.json +18 -0
  72. package/examples/existing-project.json +17 -0
  73. package/examples/expo.eas.json +17 -0
  74. package/examples/expo.factory.json +18 -0
  75. package/examples/expo.json +16 -0
  76. package/examples/factory.json +0 -2
  77. package/examples/flutter.factory.json +13 -0
  78. package/examples/flutter.json +16 -0
  79. package/examples/luggage.json +5 -3
  80. package/examples/mygo-web.factory.json +18 -0
  81. package/examples/mygo-web.json +16 -0
  82. package/examples/mygo.factory.json +18 -0
  83. package/examples/mygo.json +16 -0
  84. package/examples/react-native.factory.json +18 -0
  85. package/examples/react-native.json +16 -0
  86. package/examples/runtime/android-java/app/build.gradle +7 -0
  87. package/examples/runtime/android-java/app/src/main/AndroidManifest.xml +8 -0
  88. package/examples/runtime/android-java/app/src/main/java/dev/apphx/runtime/MainActivity.java +44 -0
  89. package/examples/runtime/android-java/sdk-build.mjs +29 -0
  90. package/examples/runtime/android-java/settings.gradle +2 -0
  91. package/examples/runtime/android-ui.mjs +70 -0
  92. package/examples/runtime/mygo-native/go.mod +13 -0
  93. package/examples/runtime/mygo-native/go.sum +10 -0
  94. package/examples/runtime/mygo-native/main.go +173 -0
  95. package/examples/runtime/mygo-native/main_test.go +14 -0
  96. package/examples/runtime/mygo-native/mygo.json +1 -0
  97. package/examples/runtime/mygo-web/bun.lock +15 -0
  98. package/examples/runtime/mygo-web/go.mod +9 -0
  99. package/examples/runtime/mygo-web/go.sum +8 -0
  100. package/examples/runtime/mygo-web/index.html +1 -0
  101. package/examples/runtime/mygo-web/main.go +60 -0
  102. package/examples/runtime/mygo-web/mygo.config.ts +10 -0
  103. package/examples/runtime/mygo-web/package.json +1 -0
  104. package/examples/runtime/mygo-web/src/main.ts +23 -0
  105. package/examples/runtime/mygo-web/src/mygo.ts +14 -0
  106. package/examples/swift.factory.json +18 -0
  107. package/examples/swift.json +16 -0
  108. package/examples/uni-app.factory.json +18 -0
  109. package/examples/uni-app.json +16 -0
  110. package/package.json +12 -5
  111. package/scripts/bundle-obfuscator.mjs +19 -0
  112. package/scripts/runtime-smoke.mjs +209 -0
  113. package/skills/apphx/SKILL.md +94 -17
  114. package/skills/apphx/resources/google-play-research-result.json +6 -0
  115. package/skills/apphx/resources/local-research-result.json +6 -0
  116. package/skills/apphx/resources/requirement.json +5 -8
  117. package/skills/apphx/resources/research-result.json +5 -14
  118. package/skills/apphx/resources/review-result.json +1 -1
  119. package/dist/apple.js +0 -900
  120. package/dist/apple.js.map +0 -1
@@ -0,0 +1,18 @@
1
+ {
2
+ "stateDir": ".factory",
3
+ "workspaceRoot": "workspaces",
4
+ "allowLocalExecution": false,
5
+ "allowCloudBuild": false,
6
+ "packageTarget": "dmg",
7
+ "executionEnv": [],
8
+ "verification": {
9
+ "prerequisites": [["go", "version"], ["go", "tool", "mygo", "version"], ["bun", "--version"]],
10
+ "checks": [["go", "vet", "./..."], ["go", "test", "./..."], ["bun", "run", "build"]],
11
+ "runtime": [["go", "run", "./cmd/acceptance"]]
12
+ },
13
+ "packaging": {
14
+ "provider": "local",
15
+ "commands": [["go", "tool", "mygo", "build", "-platform", "darwin/arm64", "-sign", "-", "-skip-notarize", "-o", "build"]],
16
+ "artifactPath": "build/darwin-arm64/MyGo App 0.1.0.dmg"
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "mygo-web-local-notes",
3
+ "name": "MyGo App",
4
+ "framework": "mygo",
5
+ "platform": "macos",
6
+ "channel": "local",
7
+ "version": "0.1.0",
8
+ "screenshotsDir": "build/screenshots",
9
+ "purpose": "Capture and recover private offline desktop notes with a web frontend",
10
+ "audience": "Mac users keeping local working notes",
11
+ "differentiators": ["Offline note editing with a reversible owner-controlled backup"],
12
+ "acceptance": ["Launch the actual packaged macOS app and use its visible web frontend to create and edit a note, then restart and recover the saved text", "Export notes, remove local data and restore the same notes through the desktop UI; record actual window screenshots"],
13
+ "constraints": ["Use go.mod, mygo.config.ts and a real frontend package.json with its committed dependency lock; this paired profile illustrates an existing Bun frontend with a real build script, not a requirement to migrate package managers", "Pin github.com/egoist/mygo v0.3.0 and its cmd/mygo tool directive in go.mod; use Go 1.27.1", "Set the MyGo app name to MyGo App and version to 0.1.0; match darwin/arm64 output paths in the paired profile", "Prepare generated frontend bindings and lockfiles before verification; implement cmd/acceptance to launch and drive the real desktop app and fail on UI assertion errors, not merely run browser or headless tests", "No accounts, network dependency, Developer ID, notarization, upload or publishing"],
14
+ "hiddenFeatures": false,
15
+ "reviewBypass": false
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "stateDir": ".factory",
3
+ "workspaceRoot": "workspaces",
4
+ "allowLocalExecution": false,
5
+ "allowCloudBuild": false,
6
+ "packageTarget": "app",
7
+ "executionEnv": [],
8
+ "verification": {
9
+ "prerequisites": [["go", "version"], ["go", "tool", "mygo", "version"]],
10
+ "checks": [["go", "vet", "./..."], ["go", "test", "./..."]],
11
+ "runtime": [["go", "run", "./cmd/acceptance"]]
12
+ },
13
+ "packaging": {
14
+ "provider": "local",
15
+ "commands": [["go", "tool", "mygo", "build", "-platform", "darwin/arm64", "-sign", "-", "-skip-notarize", "-skip-dmg", "-o", "build"]],
16
+ "artifactPath": "build/darwin-arm64/MyGo App.app"
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "mygo-local-notes",
3
+ "name": "MyGo App",
4
+ "framework": "mygo",
5
+ "platform": "macos",
6
+ "channel": "local",
7
+ "version": "0.1.0",
8
+ "screenshotsDir": "build/screenshots",
9
+ "purpose": "Capture and recover private offline desktop notes",
10
+ "audience": "Mac users keeping local working notes",
11
+ "differentiators": ["Offline note editing with a reversible owner-controlled backup"],
12
+ "acceptance": ["Launch the actual macOS app, create and edit a note through its visible UI, then restart and recover the saved text", "Export notes, remove local data and restore the same notes through the desktop UI; record actual window screenshots"],
13
+ "constraints": ["Use native MyGo UI with go.mod and mygo.json, without requiring package.json or Bun", "Pin github.com/egoist/mygo v0.3.0 and its cmd/mygo tool directive in go.mod; use Go 1.27.1", "Set the MyGo app name to MyGo App and version to 0.1.0; match darwin/arm64 output paths in the paired profile", "Implement cmd/acceptance as a real app-specific desktop harness that launches and drives the actual app, waits for UI assertions and fails on errors; go test or a headless view test alone is insufficient", "No accounts, network dependency, Developer ID, notarization, upload or publishing"],
14
+ "hiddenFeatures": false,
15
+ "reviewBypass": false
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "stateDir": ".factory",
3
+ "workspaceRoot": "workspaces",
4
+ "allowLocalExecution": false,
5
+ "allowCloudBuild": false,
6
+ "packageTarget": "aab",
7
+ "executionEnv": ["ANDROID_HOME", "ANDROID_DEVICE_ID"],
8
+ "verification": {
9
+ "prerequisites": [["node", "--version"], ["java", "-version"], ["adb", "version"]],
10
+ "checks": [["npm", "run", "lint"], ["npm", "test", "--", "--runInBand"]],
11
+ "runtime": [["npm", "run", "test:e2e", "--", "--platform", "android", "--device", "${ANDROID_DEVICE_ID}"]]
12
+ },
13
+ "packaging": {
14
+ "provider": "local",
15
+ "commands": [["./android/gradlew", "-p", "android", ":app:bundleRelease"]],
16
+ "artifactPath": "android/app/build/outputs/bundle/release/app-release.aab"
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "rn-field-notes",
3
+ "name": "Offline field notes",
4
+ "framework": "react-native",
5
+ "platform": "android",
6
+ "channel": "local",
7
+ "version": "1.0.0",
8
+ "screenshotsDir": "build/screenshots",
9
+ "purpose": "Capture field notes offline and restore an owner-exported backup",
10
+ "audience": "Field researchers",
11
+ "differentiators": ["Timestamped notes with a reversible local backup workflow"],
12
+ "acceptance": ["Create and edit a note offline, restart the app and recover the saved text", "Export notes, delete local data and restore the same notes from the export", "Run real device acceptance for both workflows using the project's test:e2e script and save actual screenshots"],
13
+ "constraints": ["Preserve React Native's android native host, lockfile and existing scripts; adapt lint, test and test:e2e to their real equivalents", "The test:e2e script must exercise the app on ANDROID_DEVICE_ID, not return a canned success", "No accounts, tracking, hidden features or network dependency"],
14
+ "hiddenFeatures": false,
15
+ "reviewBypass": false
16
+ }
@@ -0,0 +1,7 @@
1
+ // Deliberately no downloaded Android Gradle Plugin. This application compiles with
2
+ // the owner-selected SDK: aapt -> javac -> d8 -> zipalign -> apksigner.
3
+ plugins { id 'base' }
4
+ tasks.register('assembleDebug', Exec) {
5
+ workingDir rootProject.projectDir
6
+ commandLine providers.gradleProperty('node').getOrElse('node'), 'sdk-build.mjs'
7
+ }
@@ -0,0 +1,8 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.apphx.runtime">
2
+ <uses-sdk android:minSdkVersion="23" android:targetSdkVersion="35" />
3
+ <application android:label="apphx Local Records" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:debuggable="true">
4
+ <activity android:name=".MainActivity" android:exported="true">
5
+ <intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
6
+ </activity>
7
+ </application>
8
+ </manifest>
@@ -0,0 +1,44 @@
1
+ package dev.apphx.runtime;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Bundle;
5
+ import android.content.SharedPreferences;
6
+ import android.widget.Button;
7
+ import android.widget.EditText;
8
+ import android.widget.LinearLayout;
9
+ import android.widget.TextView;
10
+
11
+ public final class MainActivity extends Activity {
12
+ @Override public void onCreate(Bundle state) {
13
+ super.onCreate(state);
14
+ SharedPreferences records = getSharedPreferences("records", MODE_PRIVATE);
15
+ LinearLayout layout = new LinearLayout(this);
16
+ layout.setOrientation(LinearLayout.VERTICAL);
17
+ layout.setPadding(32, 64, 32, 32);
18
+ TextView title = new TextView(this);
19
+ title.setText("Local record notebook");
20
+ title.setTextSize(24);
21
+ EditText input = new EditText(this);
22
+ input.setSingleLine(true);
23
+ input.setHint("Record text");
24
+ input.setContentDescription("Record text");
25
+ TextView saved = new TextView(this);
26
+ saved.setTextSize(22);
27
+ saved.setText(records.getString("record", "No saved record"));
28
+ saved.setContentDescription("Saved record");
29
+ Button save = new Button(this);
30
+ save.setText("Save record");
31
+ save.setContentDescription("Save record");
32
+ save.setOnClickListener(view -> {
33
+ String value = input.getText().toString().trim();
34
+ if (value.isEmpty()) { input.setError("Enter a record"); return; }
35
+ if (!records.edit().putString("record", value).commit()) {
36
+ input.setError("Could not save record"); return;
37
+ }
38
+ saved.setText(value);
39
+ input.setText("");
40
+ });
41
+ layout.addView(title); layout.addView(input); layout.addView(save); layout.addView(saved);
42
+ setContentView(layout);
43
+ }
44
+ }
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
3
+ import { delimiter, join, resolve } from 'node:path';
4
+
5
+ const sdk = process.env.ANDROID_SDK_ROOT;
6
+ const java = process.env.JAVA_HOME;
7
+ if (!sdk || !java) throw new Error('ANDROID_SDK_ROOT and JAVA_HOME must select installed SDK/JDK; this driver never installs tools or accepts licenses');
8
+ const tools = join(sdk, 'build-tools', process.env.APPHX_ANDROID_BUILD_TOOLS ?? '35.0.0');
9
+ const android = join(sdk, 'platforms', process.env.APPHX_ANDROID_PLATFORM ?? 'android-35', 'android.jar');
10
+ const build = resolve('build');
11
+ mkdirSync(join(build, 'classes'), { recursive: true });
12
+ mkdirSync(join(build, 'dex'), { recursive: true });
13
+ function run(executable, args, cwd = process.cwd()) {
14
+ const result = spawnSync(executable, args, { cwd, env: process.env, encoding: 'utf8', timeout: 120000 });
15
+ process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? '');
16
+ if (result.error || result.status !== 0) throw result.error ?? new Error(`${executable} exited ${result.status}`);
17
+ }
18
+ const unsigned = join(build, 'unsigned.apk');
19
+ rmSync(unsigned, { force: true });
20
+ run(join(tools, 'aapt'), ['package', '-f', '-M', 'app/src/main/AndroidManifest.xml', '-I', android, '-F', unsigned]);
21
+ run(join(java, 'bin/javac'), ['-source', '8', '-target', '8', '-bootclasspath', [android, join(tools, 'core-lambda-stubs.jar')].join(delimiter), '-d', join(build, 'classes'), 'app/src/main/java/dev/apphx/runtime/MainActivity.java']);
22
+ run(join(tools, 'd8'), ['--lib', android, '--min-api', '23', '--output', join(build, 'dex'), join(build, 'classes/dev/apphx/runtime/MainActivity.class')]);
23
+ run('/usr/bin/zip', ['-q', unsigned, 'classes.dex'], join(build, 'dex'));
24
+ run(join(tools, 'zipalign'), ['-f', '4', unsigned, join(build, 'aligned.apk')]);
25
+ const key = join(build, 'local-only.p12');
26
+ if (!existsSync(key)) run(join(java, 'bin/keytool'), ['-genkeypair', '-keystore', key, '-storepass', 'local-smoke-only', '-keypass', 'local-smoke-only', '-alias', 'smoke', '-keyalg', 'RSA', '-keysize', '2048', '-validity', '2', '-dname', 'CN=Local runtime smoke']);
27
+ run(join(tools, 'apksigner'), ['sign', '--ks', key, '--ks-pass', 'pass:local-smoke-only', '--out', join(build, 'records.apk'), join(build, 'aligned.apk')]);
28
+ run(join(tools, 'apksigner'), ['verify', '--verbose', '--print-certs', join(build, 'records.apk')]);
29
+ run(join(tools, 'aapt'), ['dump', 'badging', join(build, 'records.apk')]);
@@ -0,0 +1,2 @@
1
+ rootProject.name = 'apphx-runtime-records'
2
+ include ':app'
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+
6
+ const session = JSON.parse(readFileSync(process.argv[2], 'utf8'));
7
+ assert.match(session.avd, /^apphx-smoke-[a-f0-9]+$/);
8
+ assert.match(session.serial, /^emulator-\d+$/);
9
+ assert.ok(session.port > 1024 && session.port !== 5037);
10
+ function adb(args, binary = false) {
11
+ const result = spawnSync(join(session.sdk, 'platform-tools/adb'), ['-P', String(session.port), '-s', session.serial, ...args], { encoding: binary ? undefined : 'utf8', timeout: 45000 });
12
+ if (result.error || result.status !== 0) throw result.error ?? new Error(`${args.join(' ')}: ${result.stderr}`);
13
+ return result.stdout;
14
+ }
15
+ assert.equal(adb(['emu', 'avd', 'name']).split(/\r?\n/)[0], session.avd, 'Refusing non-owned emulator');
16
+ const app = 'dev.apphx.runtime';
17
+ const token = 'record-' + session.avd.slice(-12);
18
+ mkdirSync('build/screenshots', { recursive: true });
19
+ const pause = () => new Promise(resolve => setTimeout(resolve, 500));
20
+ async function tree() {
21
+ adb(['shell', 'uiautomator', 'dump', '/sdcard/apphx-window.xml']);
22
+ const xml = adb(['shell', 'cat', '/sdcard/apphx-window.xml']);
23
+ writeFileSync('build/screenshots/latest.xml', xml);
24
+ return xml;
25
+ }
26
+ function tap(xml, label) {
27
+ const node = [...xml.matchAll(/<node\b[^>]*>/g)].map(match => match[0]).find(node => node.includes(`text="${label}"`) || node.includes(`content-desc="${label}"`));
28
+ assert.ok(node, `UI control missing: ${label}`);
29
+ const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
30
+ assert.ok(bounds);
31
+ adb(['shell', 'input', 'tap', String(Math.floor((+bounds[1] + +bounds[3]) / 2)), String(Math.floor((+bounds[2] + +bounds[4]) / 2))]);
32
+ }
33
+ function screenshot(name) {
34
+ const png = adb(['exec-out', 'screencap', '-p'], true);
35
+ assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
36
+ assert.ok(png.length > 1000);
37
+ writeFileSync(`build/screenshots/${name}.png`, png);
38
+ }
39
+ try {
40
+ adb(['install', '-r', 'build/records.apk']);
41
+ adb(['shell', 'pm', 'clear', app]);
42
+ adb(['shell', 'am', 'start', '-W', '-n', `${app}/.MainActivity`]);
43
+ await pause();
44
+ let xml = await tree();
45
+ assert.ok(xml.includes('No saved record'));
46
+ tap(xml, 'Record text');
47
+ await new Promise(resolve => setTimeout(resolve, 1500));
48
+ for (const character of token) {
49
+ adb(['shell', 'input', 'text', character]);
50
+ await new Promise(resolve => setTimeout(resolve, 80));
51
+ }
52
+ assert.ok((await tree()).includes(`text="${token}"`), 'Native text input did not receive the complete record');
53
+ tap(await tree(), 'Save record');
54
+ await pause();
55
+ xml = await tree();
56
+ assert.ok(xml.includes(`text="${token}"`), 'Save did not reach visible UI');
57
+ writeFileSync('build/screenshots/saved.xml', xml); screenshot('saved');
58
+ adb(['shell', 'am', 'force-stop', app]);
59
+ adb(['shell', 'am', 'start', '-W', '-n', `${app}/.MainActivity`]);
60
+ await pause();
61
+ xml = await tree();
62
+ assert.ok(xml.includes(`text="${token}"`), 'Relaunch lost persisted record');
63
+ const preferences = adb(['shell', 'run-as', app, 'cat', 'shared_prefs/records.xml']);
64
+ assert.ok(preferences.includes(token));
65
+ writeFileSync('build/screenshots/relaunch.xml', xml); screenshot('relaunch');
66
+ writeFileSync('build/runtime-evidence.json', JSON.stringify({ token, avd: session.avd, serial: session.serial, adbPort: session.port, saved: true, relaunched: true, preferences }, null, 2));
67
+ console.log('APPHX_REAL_ANDROID ' + JSON.stringify({ token, visibleSave: true, persistedRelaunch: true }));
68
+ } finally {
69
+ adb(['shell', 'am', 'force-stop', app]);
70
+ }
@@ -0,0 +1,13 @@
1
+ module github.com/egoist/mygo/apphx-smoke
2
+
3
+ go 1.27.1
4
+
5
+ require github.com/egoist/mygo v0.3.0
6
+
7
+ require (
8
+ github.com/ebitengine/purego v0.11.1 // indirect
9
+ github.com/go-text/typesetting v0.3.5 // indirect
10
+ golang.org/x/image v0.46.0 // indirect
11
+ )
12
+
13
+ tool github.com/egoist/mygo/cmd/mygo
@@ -0,0 +1,10 @@
1
+ github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
2
+ github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
3
+ github.com/egoist/mygo v0.3.0 h1:57FdVGkm60TWBkGIbWb/S0CpppYPFjI7+vKuYsBezIY=
4
+ github.com/egoist/mygo v0.3.0/go.mod h1:1sRzZ6jEhKE7iuEQXjyoXDgxyxQ1uTgMHDcDKZvoDIU=
5
+ github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
6
+ github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
7
+ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
8
+ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
9
+ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
10
+ golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
@@ -0,0 +1,173 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "flag"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "time"
10
+
11
+ "github.com/ebitengine/purego/objc"
12
+ "github.com/egoist/mygo"
13
+ "github.com/egoist/mygo/internal/darwin"
14
+ "github.com/egoist/mygo/ui"
15
+ )
16
+
17
+ type counter struct { value int; stateFile string; failure error; frames int; callbacks int; diagnostic bool }
18
+ func (c *counter) change(value int) {
19
+ c.callbacks++
20
+ if c.diagnostic { fmt.Fprintf(os.Stderr,"APPHX_NATIVE_CALLBACK frame=%d callback=%d from=%d to=%d\n",c.frames,c.callbacks,c.value,value) }
21
+ c.value = value
22
+ if c.stateFile != "" { c.failure = os.WriteFile(c.stateFile, []byte(fmt.Sprint(value)), 0600) }
23
+ }
24
+ func (c *counter) view(ctx *ui.Context) {
25
+ c.frames++
26
+ if c.diagnostic { fmt.Fprintf(os.Stderr,"APPHX_NATIVE_VIEW frame=%d value=%d callbacks=%d\n",c.frames,c.value,c.callbacks) }
27
+ ui.Column(ctx).Fill().Center().Gap(16).Children(func() {
28
+ ui.Text(ctx, "MyGo · apphx desktop acceptance").FontSize(16)
29
+ ui.Textf(ctx, "%d", c.value).FontSize(52).Bold()
30
+ ui.Row(ctx).Gap(12).Children(func() {
31
+ ui.Button(ctx.Key("decrement"), "−").Label("Decrement").OnClick(func() { c.change(c.value - 1) })
32
+ ui.Button(ctx.Key("reset"), "Reset").Label("Reset").OnClick(func() { c.change(0) })
33
+ ui.PrimaryButton(ctx.Key("increment"), "+").Label("Increment").OnClick(func() { c.change(c.value + 1) })
34
+ })
35
+ ui.Text(ctx, "Native macOS window · local ad-hoc build").FontSize(12)
36
+ })
37
+ }
38
+ func nativePress(handle uintptr, label string) (allowed, accepted bool) {
39
+ children := objc.RegisterName("accessibilityChildren")
40
+ title := objc.RegisterName("accessibilityTitle")
41
+ name := objc.RegisterName("accessibilityLabel")
42
+ utf8 := objc.RegisterName("UTF8String")
43
+ press := objc.RegisterName("accessibilityPerformPress")
44
+ var walk func(objc.ID) bool
45
+ walk = func(node objc.ID) bool {
46
+ text := func(selector objc.SEL) string { value := node.Send(selector); if value == 0 { return "" }; return objc.Send[string](value, utf8) }
47
+ if text(name)+text(title) == label {
48
+ allowed = objc.Send[bool](node,objc.RegisterName("isAccessibilitySelectorAllowed:"),press)
49
+ if allowed { accepted = objc.Send[bool](node,press) }
50
+ return true
51
+ }
52
+ list := node.Send(children)
53
+ for i,n := uint64(0),objc.Send[uint64](list,objc.RegisterName("count")); i<n; i++ { if walk(list.Send(objc.RegisterName("objectAtIndex:"),i)) { return true } }
54
+ return false
55
+ }
56
+ walk(objc.ID(handle).Send(objc.RegisterName("contentView")))
57
+ return
58
+ }
59
+ func nativeWindowState(handle uintptr) map[string]any {
60
+ window := objc.ID(handle)
61
+ application := objc.ID(objc.GetClass("NSApplication")).Send(objc.RegisterName("sharedApplication"))
62
+ return map[string]any{"occlusionState": objc.Send[uint64](window,objc.RegisterName("occlusionState")), "isVisible": objc.Send[bool](window,objc.RegisterName("isVisible")), "isKeyWindow": objc.Send[bool](window,objc.RegisterName("isKeyWindow")), "isOnActiveSpace": objc.Send[bool](window,objc.RegisterName("isOnActiveSpace")), "applicationActive": objc.Send[bool](application,objc.RegisterName("isActive"))}
63
+ }
64
+ func nativeReady(state map[string]any) bool {
65
+ return state["isVisible"].(bool) && state["occlusionState"].(uint64)&2 != 0 && state["applicationActive"].(bool) && state["isKeyWindow"].(bool)
66
+ }
67
+ func main() {
68
+ mode := flag.String("acceptance", "", "first or relaunch actual native-window acceptance")
69
+ stateFile := flag.String("state-file", "", "isolated counter state")
70
+ screenshot := flag.String("screenshot", "", "actual window content PNG")
71
+ flag.Parse()
72
+ c := &counter{stateFile: *stateFile, diagnostic: *mode != ""}
73
+ if bytes, err := os.ReadFile(*stateFile); err == nil { if _, err := fmt.Sscan(string(bytes), &c.value); err != nil { panic(err) } } else if !os.IsNotExist(err) { panic(err) }
74
+ type outcome struct { Report map[string]any; Err error }
75
+ complete := make(chan outcome, 1)
76
+ mygo.App.WhenReady(func() {
77
+ window := mygo.NewWindow(mygo.WindowOptions{Title: "apphx MyGo acceptance", Width: 520, Height: 340, Content: ui.View(c.view)})
78
+ if *mode == "" { return }
79
+ go func() {
80
+ report := map[string]any{"mode": *mode}
81
+ fail := func(err error) {
82
+ mygo.RunOnMain(func() { report["failureFrames"] = c.frames; report["failureCallbacks"] = c.callbacks; report["failureTree"] = darwin.TestAccessibility(window.NativeHandle()); report["failureNativeWindow"] = nativeWindowState(window.NativeHandle()) })
83
+ complete <- outcome{Report: report, Err: err}; mygo.App.Quit()
84
+ }
85
+ deadline := time.Now().Add(20*time.Second)
86
+ ready := false
87
+ focusRequested := false
88
+ for time.Now().Before(deadline) {
89
+ mygo.RunOnMain(func() {
90
+ found := false
91
+ for _, node := range darwin.TestAccessibility(window.NativeHandle()) { if node.Role == "AXButton" && node.Label == "Increment" && node.Frame[2] > 0 && node.Frame[3] > 0 { found = true } }
92
+ // A shown AX tree can belong to an occluded window whose display link does not tick.
93
+ if found && c.frames > 0 && !focusRequested { report["focusRequestedAfterFrame"] = c.frames; window.Focus(); focusRequested = true }
94
+ state := nativeWindowState(window.NativeHandle())
95
+ ready = found && nativeReady(state)
96
+ })
97
+ if ready { break }
98
+ time.Sleep(25*time.Millisecond)
99
+ }
100
+ if !ready || !window.IsVisible() { fail(fmt.Errorf("actual visible native accessibility tree did not become ready")); return }
101
+ report["visible"] = window.IsVisible()
102
+ mygo.RunOnMain(func() { report["readyFrames"] = c.frames; report["readyTree"] = darwin.TestAccessibility(window.NativeHandle()); report["readyNativeWindow"] = nativeWindowState(window.NativeHandle()) })
103
+ width,height := window.ContentSize(); report["width"] = width; report["height"] = height
104
+ initial := 0
105
+ mygo.RunOnMain(func() { initial = c.value })
106
+ report["initial"] = initial
107
+ expected := 0
108
+ labels := []string{"Increment", "Increment", "Decrement"}
109
+ final := 1
110
+ if *mode == "relaunch" { expected = 1; labels = []string{"Reset"}; final = 0 }
111
+ if initial != expected { fail(fmt.Errorf("persisted initial value=%d want=%d",initial,expected)); return }
112
+ wanted := initial
113
+ transitions := []int{}
114
+ for index, label := range labels {
115
+ performed := false
116
+ var actionError error
117
+ mygo.RunOnMain(func() {
118
+ state := nativeWindowState(window.NativeHandle())
119
+ if !nativeReady(state) { actionError = fmt.Errorf("native foreground readiness lost before %s: %v",label,state); return }
120
+ beforeFrames,beforeCallbacks := c.frames,c.callbacks
121
+ allowed,accepted := nativePress(window.NativeHandle(),label)
122
+ performed = accepted
123
+ fmt.Fprintf(os.Stderr,"APPHX_NATIVE_SELECTOR label=%s allowed=%v accepted=%v framesBefore=%d framesAfter=%d callbacksBefore=%d callbacksAfter=%d value=%d\n",label,allowed,accepted,beforeFrames,c.frames,beforeCallbacks,c.callbacks,c.value)
124
+ })
125
+ if actionError != nil { fail(actionError); return }
126
+ if !performed { fail(fmt.Errorf("real accessibility action did not reach %s",label)); return }
127
+ switch label { case "Increment": wanted++; case "Decrement": wanted--; case "Reset": wanted = 0 }
128
+ deadline := time.Now().Add(5*time.Second)
129
+ observed,callbacks := initial,0
130
+ rendered := false
131
+ for time.Now().Before(deadline) {
132
+ mygo.RunOnMain(func() {
133
+ observed,callbacks = c.value,c.callbacks
134
+ rendered = false
135
+ for _, node := range darwin.TestAccessibility(window.NativeHandle()) { if node.Role == "AXStaticText" && node.Label == fmt.Sprint(wanted) && node.Value == fmt.Sprint(wanted) { rendered = true } }
136
+ })
137
+ if observed == wanted && rendered && callbacks == index+1 { break }
138
+ time.Sleep(15*time.Millisecond)
139
+ }
140
+ if observed != wanted || !rendered || callbacks != index+1 { fail(fmt.Errorf("%s did not transition value=%d want=%d rendered=%v callbacks=%d wantCallbacks=%d",label,observed,wanted,rendered,callbacks,index+1)); return }
141
+ fmt.Fprintf(os.Stderr,"APPHX_NATIVE_TRANSITION label=%s value=%d rendered=%v callbacks=%d\n",label,observed,rendered,callbacks)
142
+ transitions = append(transitions,observed)
143
+ }
144
+ report["transitions"] = transitions
145
+ observed := 0
146
+ var persistenceError error
147
+ mygo.RunOnMain(func() { observed = c.value; persistenceError = c.failure })
148
+ if persistenceError != nil || observed != final { fail(fmt.Errorf("native action state=%d want=%d persistence=%v",observed,final,persistenceError)); return }
149
+ report["actions"] = labels; report["final"] = observed
150
+ mygo.RunOnMain(func() { report["finalNativeWindow"] = nativeWindowState(window.NativeHandle()); report["finalFrames"] = c.frames; report["finalCallbacks"] = c.callbacks })
151
+ if !nativeReady(report["finalNativeWindow"].(map[string]any)) { fail(fmt.Errorf("native foreground readiness lost after actions")); return }
152
+ if *screenshot != "" {
153
+ bytes,err := window.CapturePage()
154
+ if err != nil { fail(err); return }
155
+ captureReady := false
156
+ mygo.RunOnMain(func() { captureReady = nativeReady(nativeWindowState(window.NativeHandle())) })
157
+ if !captureReady { fail(fmt.Errorf("native foreground readiness lost during screenshot capture")); return }
158
+ if err := os.MkdirAll(filepath.Dir(*screenshot),0700); err != nil { fail(err); return }
159
+ if err := os.WriteFile(*screenshot,bytes,0600); err != nil { fail(err); return }
160
+ report["screenshot"] = *screenshot
161
+ }
162
+ complete <- outcome{Report: report}
163
+ mygo.App.Quit()
164
+ }()
165
+ })
166
+ if err := mygo.App.Run(); err != nil { panic(err) }
167
+ if *mode != "" {
168
+ result := <-complete
169
+ bytes,_ := json.Marshal(result.Report)
170
+ fmt.Println("APPHX_REAL_MYGO_UI "+string(bytes))
171
+ if result.Err != nil { fmt.Fprintln(os.Stderr,result.Err); os.Exit(1) }
172
+ }
173
+ }
@@ -0,0 +1,14 @@
1
+ package main
2
+
3
+ import (
4
+ "testing"
5
+ "github.com/egoist/mygo/ui"
6
+ )
7
+ func TestCounterView(t *testing.T) {
8
+ state := &counter{}
9
+ view := ui.NewTester(state.view, 520, 340)
10
+ for _, label := range []string{"Increment", "Increment", "Decrement"} { if err := view.Click(label); err != nil { t.Fatal(err) } }
11
+ if state.value != 1 || !view.HasText("1") { t.Fatalf("counter=%d",state.value) }
12
+ if err := view.Click("Reset"); err != nil { t.Fatal(err) }
13
+ if state.value != 0 { t.Fatalf("reset counter=%d",state.value) }
14
+ }
@@ -0,0 +1 @@
1
+ {"name":"MyGo App","identifier":"org.example.apphx.mygo.smoke","version":"0.1.0","out":"build","macos":{"signingIdentity":"-"}}
@@ -0,0 +1,15 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "apphx-mygo-web-smoke",
7
+ "dependencies": {
8
+ "mygo-runtime": "0.3.0",
9
+ },
10
+ },
11
+ },
12
+ "packages": {
13
+ "mygo-runtime": ["mygo-runtime@0.3.0", "", {}, "sha512-/GOa5qX6HmbUFDQlJ/ZPRUDXW5wURuTfH/aPNprTH19nibnblEIP+MIvfnDotK3ZYfXUcfkiPpZouXtRDseagA=="],
14
+ }
15
+ }
@@ -0,0 +1,9 @@
1
+ module example.com/apphx-mygo-web
2
+
3
+ go 1.27.1
4
+
5
+ require github.com/egoist/mygo v0.3.0
6
+
7
+ require github.com/ebitengine/purego v0.11.1 // indirect
8
+
9
+ tool github.com/egoist/mygo/cmd/mygo
@@ -0,0 +1,8 @@
1
+ github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
2
+ github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
3
+ github.com/egoist/mygo v0.3.0 h1:57FdVGkm60TWBkGIbWb/S0CpppYPFjI7+vKuYsBezIY=
4
+ github.com/egoist/mygo v0.3.0/go.mod h1:1sRzZ6jEhKE7iuEQXjyoXDgxyxQ1uTgMHDcDKZvoDIU=
5
+ github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
6
+ github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
7
+ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
8
+ golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
@@ -0,0 +1 @@
1
+ <!doctype html><html><head><meta charset="UTF-8"><title>MyGo Web Acceptance</title><style>:root{font-family:system-ui;color-scheme:light dark}body{margin:0;padding:40px}main{max-width:460px;margin:auto}button{font:inherit;padding:10px 16px;margin:8px 8px 8px 0}output{display:block;font-size:48px;margin:24px 0}#status{min-height:30px}</style></head><body><main><h1>MyGo + WKWebView</h1><p>Compiled Go service with a bundled TypeScript frontend.</p><output id="count" aria-live="polite">0</output><button id="add">Add 3</button><button id="subtract">Subtract 10</button><p id="status" role="status">Ready</p></main><script type="module" src="./main.js"></script></body></html>
@@ -0,0 +1,60 @@
1
+ package main
2
+ import (
3
+ "context"
4
+ "encoding/json"
5
+ "errors"
6
+ "flag"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "time"
11
+ "github.com/egoist/mygo"
12
+ )
13
+ type Counter struct { value int; state string }
14
+ func(c *Counter)Change(delta int)(int,error){
15
+ if c.value+delta<0{return c.value,errors.New("negative totals are rejected")}
16
+ next:=c.value+delta
17
+ if c.state!=""{if err:=os.MkdirAll(filepath.Dir(c.state),0700);err!=nil{return c.value,err};if err:=os.WriteFile(c.state,[]byte(fmt.Sprint(next)),0600);err!=nil{return c.value,err}}
18
+ c.value=next;return c.value,nil
19
+ }
20
+ func(c *Counter)Value()int{return c.value}
21
+ func main(){
22
+ acceptance:=flag.Bool("acceptance",false,"run real WKWebView acceptance")
23
+ screenshot:=flag.String("screenshot","","window PNG")
24
+ state:=flag.String("state-file","","durable counter file")
25
+ relaunch:=flag.Bool("relaunch",false,"verify persisted state without adding")
26
+ flag.Parse()
27
+ service:=&Counter{state:*state}
28
+ if *state!=""{data,err:=os.ReadFile(*state);if err==nil{if _,err=fmt.Sscan(string(data),&service.value);err!=nil{panic(err)}}else if !os.IsNotExist(err){panic(err)}}
29
+ mygo.Bind(service)
30
+ type outcome struct{report map[string]any;err error};done:=make(chan outcome,1)
31
+ mygo.App.WhenReady(func(){
32
+ window:=mygo.NewWindow(mygo.WindowOptions{Title:"apphx MyGo Web acceptance",Width:640,Height:440,URL:"/"})
33
+ if !*acceptance{return}
34
+ go func(){
35
+ ctx,cancel:=context.WithTimeout(context.Background(),25*time.Second);defer cancel()
36
+ finish:=func(report map[string]any,err error){done<-outcome{report,err};mygo.App.Quit()}
37
+ ready:=false
38
+ for ctx.Err()==nil{value,err:=window.Page().EvalContext(ctx,"window.smokeReady === true");if err==nil&&value==true{ready=true;break};time.Sleep(25*time.Millisecond)}
39
+ if !ready||!window.IsVisible(){finish(nil,fmt.Errorf("real visible WKWebView did not become ready"));return}
40
+ startup,err:=mygo.EvalAs[map[string]any](window.Page(),"({visibleTotal:Number(document.querySelector('#count').textContent),ready:window.smokeReady === true})")
41
+ if err!=nil{finish(startup,err);return}
42
+ startup["goValue"]=service.Value()
43
+ startup["visible"]=window.IsVisible()
44
+ expected:=0;if *relaunch{expected=3}
45
+ if startup["visibleTotal"]!=float64(expected)||service.Value()!=expected{
46
+ if *screenshot!=""{if png,captureErr:=window.CapturePage();captureErr==nil{if os.MkdirAll(filepath.Dir(*screenshot),0700)==nil{_ = os.WriteFile(*screenshot+".startup.png",png,0600)}}}
47
+ finish(startup,fmt.Errorf("ordinary startup UI/backend mismatch before acceptance actions: %v; want %d",startup,expected));return
48
+ }
49
+ report,err:=mygo.EvalAs[map[string]any](window.Page(),fmt.Sprintf("window.runAcceptance(%t)",*relaunch))
50
+ if err!=nil{finish(report,err);return}
51
+ if report["goValue"]!=float64(3)||report["visibleTotal"]!=float64(3)||report["negativeRejected"]!=true{finish(report,fmt.Errorf("bridge/UI invariants failed: %v",report));return}
52
+ report["startup"]=startup
53
+ report["visible"]=window.IsVisible()
54
+ if *screenshot!=""{png,err:=window.CapturePage();if err!=nil{finish(report,err);return};if err=os.MkdirAll(filepath.Dir(*screenshot),0700);err!=nil{finish(report,err);return};if err=os.WriteFile(*screenshot,png,0600);err!=nil{finish(report,err);return};report["screenshot"]=*screenshot}
55
+ finish(report,nil)
56
+ }()
57
+ })
58
+ if err:=mygo.App.Run();err!=nil{panic(err)}
59
+ if *acceptance{result:=<-done;bytes,_:=json.Marshal(result.report);fmt.Println("APPHX_REAL_MYGO_WEB "+string(bytes));if result.err!=nil{fmt.Fprintln(os.Stderr,result.err);os.Exit(1)}}
60
+ }
@@ -0,0 +1,10 @@
1
+ export default {
2
+ name: "MyGo Web App",
3
+ identifier: "org.example.apphx.mygo.web.smoke",
4
+ version: "0.1.0",
5
+ buildCommand: "bun run build:web",
6
+ frontendDist: "dist",
7
+ bindings: "src/mygo.ts",
8
+ out: "build",
9
+ macos: { signingIdentity: "-" },
10
+ };
@@ -0,0 +1 @@
1
+ {"name":"apphx-mygo-web-smoke","private":true,"scripts":{"build:web":"bun build ./src/main.ts --outdir dist && cp index.html dist/index.html"},"dependencies":{"mygo-runtime":"0.3.0"}}