[feat] add mobile update badges and iOS simulator support

This commit is contained in:
dijunkun
2026-10-04 01:38:08 +08:00
parent 115533cd62
commit b8f704abb9
25 changed files with 766 additions and 61 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Run the production event-driven update checker with a deterministic transport."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(sys.platform == "darwin", "requires the Apple Swift runtime")
class AppUpdateCheckerTest(unittest.TestCase):
def test_visit_session_end_failures_and_lifecycle(self):
source = Path(__file__).resolve().parents[1] / "CrossDeskMobile/AppUpdateChecker.swift"
with tempfile.TemporaryDirectory(prefix="crossdesk-app-update-tests-") as directory:
harness = Path(directory) / "Tests.swift"
harness.write_text(r'''
import Foundation
// The actual shared C++ parser is covered by version_checker_test.cpp.
enum CrossDeskRTCBridge {
static func checkMobileUpdate(appVersion: String, releaseJSON: Data) -> String? {
String(data: releaseJSON, encoding: .utf8)
}
}
func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError(message) }
}
@MainActor
final class Fixture {
var replies: [(Data?) -> Void] = []
var cancellations = 0
lazy var checker = AppUpdateChecker(currentVersion: "0.0.1", fetch: { completion in
self.replies.append(completion)
return { self.cancellations += 1 }
})
func reply(_ value: String?) { replies.last!(value.map { Data($0.utf8) }) }
}
@main
enum Tests {
@MainActor static func main() {
let f = Fixture()
f.checker.checkNow()
expect(f.replies.isEmpty, "No checks without consent and foreground")
f.checker.setEnabled(true)
f.checker.checkNow()
f.checker.setEnabled(true)
expect(f.replies.count == 1, "Deduplicate concurrent checks")
f.reply("0.0.2")
expect(f.checker.updateAvailable && f.checker.status == .available, "Show badge")
f.checker.setEnabled(true)
expect(f.replies.count == 1, "Duplicate active callbacks do not recheck after completion")
f.checker.setEnabled(false)
f.checker.setEnabled(true)
expect(f.replies.count == 2, "Every new visit checks even immediately after success")
f.reply(nil)
expect(f.checker.status == .failed && f.checker.updateAvailable,
"Failed checks retain discovered update")
f.checker.setEnabled(true)
expect(f.replies.count == 2, "A failure does not restart the check")
// Session teardown and the About action both explicitly request a check.
f.checker.checkNow()
f.checker.checkNow()
expect(f.replies.count == 3, "Explicit events check again without a cooldown and deduplicate")
f.reply("")
expect(!f.checker.updateAvailable && f.checker.status == .upToDate,
"Successful current-version check clears badge")
f.checker.checkNow()
let stale = f.replies.last!
f.checker.setEnabled(false)
expect(f.cancellations == 1, "Background/revocation cancels request")
stale(Data("9.0.0".utf8))
expect(!f.checker.updateAvailable, "Ignore cancelled response")
f.checker.setEnabled(true)
stale(Data("9.0.0".utf8))
expect(f.checker.status == .checking, "Ignore old response during new request")
f.reply("0.0.2")
f.checker.setEnabled(false)
let beforeForeground = f.replies.count
f.checker.checkNow()
expect(f.replies.count == beforeForeground, "Session endings in background do not check")
f.checker.setEnabled(true)
expect(f.replies.count == beforeForeground + 1, "Check once on return from background")
expect(f.checker.updateAvailable, "Retain badge while rechecking")
f.checker.setEnabled(false)
}
}
''')
binary = Path(directory) / "app-update-tests"
subprocess.run(["xcrun", "swiftc", str(source), str(harness), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -133,6 +133,24 @@ elif args[0] == "show":
self.run_build()
self.assertTrue(all("-c" in args for args in self.config_calls()))
def test_simulator_uses_separate_archives_and_switches_back_to_device(self):
self.run_build()
device_archive = self.output.read_bytes()
self.env["PLATFORM_NAME"] = "iphonesimulator"
self.run_build()
simulator = self.ios / "Vendor/iphonesimulator/Release/libCrossDeskMiniRTC.a"
self.assertTrue(simulator.exists())
self.assertTrue((self.ios / ".xmake/wire-work-simulator/xmake.lua").exists())
signature = self.ios / ".xmake/minirtc-build-simulator/ios-config-signature"
self.assertIn("packages-simulator", signature.read_text())
for args in self.config_calls()[-2:]:
self.assertIn("--appledev=simulator", args)
self.assertIn("-simulator", args[args.index("-o") + 1])
self.env["PLATFORM_NAME"] = "iphoneos"
self.run_build()
self.assertEqual(self.output.read_bytes(), device_archive)
self.assertTrue(all("--appledev=iphone" in args for args in self.config_calls()[-2:]))
def test_aom_is_only_required_and_merged_when_enabled(self):
self.run_build()
manifest = Path(str(self.output) + ".inputs.sha256")
@@ -129,6 +129,21 @@ class LicenseValidationTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"):
self.verify()
def test_simulator_cache_keeps_dependency_and_archive_validation(self):
(self.root / "packages").rename(self.root / "packages-simulator")
self.archive = Path(str(self.archive).replace("/packages/", "/packages-simulator/"))
self.inputs.write_text(self.inputs.read_text().replace("/packages/", "/packages-simulator/"))
self.target.write_text(self.target.read_text().replace("/packages/", "/packages-simulator/"))
self.verify()
valid_target = self.target.read_text()
self.target.write_text(valid_target.replace("/asio/1.0/", "/asio/2.0/"))
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
self.verify()
self.target.write_text(valid_target)
self.archive.write_bytes(b"changed simulator archive")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):
self.verify()
def test_changed_native_archive_is_rejected(self):
self.archive.write_bytes(b"changed archive")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"):