mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 18:01:47 +01:00
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed / Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install chooser for catalogs, Steam, Linux apps, files and install links. Installed rows show identity, one state and Open/Stop; the rest moves to app details. Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper delivery and catalogue jobs stay bound to the headset they were meant for; report preview follows the latest consent; failed Store reads are not an empty library; keyboard access to device details and the Store sheet; the iOS bundle ships the artwork renderer and OpenXR resources; typed addresses and custom SSH ports survive setup; WebKit crashes recover; native preferences survive relaunch; APK update rollback and repair stay retryable. Refs #101, #104, #106 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
9.3 KiB
Python
176 lines
9.3 KiB
Python
"""iPhone packaging and Foundation contracts; no headset, downloads or Xcode build."""
|
|
import sandbox # noqa: F401
|
|
import fnmatch
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class BundleContracts(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
spec = importlib.util.spec_from_file_location("ios_bundle_contract", ROOT / "ios/scripts/make_frame_bundle.py")
|
|
cls.bundle = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(cls.bundle)
|
|
cls.tmp = tempfile.TemporaryDirectory()
|
|
cls.root = Path(cls.tmp.name)
|
|
with tarfile.open(fileobj=io.BytesIO(cls.bundle.build()), mode="r:gz") as archive:
|
|
archive.extractall(cls.root)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.tmp.cleanup()
|
|
|
|
def test_required_resources_ship_to_both_clients(self):
|
|
resources = ["frame/android/library_artwork.js", "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
|
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"]
|
|
config = json.loads((ROOT / "app/package.json").read_text())
|
|
for resource in resources:
|
|
with self.subTest(resource=resource):
|
|
self.assertTrue((self.root / resource).is_file(), resource)
|
|
# Match the desktop's real resource filters as well as the phone archive.
|
|
packaged = False
|
|
for entry in config["build"]["extraResources"]:
|
|
source = entry["from"].removeprefix("../")
|
|
if Path(resource).is_relative_to(source):
|
|
relative = str(Path(resource).relative_to(source))
|
|
packaged |= any(fnmatch.fnmatch(relative, pattern) for pattern in entry.get("filter", ["**/*"]))
|
|
self.assertTrue(packaged, "desktop resource contract: " + resource)
|
|
|
|
def test_extracted_runtime_can_render_and_load_xr_layer(self):
|
|
result = subprocess.run([sys.executable, "-I", "-B", "-c", """
|
|
import sys, zipfile
|
|
from unittest.mock import patch
|
|
sys.path.insert(0, 'ui')
|
|
import frame_android as android
|
|
with patch.object(android, 'ssh', return_value='rendered') as ssh:
|
|
assert android.shortcut_tool('render', '/synthetic/app') == 'rendered'
|
|
assert 'ART_RENDERER = ' in ssh.call_args.kwargs['input']
|
|
assert len(ssh.call_args.kwargs['input']) > 1000
|
|
with zipfile.ZipFile('synthetic.apk', 'w') as apk:
|
|
apk.writestr('lib/arm64-v8a/libopenxr_loader.so', b'synthetic loader')
|
|
files = android.xr_compat_files('synthetic.apk')
|
|
assert set(files) == set(android.XR_COMPAT_FILES)
|
|
assert files['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\\x7fELF')
|
|
"""], cwd=self.root, capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
class NativeContracts(unittest.TestCase):
|
|
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS Swift compiles actual Foundation production sources")
|
|
def test_foundation_contracts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
main = Path(tmp) / "main.swift"
|
|
main.write_text("import XCTest\nlet suite = XCTestSuite(forTestCaseClass: NativeSessionTests.self)\nsuite.run()\nexit(suite.testRun!.hasSucceeded ? 0 : 1)\n")
|
|
executable = Path(tmp) / "native-contracts"
|
|
developer = subprocess.check_output(["xcode-select", "-p"], text=True).strip()
|
|
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
|
|
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
|
|
shared = str(Path(developer).parent / "SharedFrameworks")
|
|
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
|
|
"-Xlinker", "-rpath", "-Xlinker", libraries,
|
|
"-Xlinker", "-rpath", "-Xlinker", shared,
|
|
"-D", "NATIVE_CONTRACT_STANDALONE",
|
|
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
|
str(ROOT / "ios/FrameControlTests/NativeSessionTests.swift"),
|
|
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn("Executed 5 tests", result.stdout + result.stderr)
|
|
|
|
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS exercises the production finder with controlled probes")
|
|
def test_finder_ignores_late_probes_after_edit_and_stop(self):
|
|
harness = r'''import Foundation
|
|
actor Probes {
|
|
var pending: [String: CheckedContinuation<Bool, Never>] = [:]
|
|
func probe(_ host: String) async -> Bool {
|
|
await withCheckedContinuation { pending[host] = $0 }
|
|
}
|
|
func has(_ host: String) -> Bool { pending[host] != nil }
|
|
func answer(_ host: String) { pending.removeValue(forKey: host)!.resume(returning: true) }
|
|
}
|
|
@main struct FinderContracts {
|
|
@MainActor static func wait(_ condition: () async -> Bool) async {
|
|
for _ in 0..<100 {
|
|
if await condition() { return }
|
|
try! await Task.sleep(nanoseconds: 5_000_000)
|
|
}
|
|
fatalError("controlled callback did not run")
|
|
}
|
|
@MainActor static func main() async {
|
|
let probes = Probes()
|
|
let finder = FrameFinder(probe: { await probes.probe($0) }, discoveryEnabled: false)
|
|
finder.start(fallback: "old.local")
|
|
await wait { await probes.has("old.local") }
|
|
finder.start(fallback: "100.100.100.2:2222", manual: true)
|
|
await wait { await probes.has("100.100.100.2:2222") }
|
|
await probes.answer("old.local")
|
|
// Let the cancelled task return its deliberately late successful answer.
|
|
try! await Task.sleep(nanoseconds: 20_000_000)
|
|
precondition(finder.found == nil, "A stale probe replaced the edited target")
|
|
await probes.answer("100.100.100.2:2222")
|
|
await wait { finder.found != nil }
|
|
precondition(finder.found?.host == "100.100.100.2:2222")
|
|
finder.start(fallback: "stopped.local", manual: true)
|
|
await wait { await probes.has("stopped.local") }
|
|
finder.stop()
|
|
await probes.answer("stopped.local")
|
|
try! await Task.sleep(nanoseconds: 20_000_000)
|
|
precondition(finder.found == nil, "A stopped search published a result")
|
|
print("Production FrameFinder: stale and cancelled probes ignored; manual target retained")
|
|
}
|
|
}
|
|
'''
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
main = Path(tmp) / "FinderContracts.swift"
|
|
main.write_text(harness)
|
|
executable = Path(tmp) / "finder-contracts"
|
|
result = subprocess.run(["swiftc", "-parse-as-library",
|
|
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
|
str(ROOT / "ios/FrameControl/SSH/FrameFinder.swift"),
|
|
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "Node executes the unmodified production bridge script")
|
|
def test_bridge_preferences_and_document_acknowledgement(self):
|
|
source = (ROOT / "ios/FrameControl/Web/WebShell.swift").read_text()
|
|
bridge = source.split('static let bridge = """', 1)[1].split('"""', 1)[0]
|
|
script = r'''const vm = require('node:vm');
|
|
const assert = require('node:assert/strict');
|
|
const calls = [];
|
|
const window = {webkit: {messageHandlers: {frameApp: {postMessage: body => {calls.push(body); return Promise.resolve(true)}}}}};
|
|
vm.runInNewContext(process.argv[1], {window, crypto: {randomUUID: () => 'document-42'}});
|
|
assert.equal(window.__frameInstallLink({}), false);
|
|
window.frameApp.preferences.read();
|
|
window.frameApp.preferences.write('mvSeparate', '0');
|
|
assert.equal(JSON.stringify(calls), JSON.stringify([
|
|
{name: 'preferencesRead', arg: null}, {name: 'preferencesWrite', arg: {key: 'mvSeparate', value: '0'}}
|
|
]));
|
|
const received = [];
|
|
window.frameApp.onInstallLink(req => received.push(req));
|
|
assert.equal(calls.at(-1).name, 'installLinkReady');
|
|
assert.equal(calls.at(-1).arg, 'document-42');
|
|
assert.equal(window.__frameInstallLink({target: 'a'}), true);
|
|
assert.equal(received[0].target, 'a');
|
|
window.frameApp.onInstallLink(() => {throw new Error('callback failed')});
|
|
assert.throws(() => window.__frameInstallLink({}), /callback failed/);
|
|
'''
|
|
result = subprocess.run(["node", "-e", script, bridge], capture_output=True, text=True, timeout=15)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|