From 3992ea56db16e600a2ed18a8b6e729eb3fd5e500 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 28 Sep 2026 14:16:47 +0800 Subject: [PATCH] [fix] decouple iOS license checks from minirtc revisions --- .github/workflows/build.yml | 5 + .../CrossDeskMobile.xcodeproj/project.pbxproj | 2 +- .../ThirdPartyLicensesView.swift | 27 ++- apps/ios/README.md | 33 ++-- .../scripts/generate_third_party_licenses.py | 8 +- apps/ios/scripts/prepare_source_metadata.py | 19 +++ apps/ios/tests/third_party_licenses_test.py | 161 ++++++++++++++++++ 7 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 apps/ios/tests/third_party_licenses_test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcac2efe..9908a777 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -422,6 +422,11 @@ jobs: with: python-version: "3.13" + - name: Verify iOS license catalog + run: | + python3 -B -m unittest discover -s apps/ios/tests -p 'third_party_licenses_test.py' -v + python3 -B apps/ios/scripts/generate_third_party_licenses.py + - name: Install xmake uses: xmake-io/github-action-setup-xmake@v1 with: diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index a9625fcd..b360f584 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -231,7 +231,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n"; + shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/iphoneos/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift index bdccfe67..184b3cf3 100644 --- a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift +++ b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift @@ -20,7 +20,19 @@ private struct LicensedComponent: Decodable, Identifiable { if id == "crossdesk" { return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" } - return version.count == 40 ? String(version.prefix(12)) : version + let currentVersion = SourceMetadata.bundled?.componentSources[id]?.version ?? version + return currentVersion.count == 40 ? String(currentVersion.prefix(12)) : currentVersion + } + + var resolvedSourceURL: URL { + if id == "crossdesk" { + return SourceMetadata.bundled?.sourceURL ?? sourceURL + } + return SourceMetadata.bundled?.componentSources[id]?.sourceURL ?? sourceURL + } + + var resolvedBuildSourceURL: URL? { + SourceMetadata.bundled?.componentSources[id]?.buildSourceURL ?? buildSourceURL } } @@ -40,6 +52,12 @@ private struct LicenseCatalog: Decodable { } } +struct ComponentSourceMetadata: Decodable { + let version: String? + let sourceURL: URL? + let buildSourceURL: URL? +} + struct SourceMetadata: Decodable { let revision: String let tag: String? @@ -47,6 +65,7 @@ struct SourceMetadata: Decodable { let sourceURL: URL let buildInstructionsURL: URL let privacyPolicyURL: URL + let componentSources: [String: ComponentSourceMetadata] static let bundled: SourceMetadata? = { guard let url = Bundle.main.url(forResource: "SourceMetadata", withExtension: "json"), @@ -137,10 +156,8 @@ private struct ComponentLicenseView: View { Text(component.name).font(.headline) Text(component.displayVersion).textSelection(.enabled) Text(component.license).foregroundStyle(.secondary) - Link("查看源码", destination: component.id == "crossdesk" - ? SourceMetadata.bundled?.sourceURL ?? component.sourceURL - : component.sourceURL) - if let buildSourceURL = component.buildSourceURL { + Link("查看源码", destination: component.resolvedSourceURL) + if let buildSourceURL = component.resolvedBuildSourceURL { Link("构建配方与项目补丁", destination: buildSourceURL) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index b5d72e72..8eca7f12 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -98,8 +98,8 @@ top of the notices page, with its INRIA copyright, CeCILL-C terms and limited warranty/liability notice. Its embedded BSD and CC-BY-SA notices are preserved in the source-notices document as well. -`licenses/sources.json` pins the reviewed source archives (SHA-256), Git -revisions, selected legal files and source-notice extraction rules. +`licenses/sources.json` pins the reviewed source archives (SHA-256), notice-review +Git revisions, selected legal files and source-notice extraction rules. `CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource bundled by Xcode. The catalog covers 34 components for the default iOS build, including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt, @@ -122,13 +122,16 @@ collected from the reviewed runtime source directories; the catalog is an attribution superset, not a claim that every source file survives linker dead stripping. -Normal Xcode builds verify the checked-in resource offline, compare all resolved -dependency versions (including header-only packages) with the catalog, check -the merged archive's input hashes and local package recipes, and check the -MiniRTC revision. A missing, stale or altered catalog fails the build. When -dependencies or MiniRTC change, review the corresponding source notices and -update the lock before regenerating; regeneration alone does not select a new -dependency version. `MINIRTC_ENABLE_AOM=true` also requires a reviewed AOM entry. +Normal Xcode builds verify the checked-in resource, local license files and +package recipes offline before compiling native code. After compilation they +also compare all resolved dependency versions (including header-only packages) +with the catalog and check the merged archive's input hashes and pinned +toolchain. A missing, stale or altered catalog fails the build. Ordinary MiniRTC +code changes do not require changing the notice-review revision or regenerating +the catalog. Changes to dependencies, license texts or embedded third-party +notices still require reviewing and updating the affected catalog entries; +regeneration alone does not select a new dependency version. +`MINIRTC_ENABLE_AOM=true` also requires a reviewed AOM entry. The source screen links to the exact application tag/commit, MiniRTC commit, and custom dependency recipes/patches. `SourceMetadata.json` is generated in @@ -136,7 +139,12 @@ the app bundle, not in the checkout. Development builds with local edits are explicitly identified as modified. Version-tag CI builds reject an unclean checkout or a mismatched tag. The release archive also carries `ThirdPartySources.json`, `SourceMetadata.json` and the open-source rights -notice; release notes provide the corresponding source and build links. +notice. `ThirdPartySources.json` preserves the notice-review catalog; +`SourceMetadata.json` records the actual build commit and per-component version, +source and recipe links used by the license screen, including MiniRTC, inih and +WebRTC-derived code. These links update automatically on every build without +rewriting the checked-in license resource. Release notes provide the +corresponding source and build links. Keep the source tags, submodule commits and referenced dependency source archives available for recipients of that release. @@ -144,8 +152,9 @@ To change a library, edit MiniRTC in its submodule, or modify the relevant package recipe/patch under `deps/submodules/minirtc/thirdparty`. If a package recipe changes, change its package configuration revision (or rebuild that package) so Xmake does not reuse a previous binary. Review affected notices, -update recipe hashes, commit MiniRTC, update its revision in `sources.json`, -then regenerate the catalog. Build the application again to relink it with +update recipe hashes, and commit MiniRTC. When the notice catalog changes, +record the reviewed revision in `sources.json` and regenerate the catalog. +Build the application again to relink it with the modified library. Use your own team and bundle identifier to install the modified build; the maintainer's signing credentials are not required. diff --git a/apps/ios/scripts/generate_third_party_licenses.py b/apps/ios/scripts/generate_third_party_licenses.py index ce01e63f..25df8935 100644 --- a/apps/ios/scripts/generate_third_party_licenses.py +++ b/apps/ios/scripts/generate_third_party_licenses.py @@ -178,11 +178,9 @@ def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None) for line in inputs.read_text().splitlines(): sha, path = line.split(maxsplit=1) checked(Path(path).read_bytes(), sha, path) - revision = subprocess.check_output( - ["git", "-C", str(REPO / "deps/submodules/minirtc"), "rev-parse", "HEAD"], text=True).strip() - mini = next(c for c in lock["components"] if c["id"] == "minirtc") - if revision != mini["source"]["revision"]: - raise ValueError("MiniRTC revision changed; review embedded notices and update sources.json") + # Repository revisions identify the reviewed notices, not a required build + # commit. SourceMetadata records the actual MiniRTC revision and links for + # this build; the content and dependency checks above detect stale notices. def main(): diff --git a/apps/ios/scripts/prepare_source_metadata.py b/apps/ios/scripts/prepare_source_metadata.py index 7e30c210..a80d4c45 100644 --- a/apps/ios/scripts/prepare_source_metadata.py +++ b/apps/ios/scripts/prepare_source_metadata.py @@ -19,6 +19,24 @@ def git(*args, root=REPO): return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() +def component_sources(lock, mini_revision): + """Resolve MiniRTC source and recipe links without rewriting reviewed notices.""" + mini_url = f"{MINIRTC_URL}/tree/{mini_revision}" + sources = {} + for component in lock["components"]: + source = component["source"] + entry = {} + if source["kind"] == "repository" and source["url"] == MINIRTC_URL: + entry.update(version=mini_revision, sourceURL=mini_url) + recipe = component.get("recipe_path", "") + if recipe.startswith("deps/submodules/minirtc/"): + directory = Path(recipe).relative_to("deps/submodules/minirtc").parent + entry["buildSourceURL"] = f"{mini_url}/{directory.as_posix()}" + if entry: + sources[component["id"]] = entry + return sources + + def source_metadata(require_release=False, tag=None): revision = git("rev-parse", "HEAD") dirty = bool(git("status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none")) @@ -47,6 +65,7 @@ def source_metadata(require_release=False, tag=None): "privacyPolicyURL": f"{REPOSITORY_URL}/blob/{ref}/PRIVACY.md", "miniRTCRevision": mini_revision, "miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}", + "componentSources": component_sources(lock, mini_revision), "licenseCatalogSHA256": lock["bundle_sha256"], "sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(), "toolchain": lock["toolchain"], diff --git a/apps/ios/tests/third_party_licenses_test.py b/apps/ios/tests/third_party_licenses_test.py new file mode 100644 index 00000000..6e31c7e6 --- /dev/null +++ b/apps/ios/tests/third_party_licenses_test.py @@ -0,0 +1,161 @@ +"""Regression coverage for notice validation and build-specific source links.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +def load_script(name): + path = Path(__file__).resolve().parents[1] / "scripts" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +licenses = load_script("generate_third_party_licenses") +metadata = load_script("prepare_source_metadata") + + +class LicenseValidationTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="crossdesk-ios-licenses-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.mini = self.root / "deps/submodules/minirtc" + self.mini.mkdir(parents=True) + self.notice = self.mini / "LICENSE" + self.notice.write_text("Reviewed license text\n") + self.recipe = self.mini / "thirdparty/opus/xmake.lua" + self.recipe.parent.mkdir(parents=True) + self.recipe.write_text('package("opus")\n') + self.source = self.mini / "transport.cpp" + self.source.write_text("int transport() { return 1; }\n") + self.git("init", "-q") + self.git("add", ".") + self.git("commit", "-qm", "Reviewed source") + self.reviewed_revision = self.git("rev-parse", "HEAD") + + self.bundle = self.root / "ThirdPartyLicenses.json" + self.bundle.write_text('{"schemaVersion": 1, "components": []}\n') + self.archive = self.root / "packages/o/opus/1.0/abcd/lib/libopus.a" + self.archive.parent.mkdir(parents=True) + self.archive.write_bytes(b"reviewed archive") + self.inputs = self.root / "inputs.sha256" + self.inputs.write_text(f"{licenses.digest(self.archive.read_bytes())} {self.archive}\n") + self.target = self.root / "target-info.txt" + self.target.write_text(str(self.root / "packages/a/asio/1.0/abcd/include") + "\n") + self.lock = { + "bundle_sha256": licenses.digest(self.bundle.read_bytes()), + "toolchain": {"xmake_version": "3.1.1"}, + "components": [ + { + "id": "minirtc", "version": self.reviewed_revision, + "source": {"kind": "repository", "url": metadata.MINIRTC_URL, + "revision": self.reviewed_revision}, + "documents": [{"path": str(self.notice.relative_to(self.root)), + "sha256": licenses.digest(self.notice.read_bytes())}], + }, + { + "id": "opus", "package": "opus", "version": "1.0", + "source": {"kind": "archive", "url": "https://example.com/opus.tar.gz"}, + "recipe_path": str(self.recipe.relative_to(self.root)), + "recipe_sha256": licenses.digest(self.recipe.read_bytes()), + }, + {"id": "asio", "package": "asio", "version": "1.0", + "source": {"kind": "archive", "url": "https://example.com/asio.tar.gz"}}, + ], + } + for name, value in (("REPO", self.root), ("OUTPUT", self.bundle)): + patcher = patch.object(licenses, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def git(self, *args): + return subprocess.check_output( + ["git", "-C", str(self.mini), "-c", "user.name=Test", + "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args], + text=True).strip() + + def verify(self, preflight=False, **kwargs): + licenses.verify(self.lock, None if preflight else self.inputs, + None if preflight else self.target, **kwargs) + + def test_source_only_commit_keeps_reviewed_catalog_and_uses_actual_links(self): + self.source.write_text("int transport() { return 2; }\n") + self.git("add", ".") + self.git("commit", "-qm", "Change transport implementation") + current = self.git("rev-parse", "HEAD") + self.assertNotEqual(current, self.reviewed_revision) + original_lock = copy.deepcopy(self.lock) + original_bundle = self.bundle.read_bytes() + self.verify(preflight=True) + self.verify() + sources = metadata.component_sources(self.lock, current) + self.assertEqual(sources["minirtc"]["version"], current) + self.assertEqual(sources["minirtc"]["sourceURL"], f"{metadata.MINIRTC_URL}/tree/{current}") + self.assertEqual(sources["opus"]["buildSourceURL"], + f"{metadata.MINIRTC_URL}/tree/{current}/thirdparty/opus") + self.assertEqual(self.lock, original_lock) + self.assertEqual(self.bundle.read_bytes(), original_bundle) + + def test_changed_license_is_rejected_before_compilation(self): + self.notice.write_text("Changed license text\n") + with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*LICENSE"): + self.verify(preflight=True) + + def test_changed_recipe_is_rejected_before_compilation(self): + self.recipe.write_text('package("different-opus")\n') + with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*xmake.lua"): + self.verify(preflight=True) + + def test_changed_bundle_is_rejected_before_compilation(self): + self.bundle.write_text("{}\n") + with self.assertRaisesRegex(ValueError, "Source checksum mismatch:"): + self.verify(preflight=True) + + def test_unreviewed_header_only_dependency_is_rejected(self): + self.target.write_text(str(self.root / "packages/a/asio/2.0/abcd/include") + "\n") + with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"): + self.verify() + + def test_missing_dependency_is_rejected(self): + self.target.write_text("") + with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"): + 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"): + self.verify() + + def test_unreviewed_toolchain_is_rejected(self): + version = self.root / "xmake-version.txt" + version.write_text("xmake v3.1.2\n") + with self.assertRaisesRegex(ValueError, "Xmake version differs"): + self.verify(xmake_version=version) + + +class ComponentSourceTest(unittest.TestCase): + def test_all_minirtc_components_and_custom_recipes_follow_build_revision(self): + lock_path = Path(__file__).resolve().parents[1] / "licenses/sources.json" + lock = json.loads(lock_path.read_text()) + reviewed = copy.deepcopy(lock) + revision = "a" * 40 + base = f"{metadata.MINIRTC_URL}/tree/{revision}" + sources = metadata.component_sources(lock, revision) + for name in ("minirtc", "inih", "webrtc"): + self.assertEqual(sources[name], {"version": revision, "sourceURL": base}) + self.assertEqual(sources["openssl3"], {"buildSourceURL": base + "/thirdparty/openssl"}) + self.assertNotIn("asio", sources) + self.assertNotIn("crossdesk", sources) + self.assertEqual(lock, reviewed) + + +if __name__ == "__main__": + unittest.main()