[fix] decouple iOS license checks from minirtc revisions

This commit is contained in:
dijunkun
2026-09-28 14:16:47 +08:00
parent 51d392ef48
commit 3992ea56db
7 changed files with 232 additions and 23 deletions
+5
View File
@@ -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:
@@ -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 */
@@ -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)
}
}
+21 -12
View File
@@ -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.
@@ -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():
@@ -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"],
+161
View File
@@ -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()