From fa72d046ab921fa8d5307fa3dd2a8aa7c91a02b6 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 13:41:22 +0200 Subject: [PATCH 01/28] Add macOS code signing and notarization to `flet build macos` New `--macos-signing-identity`, `--macos-notarize` and `--macos-notary-profile` options, configurable via `[tool.flet.macos.signing]` and `FLET_MACOS_*` environment variables: - Signs every bundled Mach-O inside-out (discovered by magic bytes, not extension) with hardened runtime and secure timestamp; entitlements are applied to the app bundle and standalone helper executables, libraries are signed bare per Apple guidance. Identity is resolved against the keychain up front (name, SHA-1, or substring; deduplicated across keychains) and every failure is per-file and fatal. - Verifies with `codesign --verify --deep --strict` plus a per-binary coverage assertion. - Optionally notarizes (`notarytool submit --wait` with a keychain profile or App Store Connect API key env vars), surfaces Apple's notarization log on rejection, then staples and validates the ticket. - Adds `com.apple.security.cs.allow-unsigned-executable-memory` to the default entitlements: Apple's libffi allocates W+X closure memory on x86_64, breaking ctypes/cffi callbacks under the hardened runtime. - Fixes the entitlements templates emitting self-closing tags with a space (``), which Xcode tolerates but codesign's AMFI parser rejects; the signer additionally normalizes any entitlements file through plistlib before use. - Documents signing, notarization, credentials, CI setup and troubleshooting in the macOS publish guide, and adds the new environment variables to the reference page (now fully sorted). Ref #2347 --- .../flet-cli/src/flet_cli/commands/build.py | 136 +++++ .../src/flet_cli/commands/build_base.py | 38 +- .../flet-cli/src/flet_cli/utils/macos_sign.py | 498 ++++++++++++++++++ .../flet-cli/tests/test_macos_sign.py | 277 ++++++++++ .../macos/Runner/DebugProfile.entitlements | 2 +- .../macos/Runner/Release.entitlements | 2 +- website/docs/publish/macos.md | 209 ++++++++ .../docs/reference/environment-variables.md | 142 ++--- 8 files changed, 1237 insertions(+), 67 deletions(-) create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py create mode 100644 sdk/python/packages/flet-cli/tests/test_macos_sign.py diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index d9a6364b2d..410c9c0a73 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -7,7 +7,15 @@ from rich.live import Live from flet_cli.commands.build_base import BaseBuildCommand, console +from flet_cli.commands.flutter_base import verbose1_style from flet_cli.utils.android import flutter_target_platforms +from flet_cli.utils.macos_sign import ( + MacOSSigningError, + NotaryCredentials, + notarize_and_staple, + resolve_identity, + sign_app, +) class Command(BaseBuildCommand): @@ -84,6 +92,8 @@ def handle(self, options: argparse.Namespace) -> None: self.customize_splash_images() self.run_flutter() self.copy_build_output() + if self.target_platform == "macos": + self.sign_macos_app() self.cleanup( 0, @@ -207,3 +217,129 @@ def run_flutter(self): f"Built [cyan]{self.platforms[self.target_platform]['status_text']}" f"[/cyan] {self.emojis['checkmark']}", ) + + def sign_macos_app(self): + """ + Code-sign — and optionally notarize — the built macOS app bundle. + + No-op unless a signing identity is configured via + `--macos-signing-identity`, `[tool.flet.macos.signing]` in + pyproject.toml, or the `FLET_MACOS_SIGNING_IDENTITY` environment + variable; the app then keeps the default ad-hoc signature produced + by the Flutter build. + """ + + assert self.options + assert self.get_pyproject + assert self.out_dir + assert self.flutter_dir + + identity = ( + self.options.macos_signing_identity + or self.get_pyproject("tool.flet.macos.signing.identity") + or os.getenv("FLET_MACOS_SIGNING_IDENTITY") + ) + notarize = ( + self.options.macos_notarize + if self.options.macos_notarize is not None + else bool(self.get_pyproject("tool.flet.macos.signing.notarize")) + ) + + if not identity: + if notarize: + self.cleanup( + 1, + "Notarization requires a code-signing identity. Pass " + "--macos-signing-identity or set " + "`[tool.flet.macos.signing].identity` in pyproject.toml.", + ) + return + + apps = sorted(self.out_dir.glob("*.app")) + if len(apps) != 1: + self.cleanup( + 1, + f"Expected exactly one .app bundle in {self.rel_out_dir}, " + f"found {len(apps)}.", + ) + app_path = apps[0] + + # The Xcode-generated entitlements file already contains the merged + # defaults + [tool.flet.macos.entitlement] + --macos-entitlements + # values; re-signing replaces the signature, so they must be + # re-applied to the app bundle here. + entitlements = self.flutter_dir / "macos" / "Runner" / "Release.entitlements" + + def log(message: str): + if self.verbose > 0: + console.log(message, style=verbose1_style) + + self.update_status(f"[bold blue]Signing [cyan]{app_path.name}[/cyan]...") + try: + resolved = resolve_identity(identity) + if notarize and resolved.is_adhoc: + self.cleanup( + 1, + "Notarization requires a Developer ID identity; " + 'ad-hoc ("-") signed apps cannot be notarized.', + ) + signed_count = sign_app( + app_path, + resolved, + entitlements=entitlements if entitlements.is_file() else None, + log=log, + ) + console.log( + f"Signed [cyan]{app_path.name}[/cyan] ({signed_count} binaries, " + f"identity: {resolved.description}) {self.emojis['checkmark']}" + ) + + if notarize: + credentials = self._macos_notary_credentials() + self.update_status( + f"[bold blue]Notarizing [cyan]{app_path.name}[/cyan] " + "(this can take a few minutes)...", + ) + notarize_and_staple(app_path, credentials, log=log) + console.log( + f"Notarized and stapled [cyan]{app_path.name}[/cyan] " + f"{self.emojis['checkmark']}" + ) + except MacOSSigningError as e: + self.cleanup(1, str(e)) + + def _macos_notary_credentials(self) -> NotaryCredentials: + """ + Resolve Apple notary service credentials: CLI over pyproject.toml over + environment, with the flet-specific profile variable ranking above + ambient App Store Connect API key variables that other tooling may + have exported. + """ + + assert self.options + assert self.get_pyproject + + profile = ( + self.options.macos_notary_profile + or self.get_pyproject("tool.flet.macos.signing.notary_profile") + or os.getenv("FLET_MACOS_NOTARY_PROFILE") + ) + if profile: + return NotaryCredentials(keychain_profile=profile) + + api_key = os.getenv("APPLE_API_KEY") + api_key_id = os.getenv("APPLE_API_KEY_ID") + api_issuer = os.getenv("APPLE_API_ISSUER") + if api_key and api_key_id and api_issuer: + return NotaryCredentials( + api_key=api_key, api_key_id=api_key_id, api_issuer=api_issuer + ) + + self.cleanup( + 1, + "Notary service credentials are missing. Either store an " + "App Store Connect API key or Apple ID app-specific password " + "with `xcrun notarytool store-credentials ` and pass " + "--macos-notary-profile , or set the APPLE_API_KEY, " + "APPLE_API_KEY_ID and APPLE_API_ISSUER environment variables.", + ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 5eb5ef62cd..395694d3ee 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -655,6 +655,31 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: default=None, help="Android signing key alias [env: FLET_ANDROID_SIGNING_KEY_ALIAS=]", ) + parser.add_argument( + "--macos-signing-identity", + dest="macos_signing_identity", + help='"Developer ID Application" certificate name, its SHA-1 ' + 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle ' + "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", + ) + parser.add_argument( + "--macos-notarize", + dest="macos_notarize", + action=argparse.BooleanOptionalAction, + default=None, + help="Submit the signed app to the Apple notary service and staple " + "the ticket; requires --macos-signing-identity and notary " + "credentials (macos only)", + ) + parser.add_argument( + "--macos-notary-profile", + dest="macos_notary_profile", + help="Keychain profile name created with `xcrun notarytool " + "store-credentials` to authenticate with the Apple notary service; " + "alternatively set the APPLE_API_KEY, APPLE_API_KEY_ID and " + "APPLE_API_ISSUER environment variables (macos only) " + "[env: FLET_MACOS_NOTARY_PROFILE=]", + ) parser.add_argument( "--build-number", dest="build_number", @@ -924,6 +949,7 @@ def setup_template_data(self): macos_entitlements = { "com.apple.security.app-sandbox": False, "com.apple.security.cs.allow-jit": True, + "com.apple.security.cs.allow-unsigned-executable-memory": True, "com.apple.security.network.client": True, "com.apple.security.network.server": True, "com.apple.security.files.user-selected.read-write": True, @@ -2853,11 +2879,13 @@ def find_platform_image( # incompatible formats so flutter_launcher_icons gets a decodable file. images = list( filter( - lambda p: not ( - (ext := Path(p).suffix.lower()) == ".icns" - and self.target_platform != "macos" - or ext == ".ico" - and self.target_platform != "windows" + lambda p: ( + not ( + (ext := Path(p).suffix.lower()) == ".icns" + and self.target_platform != "macos" + or ext == ".ico" + and self.target_platform != "windows" + ) ), glob.glob(str(src_path.joinpath(f"{image_name}.*"))), ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py new file mode 100644 index 0000000000..758b5f4961 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -0,0 +1,498 @@ +"""macOS code signing and notarization for built .app bundles. + +Signs every Mach-O binary in a bundle "inside out" (nested code first, the +bundle itself last) as required by Apple for distribution-signed code, then +optionally submits the result to the Apple notary service and staples the +ticket. `codesign --deep` is deprecated for signing and misses Mach-O files +in resource bundles (where the embedded Python stdlib and site-packages +live), which is why binaries are discovered and signed individually. +""" + +import contextlib +import json +import os +import plistlib +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Union + +# Ad-hoc signing pseudo-identity understood by codesign. +ADHOC_IDENTITY = "-" + +# Mach-O magic numbers: thin (32/64-bit) big-endian and byte-swapped +# (little-endian file) forms, and fat headers. +MACH_O_MAGICS_BE = {b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf"} # MH_MAGIC(_64) +MACH_O_MAGICS_LE = {b"\xce\xfa\xed\xfe", b"\xcf\xfa\xed\xfe"} # MH_CIGAM(_64) +MACH_O_MAGICS = MACH_O_MAGICS_BE | MACH_O_MAGICS_LE +FAT_MAGIC = b"\xca\xfe\xba\xbe" # FAT_MAGIC (also the Java class file magic) +FAT_CIGAM = b"\xbe\xba\xfe\xca" + +# Mach-O header filetype value for standalone executables. +MH_EXECUTE = 0x2 + +# Directories treated as nested code bundles that receive their own +# signature (sealing their resources) after their inner binaries are signed. +NESTED_BUNDLE_SUFFIXES = {".framework", ".app", ".appex", ".xpc"} + + +class MacOSSigningError(Exception): + """Raised when signing, verification, or notarization fails.""" + + +@dataclass +class SigningIdentity: + """A codesigning identity resolved from the keychain.""" + + sha1: str + name: str + + @property + def is_adhoc(self) -> bool: + return self.sha1 == ADHOC_IDENTITY + + @property + def description(self) -> str: + return self.name if not self.is_adhoc else "ad-hoc" + + +ADHOC = SigningIdentity(sha1=ADHOC_IDENTITY, name=ADHOC_IDENTITY) + + +@dataclass +class NotaryCredentials: + """Credentials for the Apple notary service. + + Either a notarytool keychain profile name (created with + `xcrun notarytool store-credentials`) or an App Store Connect API key + triple (key file path, key ID, issuer ID). + """ + + keychain_profile: Optional[str] = None + api_key: Optional[str] = None + api_key_id: Optional[str] = None + api_issuer: Optional[str] = None + + def as_args(self) -> list[str]: + if self.keychain_profile: + return ["--keychain-profile", self.keychain_profile] + assert self.api_key and self.api_key_id and self.api_issuer + return [ + "--key", + self.api_key, + "--key-id", + self.api_key_id, + "--issuer", + self.api_issuer, + ] + + +def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def resolve_identity(identity: str) -> SigningIdentity: + """Resolve a user-provided identity against the keychain. + + Accepts `-` (ad-hoc), a 40-hex SHA-1 fingerprint, the full certificate + name, or a unique substring of it. Fails fast — with the list of + available identities — instead of letting codesign silently no-op later. + """ + + identity = identity.strip() + if identity == ADHOC_IDENTITY: + return ADHOC + + result = _run(["security", "find-identity", "-v", "-p", "codesigning"]) + if result.returncode != 0: + raise MacOSSigningError( + f"Unable to list codesigning identities in the keychain:\n{result.stderr}" + ) + + # Lines look like: ` 1) <40-hex SHA-1> "Developer ID Application: ... (TEAMID)"` + # A certificate present in several keychains (e.g. login and System) is + # listed once per keychain — deduplicate by fingerprint so the same + # certificate is never treated as an ambiguous match. + seen: dict[str, SigningIdentity] = {} + for m in re.finditer(r"([0-9A-Fa-f]{40})\s+\"([^\"]+)\"", result.stdout): + seen.setdefault( + m.group(1).lower(), SigningIdentity(sha1=m.group(1), name=m.group(2)) + ) + available = list(seen.values()) + + if re.fullmatch(r"[0-9A-Fa-f]{40}", identity): + matches = [i for i in available if i.sha1.lower() == identity.lower()] + else: + matches = [i for i in available if i.name == identity] + if not matches: + matches = [i for i in available if identity in i.name] + + if len(matches) == 1: + return matches[0] + + listing = ( + "\n".join(f' {i.sha1} "{i.name}"' for i in available) + if available + else " (no valid codesigning identities found)" + ) + problem = ( + "matches multiple identities" if matches else "does not match any identity" + ) + raise MacOSSigningError( + f'Signing identity "{identity}" {problem} in the keychain. ' + f"Valid codesigning identities:\n{listing}\n" + "Pass the exact certificate name, its SHA-1 fingerprint, " + 'or "-" for ad-hoc signing.' + ) + + +def is_mach_o(path: Path) -> bool: + """Check whether a file is a Mach-O binary by its magic number.""" + + try: + with open(path, "rb") as f: + header = f.read(8) + except OSError: + return False + if len(header) < 8: + return False + magic = header[:4] + if magic in MACH_O_MAGICS: + return True + # The fat magic is shared with Java class files; a fat header follows + # with nfat_arch (a small integer), a class file with its format version + # (minimum 45, far above any real architecture count). + if magic == FAT_MAGIC: + return int.from_bytes(header[4:8], "big") < 45 + if magic == FAT_CIGAM: + return int.from_bytes(header[4:8], "little") < 45 + return False + + +def find_mach_o_files(app_path: Path) -> list[Path]: + """Find all Mach-O files in a bundle, real files only (symlinks skipped). + + Every file is checked by content — extension or executable-bit filters + would miss binaries like versioned libraries (`libfoo.so.3`) that Python + wheels ship, and one unsigned Mach-O fails notarization. + """ + + mach_o_files = [] + for root, _dirs, files in os.walk(app_path): + root_path = Path(root) + for name in files: + path = root_path / name + if not path.is_symlink() and is_mach_o(path): + mach_o_files.append(path) + return mach_o_files + + +def mach_o_filetype(path: Path) -> Optional[int]: + """Return the Mach-O header filetype (e.g. MH_EXECUTE), if readable. + + For fat binaries, the first architecture slice is inspected (all slices + of a real universal binary share one filetype). + """ + + def thin_filetype(header: bytes) -> Optional[int]: + if len(header) < 16: + return None + magic = header[:4] + if magic in MACH_O_MAGICS_BE: + return int.from_bytes(header[12:16], "big") + if magic in MACH_O_MAGICS_LE: + return int.from_bytes(header[12:16], "little") + return None + + try: + with open(path, "rb") as f: + header = f.read(20) + magic = header[:4] + if magic in {FAT_MAGIC, FAT_CIGAM} and len(header) >= 20: + # fat_header (magic, nfat_arch) is followed by fat_arch + # entries (cputype, cpusubtype, offset, size, align), all + # big-endian for FAT_MAGIC. + endianness = "big" if magic == FAT_MAGIC else "little" + slice_offset = int.from_bytes(header[16:20], endianness) + f.seek(slice_offset) + return thin_filetype(f.read(16)) + return thin_filetype(header) + except OSError: + return None + + +def find_nested_bundles(app_path: Path) -> list[Path]: + """Find nested code bundles (frameworks, helper apps) inside a bundle.""" + + bundles = [] + for root, dirs, _files in os.walk(app_path): + root_path = Path(root) + for name in dirs: + path = root_path / name + if path.suffix.lower() in NESTED_BUNDLE_SUFFIXES and not path.is_symlink(): + bundles.append(path) + return bundles + + +def _main_executable(app_path: Path) -> Optional[Path]: + """Return the app's main executable per CFBundleExecutable.""" + + try: + with open(app_path / "Contents" / "Info.plist", "rb") as f: + executable = plistlib.load(f).get("CFBundleExecutable") + except (OSError, plistlib.InvalidFileException): + executable = None + return (app_path / "Contents" / "MacOS" / executable) if executable else None + + +def _is_bundle_main_binary( + path: Path, app_path: Path, main_executable: Optional[Path] +) -> bool: + """Check if a Mach-O is the main binary of the app or of a nested bundle. + + Those files are signed as part of signing their enclosing bundle; signing + them individually first would be redundant. Any other executable — helper + tools included — must be signed individually, because sealing a bundle + does not sign extra Mach-O files inside it. + """ + + # .app/Contents/MacOS/ + if main_executable is not None and path == main_executable: + return True + # .framework/Versions// or .framework/ + for ancestor in path.parents: + if ancestor == app_path: + break + if ancestor.suffix.lower() == ".framework": + framework_name = ancestor.stem + if path.name == framework_name and ( + path.parent == ancestor or path.parent.parent.name == "Versions" + ): + return True + return False + + +def _normalized_entitlements(entitlements: Path, tmp_dir: str) -> Path: + """Rewrite an entitlements plist in canonical form for codesign. + + codesign embeds the file verbatim and the kernel's AMFI parser is far + stricter than CoreFoundation — e.g. it rejects self-closing tags written + with a space (``), which plutil and Xcode accept. Round-tripping + through plistlib guarantees a canonical file and validates it early. + """ + + try: + with open(entitlements, "rb") as f: + values = plistlib.load(f) + except (OSError, plistlib.InvalidFileException, ValueError) as e: + raise MacOSSigningError(f"Invalid entitlements file {entitlements}: {e}") from e + + normalized = Path(tmp_dir) / "entitlements.plist" + with open(normalized, "wb") as f: + plistlib.dump(values, f) + return normalized + + +def _codesign( + target: Path, + identity: SigningIdentity, + entitlements: Optional[Path] = None, +) -> None: + args = ["codesign", "--force", "--sign", identity.sha1] + if not identity.is_adhoc: + args += ["--timestamp", "--options", "runtime"] + if entitlements is not None: + args += ["--entitlements", str(entitlements)] + args.append(str(target)) + + result = _run(args) + if result.returncode != 0: + raise MacOSSigningError( + f"codesign failed for {target}:\n{result.stderr.strip()}" + ) + + +def sign_app( + app_path: Union[str, Path], + identity: SigningIdentity, + entitlements: Optional[Union[str, Path]] = None, + log: Callable[[str], None] = lambda message: None, +) -> int: + """Sign a .app bundle inside out and verify the result. + + Every nested Mach-O is signed individually (deepest first), then nested + bundles, then the app itself. Entitlements go on the app bundle and on + standalone helper executables (MH_EXECUTE — e.g. a JIT-using helper like + Playwright's bundled node would be killed under the hardened runtime + without them); libraries are signed without entitlements, per Apple + guidance. Returns the number of individually signed binaries. + """ + + app_path = Path(app_path).resolve() + if not app_path.is_dir() or app_path.suffix != ".app": + raise MacOSSigningError(f"Not an app bundle: {app_path}") + entitlements = Path(entitlements) if entitlements else None + if entitlements and not entitlements.is_file(): + raise MacOSSigningError(f"Entitlements file not found: {entitlements}") + + # Quarantine and Finder-info extended attributes make codesign fail with + # "resource fork, Finder information, or similar detritus not allowed". + _run(["xattr", "-cr", str(app_path)]) + + mach_o_files = find_mach_o_files(app_path) + main_executable = _main_executable(app_path) + + def depth(path: Path) -> int: + return len(path.parts) + + inner_binaries = [ + f + for f in mach_o_files + if not _is_bundle_main_binary(f, app_path, main_executable) + ] + with tempfile.TemporaryDirectory() as tmp_dir: + normalized = ( + _normalized_entitlements(entitlements, tmp_dir) if entitlements else None + ) + + for f in sorted(inner_binaries, key=depth, reverse=True): + log(f"Signing {f.relative_to(app_path)}") + is_executable = mach_o_filetype(f) == MH_EXECUTE + _codesign(f, identity, entitlements=normalized if is_executable else None) + + for bundle in sorted(find_nested_bundles(app_path), key=depth, reverse=True): + log(f"Signing {bundle.relative_to(app_path)}") + _codesign(bundle, identity) + + log(f"Signing {app_path.name}") + _codesign(app_path, identity, entitlements=normalized) + + verify_app(app_path, mach_o_files) + return len(mach_o_files) + + +def verify_app(app_path: Path, mach_o_files: list[Path]) -> None: + """Deep-verify the bundle signature and assert every Mach-O is signed. + + A shallow `codesign -v` passes even when a nested seal is broken — the + exact failure mode that produces "app is damaged" for end users — so + strict deep verification plus per-file coverage is the acceptance bar. + """ + + result = _run( + ["codesign", "--verify", "--deep", "--strict", "--verbose=2", str(app_path)] + ) + if result.returncode != 0: + raise MacOSSigningError( + f"Signature verification failed for {app_path}:\n{result.stderr.strip()}" + ) + + unsigned = [] + for f in mach_o_files: + if _run(["codesign", "--verify", str(f)]).returncode != 0: + unsigned.append(f) + if unsigned: + listing = "\n".join(f" {f.relative_to(app_path)}" for f in unsigned) + raise MacOSSigningError( + f"Mach-O binaries left unsigned or invalid in {app_path.name}:\n{listing}" + ) + + +def notarize_and_staple( + app_path: Union[str, Path], + credentials: NotaryCredentials, + log: Callable[[str], None] = lambda message: None, + timeout: int = 4 * 60 * 60, +) -> None: + """Submit a signed app to the Apple notary service and staple the ticket. + + Waits for the verdict; on rejection fetches and surfaces Apple's + per-file notarization log, which is the only place the actual errors + (e.g. an unsigned binary) are reported. The timeout is generous — + submissions normally finish within minutes, but the service is known to + back up for hours around events like WWDC. + """ + + app_path = Path(app_path).resolve() + + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / f"{app_path.stem}.zip" + log("Archiving app for notarization") + result = _run( + ["ditto", "-c", "-k", "--keepParent", str(app_path), str(archive)] + ) + if result.returncode != 0: + raise MacOSSigningError( + f"Failed to archive app for notarization:\n{result.stderr.strip()}" + ) + + log("Submitting to Apple notary service (this can take a few minutes)") + try: + result = _run( + [ + "xcrun", + "notarytool", + "submit", + str(archive), + "--wait", + "--output-format", + "json", + *credentials.as_args(), + ], + timeout=timeout, + ) + except subprocess.TimeoutExpired as e: + raise MacOSSigningError( + f"Notarization did not complete within {timeout // 60} minutes. " + "The submission may still finish on Apple's side — check it " + "with `xcrun notarytool history` (same credentials) and, once " + f'accepted, staple manually with `xcrun stapler staple "{app_path}"`.' + ) from e + + submission: dict = {} + with contextlib.suppress(json.JSONDecodeError): + submission = json.loads(result.stdout or "{}") + status = submission.get("status") + submission_id = submission.get("id") + + if result.returncode != 0 or status != "Accepted": + details = "" + if submission_id: + log_result = _run( + [ + "xcrun", + "notarytool", + "log", + submission_id, + *credentials.as_args(), + ] + ) + details = log_result.stdout.strip() or log_result.stderr.strip() + raise MacOSSigningError( + f"Notarization failed (status: {status or 'unknown'}, " + f"submission id: {submission_id or 'unknown'}).\n" + + (f"Notary log:\n{details}\n" if details else "") + + (f"{result.stderr.strip()}" if result.stderr else "") + ) + + log("Notarization accepted; stapling ticket") + result = _run(["xcrun", "stapler", "staple", str(app_path)]) + if result.returncode != 0: + raise MacOSSigningError( + f"Stapling failed for {app_path}:\n" + f"{result.stdout.strip()}\n{result.stderr.strip()}" + ) + + result = _run(["xcrun", "stapler", "validate", str(app_path)]) + if result.returncode != 0: + raise MacOSSigningError( + f"Staple validation failed for {app_path}:\n" + f"{result.stdout.strip()}\n{result.stderr.strip()}" + ) diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py new file mode 100644 index 0000000000..564c2e3637 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -0,0 +1,277 @@ +import os +import plistlib +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + +from flet_cli.utils import macos_sign +from flet_cli.utils.macos_sign import ( + ADHOC, + MH_EXECUTE, + MacOSSigningError, + NotaryCredentials, + _is_bundle_main_binary, + find_mach_o_files, + find_nested_bundles, + is_mach_o, + mach_o_filetype, + resolve_identity, + sign_app, +) + +MACH_O_64 = b"\xcf\xfa\xed\xfe" + b"\x00" * 12 +MACH_O_32 = b"\xfe\xed\xfa\xce" + b"\x00" * 12 +FAT_TWO_ARCHS = b"\xca\xfe\xba\xbe" + (2).to_bytes(4, "big") + b"\x00" * 8 +JAVA_CLASS = b"\xca\xfe\xba\xbe" + (52).to_bytes(4, "big") + b"\x00" * 8 + +MH_DYLIB = 0x6 + + +def thin_mach_o(filetype: int) -> bytes: + """A minimal little-endian 64-bit Mach-O header with the given filetype.""" + return ( + b"\xcf\xfa\xed\xfe" # MH_MAGIC_64, little-endian file + + (0x0100000C).to_bytes(4, "little") # cputype arm64 + + (0).to_bytes(4, "little") # cpusubtype + + filetype.to_bytes(4, "little") + + b"\x00" * 16 + ) + + +def write(path: Path, content: bytes, executable: bool = False) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + if executable: + path.chmod(path.stat().st_mode | 0o111) + return path + + +def test_is_mach_o_detects_magic_numbers(tmp_path): + """Thin and fat Mach-O magics should be detected, other content not.""" + assert is_mach_o(write(tmp_path / "thin64.so", MACH_O_64)) + assert is_mach_o(write(tmp_path / "thin32.so", MACH_O_32)) + assert is_mach_o(write(tmp_path / "fat.dylib", FAT_TWO_ARCHS)) + assert not is_mach_o(write(tmp_path / "script.so", b"#!/bin/sh\necho hi\n")) + assert not is_mach_o(write(tmp_path / "short.so", b"\xcf")) + assert not is_mach_o(tmp_path / "missing.so") + + +def test_is_mach_o_rejects_java_class_files(tmp_path): + """The shared cafebabe magic should not match Java class files.""" + assert not is_mach_o(write(tmp_path / "Foo.class", JAVA_CLASS)) + + +def test_find_mach_o_files_walks_bundle(tmp_path): + """Mach-O discovery should match by content, not extension.""" + app = tmp_path / "Test.app" + exe = write(app / "Contents" / "MacOS" / "test", MACH_O_64, executable=True) + lib = write(app / "Contents" / "Resources" / "py.bundle" / "x.so", MACH_O_64) + helper = write( + app / "Contents" / "Resources" / "py.bundle" / "node", MACH_O_64, True + ) + # versioned suffix and no exec bit — still a Mach-O, still found + versioned = write( + app / "Contents" / "Resources" / "py.bundle" / "libfoo.so.3", MACH_O_64 + ) + write(app / "Contents" / "Resources" / "data.txt", b"not a binary") + write(app / "Contents" / "Resources" / "fake.so", b"just text") + link = app / "Contents" / "Resources" / "link.so" + link.symlink_to(lib) + + assert sorted(find_mach_o_files(app)) == sorted([exe, lib, helper, versioned]) + + +def test_mach_o_filetype_detection(tmp_path): + """Executable vs library filetype should be read from thin and fat headers.""" + exe = write(tmp_path / "helper", thin_mach_o(MH_EXECUTE)) + lib = write(tmp_path / "lib.so", thin_mach_o(MH_DYLIB)) + # fat wrapper: header + one arch entry whose slice is a thin executable + slice_offset = 4096 + fat = ( + b"\xca\xfe\xba\xbe" + + (1).to_bytes(4, "big") # nfat_arch + + (0x0100000C).to_bytes(4, "big") # cputype + + (0).to_bytes(4, "big") # cpusubtype + + slice_offset.to_bytes(4, "big") + + (32).to_bytes(4, "big") # size + + (12).to_bytes(4, "big") # align + ) + fat_exe = write( + tmp_path / "fat", + fat + b"\x00" * (slice_offset - len(fat)) + thin_mach_o(MH_EXECUTE), + ) + + assert mach_o_filetype(exe) == MH_EXECUTE + assert mach_o_filetype(lib) == MH_DYLIB + assert mach_o_filetype(fat_exe) == MH_EXECUTE + assert mach_o_filetype(write(tmp_path / "x.txt", b"hello")) is None + + +def test_find_nested_bundles(tmp_path): + """Frameworks and helper bundles should be discovered, the app root not.""" + app = tmp_path / "Test.app" + fw = app / "Contents" / "Frameworks" / "Foo.framework" + write(fw / "Versions" / "A" / "Foo", MACH_O_64) + helper = app / "Contents" / "Frameworks" / "Helper.app" + write(helper / "Contents" / "MacOS" / "Helper", MACH_O_64, executable=True) + + assert sorted(find_nested_bundles(app)) == sorted([fw, helper]) + + +def test_is_bundle_main_binary(tmp_path): + """Bundle main binaries are signed with their bundle, everything else not.""" + app = tmp_path / "Test.app" + main = app / "Contents" / "MacOS" / "test" + fw = app / "Contents" / "Frameworks" / "Foo.framework" + + assert _is_bundle_main_binary(main, app, main) + # a helper tool next to the main executable is NOT covered by the app seal + assert not _is_bundle_main_binary(app / "Contents" / "MacOS" / "helper", app, main) + assert _is_bundle_main_binary(fw / "Versions" / "A" / "Foo", app, main) + assert _is_bundle_main_binary(fw / "Foo", app, main) + assert not _is_bundle_main_binary( + fw / "Versions" / "A" / "Libraries" / "bar.dylib", app, main + ) + assert not _is_bundle_main_binary( + app / "Contents" / "Resources" / "py.bundle" / "x.so", app, main + ) + + +SECURITY_LISTING = ( + "Policy: Code Signing\n" + " Matching identities\n" + f' 1) {"a" * 40} "Developer ID Application: Jane Doe (TEAM123456)"\n' + f' 2) {"b" * 40} "Apple Development: Jane Doe (XYZ98765)"\n' + " 2 valid identities found\n" +) + + +def fake_security(monkeypatch, stdout=SECURITY_LISTING, returncode=0): + def fake_run(args, timeout=None): + assert args[0] == "security" + return subprocess.CompletedProcess(args, returncode, stdout, "") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + + +def test_resolve_identity_adhoc(): + """The `-` pseudo-identity should resolve without touching the keychain.""" + assert resolve_identity("-").is_adhoc + + +def test_resolve_identity_matches_name_sha_and_substring(monkeypatch): + """Exact name, SHA-1 fingerprint, and unique substring should resolve.""" + fake_security(monkeypatch) + full = "Developer ID Application: Jane Doe (TEAM123456)" + assert resolve_identity(full).name == full + assert resolve_identity("a" * 40).name == full + assert resolve_identity("A" * 40).sha1 == "a" * 40 + assert resolve_identity("Developer ID").name == full + assert resolve_identity("TEAM123456").name == full + + +def test_resolve_identity_rejects_ambiguous_and_unknown(monkeypatch): + """Ambiguous substrings and unknown identities should fail fast.""" + fake_security(monkeypatch) + with pytest.raises(MacOSSigningError, match="multiple identities"): + resolve_identity("Jane Doe") + with pytest.raises(MacOSSigningError, match="does not match any"): + resolve_identity("Developer ID Installer: Someone Else") + + +def test_resolve_identity_empty_keychain(monkeypatch): + """An empty keychain should produce an actionable error.""" + fake_security(monkeypatch, stdout=" 0 valid identities found\n") + with pytest.raises(MacOSSigningError, match="no valid codesigning identities"): + resolve_identity("Developer ID Application: Jane Doe (TEAM123456)") + + +def test_resolve_identity_deduplicates_multi_keychain_listings(monkeypatch): + """The same certificate listed from several keychains is not ambiguous.""" + full = "Developer ID Application: Jane Doe (TEAM123456)" + listing = ( + f' 1) {"a" * 40} "{full}"\n' + f' 2) {"a" * 40} "{full}"\n' + " 2 valid identities found\n" + ) + fake_security(monkeypatch, stdout=listing) + assert resolve_identity(full).sha1 == "a" * 40 + assert resolve_identity("a" * 40).name == full + + +def test_notary_credentials_args(): + """Credential argument generation for both authentication mechanisms.""" + assert NotaryCredentials(keychain_profile="flet").as_args() == [ + "--keychain-profile", + "flet", + ] + assert NotaryCredentials( + api_key="key.p8", api_key_id="KID", api_issuer="ISS" + ).as_args() == ["--key", "key.p8", "--key-id", "KID", "--issuer", "ISS"] + + +def find_real_shared_object() -> Path: + """Locate a real Mach-O .so from the running interpreter's stdlib.""" + dynload = Path(sysconfig.get_path("stdlib")) / "lib-dynload" + for so in sorted(dynload.glob("*.so")): + return so + pytest.skip("no lib-dynload .so available") + + +# The exact formatting Xcode tolerates but codesign's AMFI parser rejects +# (self-closing tags with a space) — must be normalized before signing. +AMFI_HOSTILE_ENTITLEMENTS = """ + + + + com.apple.security.cs.allow-jit + + + +""" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS codesign") +def test_sign_app_adhoc_end_to_end(tmp_path): + """Ad-hoc signing a minimal real bundle should produce a verifiable app.""" + app = tmp_path / "Test.app" + macos_dir = app / "Contents" / "MacOS" + macos_dir.mkdir(parents=True) + shutil.copy(os.path.realpath(sys.executable), macos_dir / "test") + resources = app / "Contents" / "Resources" / "py.bundle" + resources.mkdir(parents=True) + shutil.copy(find_real_shared_object(), resources / "native.so") + with open(app / "Contents" / "Info.plist", "wb") as f: + plistlib.dump( + { + "CFBundleExecutable": "test", + "CFBundleIdentifier": "dev.flet.signtest", + "CFBundleName": "Test", + "CFBundlePackageType": "APPL", + }, + f, + ) + entitlements = tmp_path / "Release.entitlements" + entitlements.write_text(AMFI_HOSTILE_ENTITLEMENTS) + + signed = sign_app(app, ADHOC, entitlements=entitlements) + + assert signed == 2 # main executable + native.so + result = subprocess.run( + ["codesign", "--verify", "--deep", "--strict", str(app)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS codesign") +def test_sign_app_rejects_non_bundle(tmp_path): + """Signing should refuse paths that are not .app bundles.""" + with pytest.raises(MacOSSigningError, match="Not an app bundle"): + sign_app(tmp_path, ADHOC) diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/DebugProfile.entitlements b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/DebugProfile.entitlements index 479c845309..edc585757f 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/DebugProfile.entitlements +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/DebugProfile.entitlements @@ -6,7 +6,7 @@ {% if value is string -%} {{ value }} {% elif value is boolean -%} - <{{ "true" if value else "false" }} /> + <{{ "true" if value else "false" }}/> {% elif value is integer -%} {{ value }} {% elif value is float -%} diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/Release.entitlements b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/Release.entitlements index 479c845309..edc585757f 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/Release.entitlements +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/macos/Runner/Release.entitlements @@ -6,7 +6,7 @@ {% if value is string -%} {{ value }} {% elif value is boolean -%} - <{{ "true" if value else "false" }} /> + <{{ "true" if value else "false" }}/> {% elif value is integer -%} {{ value }} {% elif value is float -%} diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index f50483b8e0..e122321e32 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -237,11 +237,21 @@ Its value is determined in the following order of precedence: [tool.flet.macos.entitlement] "com.apple.security.app-sandbox" = false "com.apple.security.cs.allow-jit" = true + "com.apple.security.cs.allow-unsigned-executable-memory" = true "com.apple.security.network.client" = true "com.apple.security.network.server" = true "com.apple.security.files.user-selected.read-write" = true ``` + :::note + `com.apple.security.cs.allow-unsigned-executable-memory` is required for + `ctypes`/`cffi` callbacks to work on Intel Macs when the app is signed with + the [hardened runtime](#code-signing) (Apple's `libffi` allocates + writable-and-executable closure memory on `x86_64`). Apple Silicon is + unaffected. Set it to `false` if your app targets only `arm64` and you + want the strictest hardened runtime. + ::: + #### Supported value forms @@ -357,3 +367,202 @@ will be translated accordingly into this: ``` + +## Code signing + +By default, the built app bundle is **ad-hoc signed**: it runs fine on the Mac +that built it, but when other users *download* it, macOS Gatekeeper steps in. +Since macOS 15 (Sequoia), there is no Control-click bypass anymore — users must +approve every blocked item in **System Settings → Privacy & Security → Open +Anyway** with an administrator password, and a Python app can trigger this +per bundled library. For public distribution, sign your app with a +**Developer ID Application** certificate and [notarize](#notarization) it. + +### Prerequisites + +1. An [Apple Developer Program](https://developer.apple.com/programs/) membership. +2. A **Developer ID Application** certificate + ([create one](https://developer.apple.com/account/resources/certificates/list), + then install it — with its private key — into your login keychain). + Verify with: + + ```bash + security find-identity -v -p codesigning + ``` + +### Signing the app + + + +```bash +flet build macos --macos-signing-identity "Developer ID Application: Jane Doe (TEAM123456)" +``` + + +```toml +[tool.flet.macos.signing] +identity = "Developer ID Application: Jane Doe (TEAM123456)" +``` + + + +The identity may be the exact certificate name, its SHA-1 fingerprint, or a +unique substring (for example, just the team ID). Passing `"-"` produces an +explicit ad-hoc signature. + +#### Resolution order + +The signing identity is determined in the following order of precedence: + +1. [`--macos-signing-identity`](../cli/flet-build.md#--macos-signing-identity) +2. `[tool.flet.macos.signing].identity` +3. [`FLET_MACOS_SIGNING_IDENTITY`](../reference/environment-variables.md#flet_macos_signing_identity) + environment variable +4. Default: none — the app keeps its ad-hoc signature and no signing step runs. + +When a real identity is configured, `flet build macos` will, after the build: + +1. Sign every bundled binary — including the embedded Python runtime and all + native modules from your dependencies — "inside out", as + [Apple requires](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-macos), + with the **hardened runtime** enabled and a secure timestamp (both required + for notarization). [Entitlements](#entitlements) are applied to the app + bundle and to standalone helper executables shipped by your dependencies; + libraries are signed without entitlements, per Apple guidance. +2. Verify the result with `codesign --verify --deep --strict` and check that + no binary was left unsigned. + +The build fails with an actionable error if the identity is not found in the +keychain, if any file fails to sign, or if verification fails. + +## Notarization + +A Developer-ID-signed app must also be **notarized** by Apple for Gatekeeper to +open it without warnings. Notarization uploads the app to Apple's notary +service (a malware scan, typically a few minutes), after which the resulting +"ticket" is **stapled** to the app so it validates even offline. + +### Credentials + +Apple's notary service accepts two kinds of credentials: your **Apple ID** +with an [app-specific password](https://support.apple.com/102654), or an +**App Store Connect API key** (a `.p8` key file with its key ID and issuer +ID). Flet can receive them through either of two channels — a keychain +profile is not a different kind of credential, just the same secrets stored +once in the macOS keychain under a name instead of being passed on every +invocation: + +- **Keychain profile** (best for local development) — a one-time interactive + setup that saves either credential kind into the keychain: + + ```bash + xcrun notarytool store-credentials flet-notary \ + --apple-id you@example.com --team-id TEAM123456 + ``` + + From then on, only the profile name (here `flet-notary`) is needed; the + secrets never appear in your shell history, environment, or `pyproject.toml`. + +- **Environment variables** (best for CI) — pass an App Store Connect API key + inline on each run by setting `APPLE_API_KEY` (path to the `.p8` file), + `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Nothing is stored on the + machine, which suits ephemeral CI runners where no keychain profile exists — + inject the values from your repository secrets. + +#### Resolution order + +Credentials are determined in the following order of precedence: + +1. [`--macos-notary-profile`](../cli/flet-build.md#--macos-notary-profile) +2. `[tool.flet.macos.signing].notary_profile` +3. [`FLET_MACOS_NOTARY_PROFILE`](../reference/environment-variables.md#flet_macos_notary_profile) + environment variable +4. The `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER` environment + variables (all three must be set) + +A configured profile deliberately outranks the `APPLE_API_*` variables, which +other tooling (Fastlane, CI images) may have exported for a different team. + +### Notarizing the app + + + +```bash +flet build macos \ + --macos-signing-identity "Developer ID Application: Jane Doe (TEAM123456)" \ + --macos-notarize --macos-notary-profile flet-notary +``` + + +```toml +[tool.flet.macos.signing] +identity = "Developer ID Application: Jane Doe (TEAM123456)" +notarize = true +notary_profile = "flet-notary" +``` + + + +If notarization is rejected, the build fails and prints Apple's notarization +log, which lists the exact offending files. + +#### Resolution order + +Whether to notarize is determined in the following order of precedence: + +1. [`--macos-notarize`](../cli/flet-build.md#--macos-notarize) / + `--no-macos-notarize` +2. `[tool.flet.macos.signing].notarize` +3. Default: `false` + +### Distributing + +Ship the signed, notarized, and stapled `.app` in a **DMG** (recommended) or a +zip archive created with `ditto -c -k --keepParent` (preserves the bundle +structure and staple). A simple DMG that also gets its own staple: + +```bash +hdiutil create -volname "MyApp" -srcfolder build/macos/MyApp.app -ov -format UDZO MyApp.dmg +codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg +xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait +xcrun stapler staple MyApp.dmg +``` + +### Signing in CI (GitHub Actions) + +Export your certificate and private key as a `.p12` file, then store it +(base64-encoded) and its password as repository secrets: + +```yaml +- uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.MACOS_CERTIFICATE_P12 }} + p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + +- name: Build, sign and notarize + env: + FLET_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + APPLE_API_KEY: ${{ github.workspace }}/AuthKey.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + echo "${{ secrets.APPLE_API_KEY_P8 }}" > AuthKey.p8 + flet build macos --macos-notarize +``` + +### Troubleshooting + +| Symptom | Cause and fix | +|---------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `"MyApp" is damaged and can't be opened` on users' Macs | The bundle was modified after signing — most commonly the app writes next to its own files at runtime. Write user data to `os.getcwd()` (Flet points it at a writable location) instead of paths derived from `__file__`. Also triggered by building with `--no-compile-app`/`--no-compile-packages`, which lets Python create `__pycache__` inside the bundle at runtime. | +| `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use `apple-actions/import-codesign-certs`, which handles it. | +| Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | +| `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | +| Notarization takes very long | The first-ever submission for a new account can take up to an hour or more; subsequent submissions typically finish within minutes. | + +## Mac App Store + +Publishing to the Mac App Store requires the **App Sandbox** entitlement, an +*Apple Distribution* certificate, and `.pkg` packaging — a different pipeline +that `flet build` does not automate yet. The signing support above targets +**direct distribution** (your website, GitHub releases, etc.). diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 029e3f104e..ae6a4a3610 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -7,6 +7,34 @@ To set a boolean `True`, use one of the following string values: `"true"`, `"1"` Any other value will be interpreted as `False`. ::: +### `FLET_ANDROID_SIGNING_KEY_ALIAS` + +Android signing key alias used by +[`flet build`](../publish/android.md#key-alias) for Android app signing. + +It is used only when a [keystore](../publish/android.md#key-store) is configured. + +### `FLET_ANDROID_SIGNING_KEY_PASSWORD` + +Android signing key password used by +[`flet build`](../publish/android.md#key-password) for Android app signing. + +If [`FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD`](#flet_android_signing_key_store_password) is set +but this variable is not, the keystore password is reused as the key password. + +### `FLET_ANDROID_SIGNING_KEY_STORE` + +Path to the Android upload keystore (`.jks`) used by [`flet build`](../publish/android.md#key-store) +for Android app signing. + +### `FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD` + +Android signing keystore password used by +[`flet build`](../publish/android.md#key-store-password) for Android app signing. + +If [`FLET_ANDROID_SIGNING_KEY_PASSWORD`](#flet_android_signing_key_password) is set +but this variable is not, the key password is reused as the keystore password. + ### `FLET_APP_CONSOLE` The path to the application's console log file (`console.log`) in the cache storage directory @@ -19,6 +47,19 @@ In a running Flet app, the equivalent of this environment variable is [`StoragePaths.get_console_log_filename()`][flet.StoragePaths.get_console_log_filename]. ::: +### `FLET_APP_STORAGE_CACHE` + +A directory for **regenerable** cached data. The OS may purge it under storage pressure (and the +platform "clear cache" action wipes it), so only store things you can rebuild. Pre-created and +app-private; it maps to the platform's *caches* directory (`%LOCALAPPDATA%\\` on +Windows, `~/Library/Caches/` on macOS, `~/.cache/` on Linux, the app cache dir on +iOS/Android). In `flet run` it is `/.flet/storage/cache`. + +:::info +In a running Flet app, the equivalent of this environment variable is +[`StoragePaths.get_application_cache_directory()`][flet.StoragePaths.get_application_cache_directory]. +::: + ### `FLET_APP_STORAGE_DATA` A directory for **durable** application data — databases, state, config — that is preserved between @@ -36,19 +77,6 @@ In a running Flet app, this maps to a `data` subdirectory of [`StoragePaths.get_application_support_directory()`][flet.StoragePaths.get_application_support_directory]. ::: -### `FLET_APP_STORAGE_CACHE` - -A directory for **regenerable** cached data. The OS may purge it under storage pressure (and the -platform "clear cache" action wipes it), so only store things you can rebuild. Pre-created and -app-private; it maps to the platform's *caches* directory (`%LOCALAPPDATA%\\` on -Windows, `~/Library/Caches/` on macOS, `~/.cache/` on Linux, the app cache dir on -iOS/Android). In `flet run` it is `/.flet/storage/cache`. - -:::info -In a running Flet app, the equivalent of this environment variable is -[`StoragePaths.get_application_cache_directory()`][flet.StoragePaths.get_application_cache_directory]. -::: - ### `FLET_APP_STORAGE_TEMP` A directory for **throwaway** temporary files — the OS temporary directory (`getTemporaryDirectory()`). @@ -97,60 +125,55 @@ ft.run(main, assets_dir="assets") For control properties like [`Image.src`](../controls/image.md#flet.Image.src), continue using paths relative to the `ft.run(assets_dir=...)`, as described in the [assets cookbook](../cookbook/assets.md). -### `FLET_ANDROID_SIGNING_KEY_ALIAS` - -Android signing key alias used by -[`flet build`](../publish/android.md#key-alias) for Android app signing. - -It is used only when a [keystore](../publish/android.md#key-store) is configured. - -### `FLET_ANDROID_SIGNING_KEY_PASSWORD` - -Android signing key password used by -[`flet build`](../publish/android.md#key-password) for Android app signing. - -If [`FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD`](#flet_android_signing_key_store_password) is set -but this variable is not, the keystore password is reused as the key password. - -### `FLET_ANDROID_SIGNING_KEY_STORE` - -Path to the Android upload keystore (`.jks`) used by [`flet build`](../publish/android.md#key-store) -for Android app signing. - -### `FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD` - -Android signing keystore password used by -[`flet build`](../publish/android.md#key-store-password) for Android app signing. - -If [`FLET_ANDROID_SIGNING_KEY_PASSWORD`](#flet_android_signing_key_password) is set -but this variable is not, the key password is reused as the keystore password. - ### `FLET_CLI_NO_RICH_OUTPUT` Whether to disable rich output in the console. Defaults to `"false"`. -### `FLET_PLATFORM` - -The platform on which the application is running. -Its value is one of the following: `"android"`, `"ios"`, `"linux"`, `"macos"`, `"windows"` or `"fuchsia"`. - ### `FLET_CLI_SKIP_FLUTTER_DOCTOR` Whether to skip running `flutter doctor` when a build fails. Defaults to `False`. +### `FLET_FORCE_WEB_SERVER` + +Set to `true` to force running app as a web app. Automatically set on headless Linux hosts. + ### `FLET_HIDE_WINDOW_ON_START` Set to `true` to start app with the main window hidden. Defaults to `False`. -### `FLET_FORCE_WEB_SERVER` +### `FLET_MACOS_NOTARY_PROFILE` -Set to `true` to force running app as a web app. Automatically set on headless Linux hosts. +Name of the `notarytool` keychain profile (created with +`xcrun notarytool store-credentials`) [used](../publish/macos.md#notarization) by +`flet build` to authenticate with the Apple notary service when notarizing a macOS app. + +A profile is not a separate kind of credential — it is the same Apple ID or +App Store Connect API key credentials, stored once in the macOS keychain +under a name. Alternatively, set the `APPLE_API_KEY` (path to the `.p8` +file), `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` environment variables to +pass an App Store Connect API key inline; a configured profile takes +[precedence](../publish/macos.md#credentials) over them. + +### `FLET_MACOS_SIGNING_IDENTITY` + +Code-signing identity [used](../publish/macos.md#code-signing) by `flet build` +to sign the macOS app bundle: a "Developer ID Application" certificate name, +its SHA-1 fingerprint, or `-` for ad-hoc signing. + +When not configured (here, via the CLI, or in `pyproject.toml`), +the built app keeps its default ad-hoc signature. + +### `FLET_MAX_UPLOAD_SIZE` + +Maximum allowed size (in bytes) of uploaded files. + +Default is unlimited. ### `FLET_OAUTH_CALLBACK_HANDLER_ENDPOINT` @@ -164,11 +187,10 @@ Maximum allowed time (in seconds) to complete OAuth web flow. Defaults to `600`. -### `FLET_MAX_UPLOAD_SIZE` - -Maximum allowed size (in bytes) of uploaded files. +### `FLET_PLATFORM` -Default is unlimited. +The platform on which the application is running. +Its value is one of the following: `"android"`, `"ios"`, `"linux"`, `"macos"`, `"windows"` or `"fuchsia"`. ### `FLET_SECRET_KEY` @@ -217,20 +239,20 @@ Defaults to `"/"` - host app in the root. Set to `true` to avoid loading CanvasKit, Pyodide, and fonts from CDNs. -### `FLET_WEBSOCKET_HANDLER_ENDPOINT` - -Custom path for WebSocket handler. - -Defaults to `"/ws"`. - ### `FLET_WEB_RENDERER` Web rendering mode: `"canvaskit"` (default), `"skwasm"` or `"auto"`. +### `FLET_WEB_ROUTE_URL_STRATEGY` + +The URL strategy of the web application. Its value can be either `"path"` (default) or `"hash"`. + ### `FLET_WEB_USE_COLOR_EMOJI` Set to `True`, `true` or `1` to load web font with colorful emojis. -### `FLET_WEB_ROUTE_URL_STRATEGY` +### `FLET_WEBSOCKET_HANDLER_ENDPOINT` -The URL strategy of the web application. Its value can be either `"path"` (default) or `"hash"`. +Custom path for WebSocket handler. + +Defaults to `"/ws"`. From 3fcafa3faeaabefd81dfc529b9ddcdaba8d310f5 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 14:23:18 +0200 Subject: [PATCH 02/28] Improve documentation of the macOS signing code Expand every function, method, and dataclass in flet_cli/utils/macos_sign.py, the sign_macos_app/_macos_notary_credentials methods, and the test suite to full Google-style docstrings (Args/Returns/Raises), including the rationale behind the non-obvious decisions: the inside-out signing order and why it protects resource seals, the entitlements policy (app bundle + MH_EXECUTE helpers only), fingerprint-based identity selection, the AMFI plist normalization, and the multi-keychain deduplication. Also refines the macOS publish guide's CI section heading and links the apple-actions/import-codesign-certs action in the troubleshooting table. --- .../flet-cli/src/flet_cli/commands/build.py | 42 ++- .../flet-cli/src/flet_cli/utils/macos_sign.py | 298 ++++++++++++++++-- .../flet-cli/tests/test_macos_sign.py | 78 ++++- website/docs/publish/macos.md | 6 +- 4 files changed, 377 insertions(+), 47 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 410c9c0a73..30418d3de2 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -222,11 +222,20 @@ def sign_macos_app(self): """ Code-sign — and optionally notarize — the built macOS app bundle. - No-op unless a signing identity is configured via - `--macos-signing-identity`, `[tool.flet.macos.signing]` in - pyproject.toml, or the `FLET_MACOS_SIGNING_IDENTITY` environment - variable; the app then keeps the default ad-hoc signature produced - by the Flutter build. + Runs after `copy_build_output()` and operates on the final `.app` + in the output directory, i.e. the artifact users distribute. + + No-op unless a signing identity is configured; without one, the app keeps the + default ad-hoc signature produced by the Flutter build. Notarization is + additionally gated and requires a real (non-ad-hoc) identity plus notary + credentials. + + The app-bundle signature re-applies the entitlements from the + template-generated `Release.entitlements` — re-signing replaces the + signature Xcode embedded them in, so they must be supplied again. + + Exits via `cleanup(1, ...)` with an actionable message on any + configuration or signing failure. """ assert self.options @@ -264,10 +273,7 @@ def sign_macos_app(self): ) app_path = apps[0] - # The Xcode-generated entitlements file already contains the merged - # defaults + [tool.flet.macos.entitlement] + --macos-entitlements - # values; re-signing replaces the signature, so they must be - # re-applied to the app bundle here. + # Release.entitlements is the single merged source of entitlements. entitlements = self.flutter_dir / "macos" / "Runner" / "Release.entitlements" def log(message: str): @@ -310,10 +316,20 @@ def log(message: str): def _macos_notary_credentials(self) -> NotaryCredentials: """ - Resolve Apple notary service credentials: CLI over pyproject.toml over - environment, with the flet-specific profile variable ranking above - ambient App Store Connect API key variables that other tooling may - have exported. + Resolve Apple notary service credentials. + + A keychain profile is looked up first — `--macos-notary-profile`, + then `[tool.flet.macos.signing].notary_profile`, then the + `FLET_MACOS_NOTARY_PROFILE` environment variable — and only then + the `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store + Connect API key variables (all three required). A configured + profile deliberately outranks the `APPLE_API_*` variables, which + other tooling (Fastlane, CI images) may have exported ambiently, + possibly for a different Apple team. + + Returns: + Credentials for `notarytool`; exits via `cleanup(1, ...)` with + setup instructions when nothing is configured. """ assert self.options diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 758b5f4961..7eb0ce1724 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -6,6 +6,17 @@ ticket. `codesign --deep` is deprecated for signing and misses Mach-O files in resource bundles (where the embedded Python stdlib and site-packages live), which is why binaries are discovered and signed individually. + +Typical flow, as driven by `flet build macos`: + +1. `resolve_identity()` — validate the requested identity against the + keychain before spending time on anything else. +2. `sign_app()` — discover, sign, and verify the bundle. +3. `notarize_and_staple()` — optional, requires a real (non-ad-hoc) + identity and `NotaryCredentials`. + +All failures raise `MacOSSigningError` with a user-actionable message; no +other exception type is intentionally propagated. """ import contextlib @@ -44,17 +55,36 @@ class MacOSSigningError(Exception): @dataclass class SigningIdentity: - """A codesigning identity resolved from the keychain.""" + """A codesigning identity resolved from the keychain. + + Obtain instances via `resolve_identity()` (or use the `ADHOC` constant) + rather than constructing them directly, so that only identities that + actually exist in the keychain are ever passed to `codesign`. + """ sha1: str + """ + The certificate's SHA-1 fingerprint (40 hex characters), or `-` for the + ad-hoc pseudo-identity. Passed to `codesign --sign`; the fingerprint is + preferred over the name because it stays unambiguous when several + certificates share a name (e.g. a renewed certificate alongside an + expired one). + """ + name: str + """ + The certificate's common name, e.g. + `Developer ID Application: Jane Doe (TEAM123456)`. + """ @property def is_adhoc(self) -> bool: + """Whether this is the ad-hoc pseudo-identity (`-`).""" return self.sha1 == ADHOC_IDENTITY @property def description(self) -> str: + """Human-readable identity name for status and log messages.""" return self.name if not self.is_adhoc else "ad-hoc" @@ -65,17 +95,37 @@ def description(self) -> str: class NotaryCredentials: """Credentials for the Apple notary service. - Either a notarytool keychain profile name (created with - `xcrun notarytool store-credentials`) or an App Store Connect API key - triple (key file path, key ID, issuer ID). + Populate either `keychain_profile` alone, or the complete API key + triple (`api_key`, `api_key_id`, `api_issuer`). A profile is not a + different kind of credential — it is the same secrets stored once in + the macOS keychain under a name, so `notarytool` can look them up + instead of receiving them inline. """ keychain_profile: Optional[str] = None + """Name of a notarytool keychain profile previously + created with `xcrun notarytool store-credentials`. + """ + api_key: Optional[str] = None + """Path to an App Store Connect API private key (`.p8` file).""" + api_key_id: Optional[str] = None + """The App Store Connect API key ID.""" + api_issuer: Optional[str] = None + """The App Store Connect API key issuer ID.""" def as_args(self) -> list[str]: + """Return these credentials as `notarytool` command-line arguments. + + Used for both `notarytool submit` and `notarytool log`, which must + authenticate with the same credentials. + + Returns: + `["--keychain-profile", ...]` when a profile is set, otherwise + `["--key", ..., "--key-id", ..., "--issuer", ...]`. + """ if self.keychain_profile: return ["--keychain-profile", self.keychain_profile] assert self.api_key and self.api_key_id and self.api_issuer @@ -90,6 +140,17 @@ def as_args(self) -> list[str]: def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.CompletedProcess: + """Run a command, capturing text output and never raising on exit code. + + Args: + args: Full command line, executable first. + timeout: Seconds to wait before `subprocess.TimeoutExpired` is + raised; `None` waits indefinitely. Only the notarization + submit uses a timeout — everything else is local and fast. + + Returns: + The completed process; callers decide how to treat `returncode`. + """ return subprocess.run( args, capture_output=True, @@ -101,9 +162,23 @@ def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.Completed def resolve_identity(identity: str) -> SigningIdentity: """Resolve a user-provided identity against the keychain. - Accepts `-` (ad-hoc), a 40-hex SHA-1 fingerprint, the full certificate - name, or a unique substring of it. Fails fast — with the list of - available identities — instead of letting codesign silently no-op later. + Fails fast — with the list of available identities — instead of letting + a typo'd identity surface later as an opaque `codesign` error for every + file in the bundle. + + Args: + identity: `-` for ad-hoc signing, a 40-hex SHA-1 fingerprint, the + full certificate name (e.g. `Developer ID Application: Jane Doe + (TEAM123456)`), or any substring of the name that matches + exactly one certificate (e.g. just the team ID). + + Returns: + The matched identity; its SHA-1 fingerprint is what is ultimately + passed to `codesign`. + + Raises: + MacOSSigningError: If `security find-identity` fails, no identity + matches, or the value matches more than one certificate. """ identity = identity.strip() @@ -154,7 +229,19 @@ def resolve_identity(identity: str) -> SigningIdentity: def is_mach_o(path: Path) -> bool: - """Check whether a file is a Mach-O binary by its magic number.""" + """Check whether a file is a Mach-O binary by its magic number. + + Recognizes thin 32/64-bit images in both byte orders as well as fat + (universal) binaries. Java `.class` files, which share the fat magic + `0xcafebabe`, are explicitly excluded (see inline comment). + + Args: + path: File to inspect; only its first 8 bytes are read. + + Returns: + True if the file starts with a Mach-O or fat header; False for + anything else, including unreadable or too-short files. + """ try: with open(path, "rb") as f: @@ -179,9 +266,19 @@ def is_mach_o(path: Path) -> bool: def find_mach_o_files(app_path: Path) -> list[Path]: """Find all Mach-O files in a bundle, real files only (symlinks skipped). - Every file is checked by content — extension or executable-bit filters - would miss binaries like versioned libraries (`libfoo.so.3`) that Python - wheels ship, and one unsigned Mach-O fails notarization. + Every file is checked by content — extension or executable-bit filters would miss + binaries like versioned libraries (`libfoo.so.3`) or extensionless helper tools + that Python wheels ship, and a single unsigned Mach-O fails notarization. + Symlinks are skipped because signatures live in the real file; frameworks contain + symlinked duplicates (`Foo.framework/Foo` → `Versions/A/Foo`) that must not be + signed twice. + + Args: + app_path: Bundle directory to walk recursively. + + Returns: + Paths of all Mach-O files found, in filesystem walk order + (callers sort as needed). """ mach_o_files = [] @@ -195,13 +292,24 @@ def find_mach_o_files(app_path: Path) -> list[Path]: def mach_o_filetype(path: Path) -> Optional[int]: - """Return the Mach-O header filetype (e.g. MH_EXECUTE), if readable. + """Return the Mach-O header filetype value of a binary, if readable. - For fat binaries, the first architecture slice is inspected (all slices - of a real universal binary share one filetype). + Used to tell standalone executables (`MH_EXECUTE`) apart from libraries + and bundles (`MH_DYLIB`, `MH_BUNDLE`, ...), which determines whether a + binary receives entitlements when signed. For fat binaries, the first + architecture slice is inspected (all slices of a real universal binary + share one filetype). + + Args: + path: A Mach-O file, as identified by `is_mach_o()`. + + Returns: + The `filetype` field of the Mach-O header (e.g. `MH_EXECUTE`), or + None if the file is unreadable or not a recognizable Mach-O image. """ def thin_filetype(header: bytes) -> Optional[int]: + """Read the filetype field from a thin Mach-O header, if valid.""" if len(header) < 16: return None magic = header[:4] @@ -229,7 +337,22 @@ def thin_filetype(header: bytes) -> Optional[int]: def find_nested_bundles(app_path: Path) -> list[Path]: - """Find nested code bundles (frameworks, helper apps) inside a bundle.""" + """Find nested code bundles inside an app bundle. + + Matches frameworks, helper apps, app extensions, and XPC services (see + `NESTED_BUNDLE_SUFFIXES`). Each of these must receive its own signature + — signing the framework/bundle root signs its main binary and seals its + resources — after its inner binaries were signed, and before the + enclosing app is sealed. + + Args: + app_path: Bundle directory to walk recursively; the root itself is + never included. + + Returns: + Paths of nested bundle directories, in filesystem walk order + (callers sort deepest-first before signing). + """ bundles = [] for root, dirs, _files in os.walk(app_path): @@ -242,7 +365,17 @@ def find_nested_bundles(app_path: Path) -> list[Path]: def _main_executable(app_path: Path) -> Optional[Path]: - """Return the app's main executable per CFBundleExecutable.""" + """Return the app's main executable, as named by CFBundleExecutable. + + Args: + app_path: The `.app` bundle directory. + + Returns: + `Contents/MacOS/`, or None when `Info.plist` + is missing, unreadable, or has no `CFBundleExecutable` key — in + which case no file is treated as the main executable and everything + gets signed individually, which is safe (merely redundant). + """ try: with open(app_path / "Contents" / "Info.plist", "rb") as f: @@ -261,6 +394,21 @@ def _is_bundle_main_binary( them individually first would be redundant. Any other executable — helper tools included — must be signed individually, because sealing a bundle does not sign extra Mach-O files inside it. + + A false positive here (excluding a file that is not actually a bundle + main binary) would leave that file unsigned — `verify_app()`'s coverage + check is the safety net for that case. + + Args: + path: The Mach-O file to classify. + app_path: The enclosing `.app` bundle root. + main_executable: The app's `CFBundleExecutable` path, from + `_main_executable()`, or None if it could not be determined. + + Returns: + True for the app's main executable and for framework main binaries + (`.framework/Versions//` or flat + `.framework/`); False for everything else. """ # .app/Contents/MacOS/ @@ -285,7 +433,20 @@ def _normalized_entitlements(entitlements: Path, tmp_dir: str) -> Path: codesign embeds the file verbatim and the kernel's AMFI parser is far stricter than CoreFoundation — e.g. it rejects self-closing tags written with a space (``), which plutil and Xcode accept. Round-tripping - through plistlib guarantees a canonical file and validates it early. + through plistlib guarantees a canonical file and validates it early, + with a clear error instead of `AMFIUnserializeXML` noise at sign time. + + Args: + entitlements: The entitlements plist to normalize (any plistlib- + readable format). + tmp_dir: Existing directory to write the canonical copy into; the + caller owns its lifetime (typically a `TemporaryDirectory`). + + Returns: + Path of the canonical copy, valid as long as `tmp_dir` exists. + + Raises: + MacOSSigningError: If the file cannot be read or parsed as a plist. """ try: @@ -305,6 +466,26 @@ def _codesign( identity: SigningIdentity, entitlements: Optional[Path] = None, ) -> None: + """Run one `codesign` invocation on a file or bundle. + + Always uses `--force` to replace the ad-hoc signature that Xcode/the + linker already put on the build output. For real identities the + hardened runtime (`--options runtime`) and a secure timestamp are + added — both notarization requirements; ad-hoc signatures get neither + (a timestamp cannot be issued for them, and the hardened runtime would + only hinder local development). + + Args: + target: Mach-O file or bundle directory to sign. For a bundle, + codesign signs its main binary and seals everything else as + resources. + identity: Identity to sign with, from `resolve_identity()`. + entitlements: Canonical entitlements plist to embed, or None to + sign without entitlements (correct for libraries). + + Raises: + MacOSSigningError: If codesign exits non-zero, with its stderr. + """ args = ["codesign", "--force", "--sign", identity.sha1] if not identity.is_adhoc: args += ["--timestamp", "--options", "runtime"] @@ -327,12 +508,42 @@ def sign_app( ) -> int: """Sign a .app bundle inside out and verify the result. - Every nested Mach-O is signed individually (deepest first), then nested - bundles, then the app itself. Entitlements go on the app bundle and on - standalone helper executables (MH_EXECUTE — e.g. a JIT-using helper like - Playwright's bundled node would be killed under the hardened runtime - without them); libraries are signed without entitlements, per Apple - guidance. Returns the number of individually signed binaries. + The order guarantees no file is ever modified after its enclosing + bundle's resource seal was created (which would invalidate the seal and + make Gatekeeper report the app as "damaged"): + + 1. Every nested Mach-O, deepest paths first — except bundle main + binaries, which are covered by step 2. + 2. Nested bundles (frameworks etc.), deepest first. + 3. The app bundle itself. + + Entitlements go on the app bundle and on standalone helper executables + (`MH_EXECUTE` — e.g. a JIT-using helper like Playwright's bundled node + would be killed under the hardened runtime without them); libraries are + signed without entitlements, per Apple guidance. Quarantine/Finder + extended attributes are cleared first, since codesign refuses to sign + over them. + + Args: + app_path: The `.app` bundle to sign, e.g. `build/macos/MyApp.app`. + identity: Identity to sign with, from `resolve_identity()` or the + `ADHOC` constant. + entitlements: Entitlements plist for the app and helper + executables; normalized via `_normalized_entitlements()` before + use, so any plistlib-readable formatting is accepted. None + signs without entitlements. + log: Per-file progress callback (used for `-v` output); defaults + to a no-op. + + Returns: + The total number of Mach-O binaries discovered in the bundle — all + of them signed (individually, or via their enclosing bundle) and + verified. + + Raises: + MacOSSigningError: If `app_path` is not an app bundle, the + entitlements file is missing or invalid, any codesign + invocation fails, or the final verification fails. """ app_path = Path(app_path).resolve() @@ -384,6 +595,19 @@ def verify_app(app_path: Path, mach_o_files: list[Path]) -> None: A shallow `codesign -v` passes even when a nested seal is broken — the exact failure mode that produces "app is damaged" for end users — so strict deep verification plus per-file coverage is the acceptance bar. + (`--deep` is deprecated for *signing* only; it remains Apple's + documented flag for verification.) + + Args: + app_path: The signed `.app` bundle. + mach_o_files: Every Mach-O in the bundle (from + `find_mach_o_files()`); each one's signature is verified + individually so that a file missed by the signing passes cannot + slip through to notarization. + + Raises: + MacOSSigningError: If deep verification fails or any binary is + unsigned/invalid, listing the offending files. """ result = _run( @@ -413,11 +637,29 @@ def notarize_and_staple( ) -> None: """Submit a signed app to the Apple notary service and staple the ticket. - Waits for the verdict; on rejection fetches and surfaces Apple's - per-file notarization log, which is the only place the actual errors - (e.g. an unsigned binary) are reported. The timeout is generous — - submissions normally finish within minutes, but the service is known to - back up for hours around events like WWDC. + Steps: zip the app with `ditto` (preserving bundle metadata), submit + with `notarytool submit --wait`, and on acceptance staple the ticket to + the `.app` itself — so however the user distributes it afterwards (DMG, + zip), Gatekeeper can validate it even offline. On rejection, Apple's + per-file notarization log is fetched and included in the error, as it + is the only place the actual problems (e.g. an unsigned binary) are + reported. + + Args: + app_path: A `.app` bundle already signed with a Developer ID + identity, hardened runtime, and secure timestamps (ad-hoc + signed apps are rejected by the notary service). + credentials: Notary service authentication. + log: Progress callback for the coarse steps; defaults to a no-op. + timeout: Seconds to wait for the notary verdict. Generous by + default — submissions normally finish within minutes, but the + service is known to back up for hours around events like WWDC. + + Raises: + MacOSSigningError: If archiving, submission, stapling, or staple + validation fails; on rejection the message includes Apple's + notarization log, on timeout it includes recovery instructions + (`notarytool history` + manual stapling). """ app_path = Path(app_path).resolve() diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index 564c2e3637..b33fb420b3 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -1,3 +1,13 @@ +"""Tests for `flet_cli.utils.macos_sign`. + +Most tests are platform-independent: Mach-O discovery and classification are +exercised against hand-crafted header bytes, and keychain identity +resolution against canned `security find-identity` output (no real +certificates or keychains are touched). Only the two tests marked +`skipif(sys.platform != "darwin")` invoke the real `codesign` — ad-hoc +signing needs no certificate, so they run on any Mac, including CI runners. +""" + import os import plistlib import shutil @@ -23,16 +33,31 @@ sign_app, ) +# Minimal file contents that `is_mach_o()` must classify correctly: thin +# 64-bit (little-endian file) and 32-bit (big-endian file) Mach-O headers, a +# fat header with a plausible architecture count, and a Java class file — +# which shares the fat magic `0xcafebabe` but carries its format version +# (>= 45) where a fat header carries `nfat_arch`. MACH_O_64 = b"\xcf\xfa\xed\xfe" + b"\x00" * 12 MACH_O_32 = b"\xfe\xed\xfa\xce" + b"\x00" * 12 FAT_TWO_ARCHS = b"\xca\xfe\xba\xbe" + (2).to_bytes(4, "big") + b"\x00" * 8 JAVA_CLASS = b"\xca\xfe\xba\xbe" + (52).to_bytes(4, "big") + b"\x00" * 8 +# Mach-O filetype for a dynamic library — the "not an executable" case for +# `mach_o_filetype()`; only MH_EXECUTE is exported by the module under test. MH_DYLIB = 0x6 def thin_mach_o(filetype: int) -> bytes: - """A minimal little-endian 64-bit Mach-O header with the given filetype.""" + """Build a minimal little-endian 64-bit Mach-O header. + + Args: + filetype: Value for the header's `filetype` field, e.g. + `MH_EXECUTE` or `MH_DYLIB`. + + Returns: + Header bytes just long enough for `mach_o_filetype()` to parse. + """ return ( b"\xcf\xfa\xed\xfe" # MH_MAGIC_64, little-endian file + (0x0100000C).to_bytes(4, "little") # cputype arm64 @@ -43,6 +68,16 @@ def thin_mach_o(filetype: int) -> bytes: def write(path: Path, content: bytes, executable: bool = False) -> Path: + """Write a file, creating parent directories as needed. + + Args: + path: Destination file path. + content: Bytes to write. + executable: Whether to also set the executable bits. + + Returns: + The written path, for inline use in assertions. + """ path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) if executable: @@ -141,6 +176,9 @@ def test_is_bundle_main_binary(tmp_path): ) +# Canned `security find-identity -v -p codesigning` output: two distinct +# certificates that share the owner name "Jane Doe", so substring matching +# on the name alone is ambiguous while each full name stays unique. SECURITY_LISTING = ( "Policy: Code Signing\n" " Matching identities\n" @@ -151,6 +189,18 @@ def test_is_bundle_main_binary(tmp_path): def fake_security(monkeypatch, stdout=SECURITY_LISTING, returncode=0): + """Replace the module's subprocess runner with a canned `security` result. + + `resolve_identity()` is the only code path exercised through this fake, + and it must not invoke anything but `security` — the inner assert + guards against that. + + Args: + monkeypatch: pytest's monkeypatch fixture. + stdout: Fake `security find-identity` output to return. + returncode: Fake exit code. + """ + def fake_run(args, timeout=None): assert args[0] == "security" return subprocess.CompletedProcess(args, returncode, stdout, "") @@ -191,7 +241,13 @@ def test_resolve_identity_empty_keychain(monkeypatch): def test_resolve_identity_deduplicates_multi_keychain_listings(monkeypatch): - """The same certificate listed from several keychains is not ambiguous.""" + """The same certificate listed from several keychains is not ambiguous. + + `security find-identity` prints one line per keychain occurrence, so a + certificate installed in both the login and System keychains (a common + state on CI runners) appears twice with an identical fingerprint. Both + the exact-name and the SHA-1 lookup must still resolve. + """ full = "Developer ID Application: Jane Doe (TEAM123456)" listing = ( f' 1) {"a" * 40} "{full}"\n' @@ -215,7 +271,13 @@ def test_notary_credentials_args(): def find_real_shared_object() -> Path: - """Locate a real Mach-O .so from the running interpreter's stdlib.""" + """Locate a real Mach-O .so from the running interpreter's stdlib. + + The end-to-end signing test needs a genuine Mach-O library — codesign + rejects the fake-header fixtures used elsewhere in this suite — and any + C extension from the interpreter's `lib-dynload` directory fits. Skips + the calling test on interpreters that ship without one. + """ dynload = Path(sysconfig.get_path("stdlib")) / "lib-dynload" for so in sorted(dynload.glob("*.so")): return so @@ -238,7 +300,15 @@ def find_real_shared_object() -> Path: @pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS codesign") def test_sign_app_adhoc_end_to_end(tmp_path): - """Ad-hoc signing a minimal real bundle should produce a verifiable app.""" + """Ad-hoc signing a minimal real bundle should produce a verifiable app. + + Builds the smallest bundle real `codesign` accepts — an Info.plist, a + genuine main executable (the running Python), and a genuine .so in a + resource bundle, mirroring where flet apps keep site-packages — signs + it with entitlements written in the AMFI-hostile `` formatting + (must be normalized, not passed through), and independently re-verifies + the result with `codesign --verify --deep --strict`. + """ app = tmp_path / "Test.app" macos_dir = app / "Contents" / "MacOS" macos_dir.mkdir(parents=True) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index e122321e32..f91c3d4f07 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -528,7 +528,9 @@ xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait xcrun stapler staple MyApp.dmg ``` -### Signing in CI (GitHub Actions) +### Signing and notarizing in CI + +#### GitHub Actions Export your certificate and private key as a `.p12` file, then store it (base64-encoded) and its password as repository secrets: @@ -555,7 +557,7 @@ Export your certificate and private key as a `.p12` file, then store it | Symptom | Cause and fix | |---------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `"MyApp" is damaged and can't be opened` on users' Macs | The bundle was modified after signing — most commonly the app writes next to its own files at runtime. Write user data to `os.getcwd()` (Flet points it at a writable location) instead of paths derived from `__file__`. Also triggered by building with `--no-compile-app`/`--no-compile-packages`, which lets Python create `__pycache__` inside the bundle at runtime. | -| `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use `apple-actions/import-codesign-certs`, which handles it. | +| `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use [`apple-actions/import-codesign-certs`](https://github.com/apple-actions/import-codesign-certs), which handles it. | | Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | | `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | | Notarization takes very long | The first-ever submission for a new account can take up to an hour or more; subsequent submissions typically finish within minutes. | From 74e21892c6d310e133ffe8f3d5caac3d822b8faa Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 16:08:55 +0200 Subject: [PATCH 03/28] Read `FLET_WEB_*` env vars in web builds; align publish env-var docs `flet build web` and `flet publish` now resolve `FLET_WEB_RENDERER`, `FLET_WEB_ROUTE_URL_STRATEGY` and `FLET_WEB_NO_CDN` from the environment (precedence: CLI flag > pyproject.toml > env var > default), making the `[env: ...]` markers already shown in `--help` accurate. Previously the build ignored them and only the runtime web server read them. Docs: add "env var" example tabs and environment-variables reference links to the Android, macOS and web publish pages; rename the former ".env" tab label to "env var" (flet reads the process environment, it does not auto-load a .env file). Also fix the Android key-alias resolution order, which listed the env var above pyproject.toml though the code resolves pyproject.toml first. --- .../flet-cli/src/flet_cli/commands/build.py | 2 +- .../src/flet_cli/commands/build_base.py | 8 +++-- .../flet-cli/src/flet_cli/commands/publish.py | 18 +++++++++-- website/docs/publish/android.md | 18 +++++------ website/docs/publish/macos.md | 17 +++++++++-- .../docs/publish/web/static-website/index.md | 30 +++++++++++++++---- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 30418d3de2..7ccb6e5a61 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -329,7 +329,7 @@ def _macos_notary_credentials(self) -> NotaryCredentials: Returns: Credentials for `notarytool`; exits via `cleanup(1, ...)` with - setup instructions when nothing is configured. + setup instructions when nothing is configured. """ assert self.options diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 395694d3ee..d1d38d8f9d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -16,7 +16,7 @@ import flet.version import flet_cli.utils.processes as processes -from flet.utils import copy_tree, slugify +from flet.utils import copy_tree, get_bool_env_var, slugify from flet.utils.deprecated import deprecated_warning from flet_cli.commands.flutter_base import ( BaseFlutterCommand, @@ -1314,6 +1314,7 @@ def _xml_attr_value(v): "route_url_strategy": ( self.options.route_url_strategy or self.get_pyproject("tool.flet.web.route_url_strategy") + or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "path" ), # "canvaskit" (dart2js), not "auto": with "auto" Chromium browsers @@ -1324,6 +1325,7 @@ def _xml_attr_value(v): "web_renderer": ( self.options.web_renderer or self.get_pyproject("tool.flet.web.renderer") + or os.getenv("FLET_WEB_RENDERER") or "canvaskit" ), "pwa_background_color": ( @@ -1339,7 +1341,9 @@ def _xml_attr_value(v): or self.get_pyproject("tool.flet.web.wasm") == False # noqa: E712 ), "no_cdn": ( - self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + self.options.no_cdn + or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + or bool(get_bool_env_var("FLET_WEB_NO_CDN")) ), # Surface the resolved Pyodide release to the cookiecutter # context so the web template's index.html can wire the diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py index b745fc647f..e51ae2cc04 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py @@ -7,7 +7,12 @@ from pathlib import Path from flet.controls.types import RouteUrlStrategy, WebRenderer -from flet.utils import copy_tree, is_within_directory, random_string +from flet.utils import ( + copy_tree, + get_bool_env_var, + is_within_directory, + random_string, +) from flet_cli.commands.base import BaseCommand from flet_cli.utils.project_dependencies import ( get_poetry_dependencies, @@ -146,7 +151,8 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: action="store_true", default=False, help="Disable loading of CanvasKit, Pyodide, and fonts from CDNs. " - "Use this for full offline deployments or air-gapped environments", + "Use this for full offline deployments or air-gapped environments " + "[env: FLET_WEB_NO_CDN=]", ) def handle(self, options: argparse.Namespace) -> None: @@ -343,7 +349,11 @@ def filter_tar(tarinfo: tarfile.TarInfo): "tool.flet.web.pwa_theme_color" ) - no_cdn = options.no_cdn or get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + no_cdn = ( + options.no_cdn + or get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + or bool(get_bool_env_var("FLET_WEB_NO_CDN")) + ) print("Patching index.html") patch_index_html( @@ -362,11 +372,13 @@ def filter_tar(tarinfo: tarfile.TarInfo): web_renderer=WebRenderer( options.web_renderer or get_pyproject("tool.flet.web.renderer") + or os.getenv("FLET_WEB_RENDERER") or "canvaskit" ), route_url_strategy=RouteUrlStrategy( options.route_url_strategy or get_pyproject("tool.flet.web.route_url_strategy") + or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "path" ), no_cdn=no_cdn, diff --git a/website/docs/publish/android.md b/website/docs/publish/android.md index d441da011e..1ef9fe3c1e 100644 --- a/website/docs/publish/android.md +++ b/website/docs/publish/android.md @@ -214,8 +214,8 @@ An alias name for the key within the keystore. Its value is determined in the following order of precedence: 1. [`--android-signing-key-alias`](../cli/flet-build.md#--android-signing-key-alias) -2. `FLET_ANDROID_SIGNING_KEY_ALIAS` -3. `[tool.flet.android.signing].key_alias` +2. `[tool.flet.android.signing].key_alias` +3. [`FLET_ANDROID_SIGNING_KEY_ALIAS`](../reference/environment-variables.md#flet_android_signing_key_alias) 4. `"upload"` #### Example @@ -232,7 +232,7 @@ flet build aab --android-signing-key-alias value key_alias = "value" ``` - + ```dotenv FLET_ANDROID_SIGNING_KEY_ALIAS="value" ``` @@ -252,7 +252,7 @@ Its value is determined in the following order of precedence: 1. [`--android-signing-key-store`](../cli/flet-build.md#--android-signing-key-store) 2. `[tool.flet.android.signing].key_store` -3. `FLET_ANDROID_SIGNING_KEY_STORE` +3. [`FLET_ANDROID_SIGNING_KEY_STORE`](../reference/environment-variables.md#flet_android_signing_key_store) #### Example @@ -268,7 +268,7 @@ flet build aab --android-signing-key-store path/to/store.jks key_store = "path/to/store.jks" ``` - + ```dotenv FLET_ANDROID_SIGNING_KEY_STORE="path/to/store.jks" ``` @@ -283,7 +283,7 @@ A password to unlock the keystore file (can contain multiple key entries). Its value is determined in the following order of precedence: 1. [`--android-signing-key-store-password`](../cli/flet-build.md#--android-signing-key-store-password) -2. `FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD` +2. [`FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD`](../reference/environment-variables.md#flet_android_signing_key_store_password) 3. [key password](#key-password) #### Example @@ -298,7 +298,7 @@ flet build aab --android-signing-key-store-password value For security reasons, the keystore password is not read from `pyproject.toml` to prevent accidental exposure in source control. See the other tabs for supported alternatives. - + ```dotenv FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD="value" ``` @@ -313,7 +313,7 @@ A password used to access the private key inside the keystore. Its value is determined in the following order of precedence: 1. [`--android-signing-key-password`](../cli/flet-build.md#--android-signing-key-password) -2. `FLET_ANDROID_SIGNING_KEY_PASSWORD` +2. [`FLET_ANDROID_SIGNING_KEY_PASSWORD`](../reference/environment-variables.md#flet_android_signing_key_password) 3. [key store password](#key-store-password) #### Example @@ -328,7 +328,7 @@ flet build aab --android-signing-key-password value For security reasons, the keystore password is not read from `pyproject.toml` to prevent accidental exposure in source control. See the other tabs for supported alternatives. - + ```dotenv FLET_ANDROID_SIGNING_KEY_PASSWORD="value" ``` diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index f91c3d4f07..72800d64ca 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -392,7 +392,7 @@ per bundled library. For public distribution, sign your app with a ### Signing the app - + ```bash flet build macos --macos-signing-identity "Developer ID Application: Jane Doe (TEAM123456)" @@ -404,6 +404,11 @@ flet build macos --macos-signing-identity "Developer ID Application: Jane Doe (T identity = "Developer ID Application: Jane Doe (TEAM123456)" ``` + +```dotenv +FLET_MACOS_SIGNING_IDENTITY="Developer ID Application: Jane Doe (TEAM123456)" +``` + The identity may be the exact certificate name, its SHA-1 fingerprint, or a @@ -485,7 +490,7 @@ other tooling (Fastlane, CI images) may have exported for a different team. ### Notarizing the app - + ```bash flet build macos \ @@ -501,6 +506,14 @@ notarize = true notary_profile = "flet-notary" ``` + +```dotenv +FLET_MACOS_SIGNING_IDENTITY="Developer ID Application: Jane Doe (TEAM123456)" +FLET_MACOS_NOTARY_PROFILE="flet-notary" +``` +Notarization must still be turned on with `--macos-notarize` (or +`[tool.flet.macos.signing].notarize = true`); this toggle has no environment-variable equivalent. + If notarization is rejected, the build fails and prints Apple's notarization diff --git a/website/docs/publish/web/static-website/index.md b/website/docs/publish/web/static-website/index.md index c8fce6c7f6..4108e4cf98 100644 --- a/website/docs/publish/web/static-website/index.md +++ b/website/docs/publish/web/static-website/index.md @@ -185,11 +185,12 @@ Its value is determined in the following order of precedence: 1. [`--route-url-strategy`](../../../cli/flet-build.md#--route-url-strategy) 2. `[tool.flet.web].route_url_strategy` -3. `"path"` +3. [`FLET_WEB_ROUTE_URL_STRATEGY`](../../../reference/environment-variables.md#flet_web_route_url_strategy) +4. `"path"` #### Example - + ```bash flet build web --route-url-strategy hash @@ -201,6 +202,11 @@ flet build web --route-url-strategy hash route_url_strategy = "hash" ``` + +```dotenv +FLET_WEB_ROUTE_URL_STRATEGY="hash" +``` + ### Web renderer @@ -224,11 +230,12 @@ Its value is determined in the following order of precedence: 1. [`--web-renderer`](../../../cli/flet-build.md#--web-renderer) 2. `[tool.flet.web].renderer` -3. `"canvaskit"` +3. [`FLET_WEB_RENDERER`](../../../reference/environment-variables.md#flet_web_renderer) +4. `"canvaskit"` #### Example - + ```bash flet build web --web-renderer skwasm @@ -240,6 +247,11 @@ flet build web --web-renderer skwasm renderer = "skwasm" ``` + +```dotenv +FLET_WEB_RENDERER="skwasm" +``` + ### CDN assets @@ -252,11 +264,12 @@ CDN loading is disabled in the following order of precedence: 1. [`--no-cdn`](../../../cli/flet-build.md#--no-cdn) 2. `[tool.flet.web].cdn = false` -3. default: CDN enabled +3. [`FLET_WEB_NO_CDN`](../../../reference/environment-variables.md#flet_web_no_cdn) +4. default: CDN enabled #### Example - + ```bash flet build web --no-cdn @@ -268,6 +281,11 @@ flet build web --no-cdn cdn = false ``` + +```dotenv +FLET_WEB_NO_CDN="true" +``` + ### PWA colors From d57e027afd69de9c6b7c1a254f6755d323bc386a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 18:12:20 +0200 Subject: [PATCH 04/28] Changelog: macOS code signing and notarization under 1.0.0 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1f39e280..a29c48f7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.0.0 + +### New features + +* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle and standalone helper executables, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities, and without a configured identity nothing changes (the app keeps its ad-hoc signature). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. + +### Bug fixes + +* Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing by ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. + ## 0.86.1 ### Improvements From 16629c0beb3d4a889c9eb12ea7f7c1fcbd9b4737 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 20:14:23 +0200 Subject: [PATCH 05/28] Harden macOS signing: wheel frameworks, helper-bundle entitlements, provenance verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes for three defects found by adversarial review of the signing code, each empirically reproduced with codesign on macOS 15: - Non-canonical frameworks (the only kind pip wheels can ship — the zip format cannot represent the Versions/Current symlink) aborted the whole signing run with 'bundle format unrecognized/ambiguous', or got a junk generic-bundle signature in hybrid layouts like PyQt6's. They are now detected via _is_signable_framework() and their Mach-O files are signed individually — which notarization and library validation accept — while canonical frameworks keep getting sealed as bundles. - Nested helper bundles (.app/.appex/.xpc) were bundle-signed without entitlements, which both stripped the entitlements their main executable had just received (codesign --force discards them without --preserve-metadata) and left hardened-runtime helpers without the JIT/unsigned-memory exceptions Python needs. Their bundle signature now carries the same entitlements as the app. - verify_app()'s per-file safety net used plain 'codesign --verify', which passes for the linker-signed stub every pip wheel ships with — so a file skipped by the signing passes sailed through to notarization rejection or a library-validation kill at runtime. Coverage now also checks provenance via 'codesign --display': linker-signed stubs always fail, real identities must appear in the Authority chain. Also: recognize fat64 (0xcafebabf) universal binaries; anchor the framework main-binary heuristic to the framework root; make a missing Release.entitlements a hard error instead of silently signing without entitlements; document the manual Mac App Store/TestFlight requirements (sandbox entitlement, Apple Distribution cert, provisioning profile, productbuild) the automated pipeline does not cover yet; expand the test suite from 14 to 28 tests (signing order and entitlements routing, all notarization failure paths, provenance verification, non-canonical framework handling). --- CHANGELOG.md | 2 +- .../flet-cli/src/flet_cli/commands/build.py | 12 +- .../flet-cli/src/flet_cli/utils/macos_sign.py | 152 +++++- .../flet-cli/tests/test_macos_sign.py | 475 +++++++++++++++++- website/docs/publish/macos.md | 30 +- 5 files changed, 639 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a29c48f7db..8e616c25e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Bug fixes -* Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing by ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +* Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. ## 0.86.1 diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 7ccb6e5a61..0cf90b0494 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -274,7 +274,17 @@ def sign_macos_app(self): app_path = apps[0] # Release.entitlements is the single merged source of entitlements. + # Signing without it would produce a hardened-runtime app missing the + # allow-jit/allow-unsigned-executable-memory exceptions Python needs — + # an app that signs fine and crashes at launch — so its absence is an + # error, not a fallback. entitlements = self.flutter_dir / "macos" / "Runner" / "Release.entitlements" + if not entitlements.is_file(): + self.cleanup( + 1, + f"Entitlements file not found: {entitlements}. The Flutter " + "build directory is incomplete; re-run the build.", + ) def log(message: str): if self.verbose > 0: @@ -292,7 +302,7 @@ def log(message: str): signed_count = sign_app( app_path, resolved, - entitlements=entitlements if entitlements.is_file() else None, + entitlements=entitlements, log=log, ) console.log( diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 7eb0ce1724..8439168f01 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -40,6 +40,8 @@ MACH_O_MAGICS = MACH_O_MAGICS_BE | MACH_O_MAGICS_LE FAT_MAGIC = b"\xca\xfe\xba\xbe" # FAT_MAGIC (also the Java class file magic) FAT_CIGAM = b"\xbe\xba\xfe\xca" +FAT_MAGIC_64 = b"\xca\xfe\xba\xbf" # FAT_MAGIC_64 (fat_arch_64 entries) +FAT_CIGAM_64 = b"\xbf\xba\xfe\xca" # Mach-O header filetype value for standalone executables. MH_EXECUTE = 0x2 @@ -253,6 +255,8 @@ def is_mach_o(path: Path) -> bool: magic = header[:4] if magic in MACH_O_MAGICS: return True + if magic in {FAT_MAGIC_64, FAT_CIGAM_64}: + return True # The fat magic is shared with Java class files; a fat header follows # with nfat_arch (a small integer), a class file with its format version # (minimum 45, far above any real architecture count). @@ -321,7 +325,7 @@ def thin_filetype(header: bytes) -> Optional[int]: try: with open(path, "rb") as f: - header = f.read(20) + header = f.read(24) magic = header[:4] if magic in {FAT_MAGIC, FAT_CIGAM} and len(header) >= 20: # fat_header (magic, nfat_arch) is followed by fat_arch @@ -331,11 +335,49 @@ def thin_filetype(header: bytes) -> Optional[int]: slice_offset = int.from_bytes(header[16:20], endianness) f.seek(slice_offset) return thin_filetype(f.read(16)) + if magic in {FAT_MAGIC_64, FAT_CIGAM_64} and len(header) >= 24: + # fat_arch_64 widens offset and size to 64 bits: cputype(4), + # cpusubtype(4), offset(8), size(8), align(4), reserved(4). + endianness = "big" if magic == FAT_MAGIC_64 else "little" + slice_offset = int.from_bytes(header[16:24], endianness) + f.seek(slice_offset) + return thin_filetype(f.read(16)) return thin_filetype(header) except OSError: return None +def _is_signable_framework(path: Path) -> bool: + """Check whether a `.framework` directory can be signed as a bundle. + + codesign only accepts canonical framework layouts: versioned with a + `Versions/Current` symlink, or flat (iOS-style) with an `Info.plist`. + Frameworks that arrive through pip wheels are never canonical — the zip + wheel format cannot represent symlinks, so `Versions/Current` is either + missing (codesign: "bundle format unrecognized") or a de-symlinked real + directory (codesign: "bundle format is ambiguous"), and hybrid layouts + like PyQt6's produce a junk "generic bundle" signature that leaves the + framework binary untouched. Non-canonical frameworks are therefore not + signed as bundles at all — their Mach-O files are signed individually + like any other library, which library validation and notarization + accept just as well. + + Args: + path: A directory whose name ends in `.framework`. + + Returns: + True if the framework has a canonical layout codesign can seal. + """ + + versions = path / "Versions" + if versions.is_dir(): + current = versions / "Current" + return current.is_symlink() and current.exists() + return (path / "Info.plist").is_file() or ( + path / "Resources" / "Info.plist" + ).is_file() + + def find_nested_bundles(app_path: Path) -> list[Path]: """Find nested code bundles inside an app bundle. @@ -343,7 +385,10 @@ def find_nested_bundles(app_path: Path) -> list[Path]: `NESTED_BUNDLE_SUFFIXES`). Each of these must receive its own signature — signing the framework/bundle root signs its main binary and seals its resources — after its inner binaries were signed, and before the - enclosing app is sealed. + enclosing app is sealed. Frameworks with non-canonical layouts (typical + for frameworks shipped inside pip wheels) are excluded — codesign + cannot seal them, so their binaries are signed individually instead + (see `_is_signable_framework()`). Args: app_path: Bundle directory to walk recursively; the root itself is @@ -359,8 +404,11 @@ def find_nested_bundles(app_path: Path) -> list[Path]: root_path = Path(root) for name in dirs: path = root_path / name - if path.suffix.lower() in NESTED_BUNDLE_SUFFIXES and not path.is_symlink(): - bundles.append(path) + if path.suffix.lower() not in NESTED_BUNDLE_SUFFIXES or path.is_symlink(): + continue + if path.suffix.lower() == ".framework" and not _is_signable_framework(path): + continue + bundles.append(path) return bundles @@ -414,14 +462,22 @@ def _is_bundle_main_binary( # .app/Contents/MacOS/ if main_executable is not None and path == main_executable: return True - # .framework/Versions// or .framework/ + # .framework/Versions// or .framework/ — but + # only for frameworks that will actually be signed as bundles; binaries + # of non-canonical (e.g. wheel-shipped) frameworks are signed + # individually and must not be excluded here. for ancestor in path.parents: if ancestor == app_path: break if ancestor.suffix.lower() == ".framework": framework_name = ancestor.stem - if path.name == framework_name and ( - path.parent == ancestor or path.parent.parent.name == "Versions" + if ( + path.name == framework_name + and ( + path.parent == ancestor + or path.parent.parent == ancestor / "Versions" + ) + and _is_signable_framework(ancestor) ): return True return False @@ -517,12 +573,15 @@ def sign_app( 2. Nested bundles (frameworks etc.), deepest first. 3. The app bundle itself. - Entitlements go on the app bundle and on standalone helper executables + Entitlements go on the app bundle, on standalone helper executables (`MH_EXECUTE` — e.g. a JIT-using helper like Playwright's bundled node - would be killed under the hardened runtime without them); libraries are - signed without entitlements, per Apple guidance. Quarantine/Finder - extended attributes are cleared first, since codesign refuses to sign - over them. + would be killed under the hardened runtime without them), and on nested + helper bundles (`.app`/`.appex`/`.xpc`, whose main executables face the + same hardened-runtime restrictions — and whose bundle signature would + otherwise strip the entitlements applied in step 1); frameworks and + libraries are signed without entitlements, per Apple guidance. + Quarantine/Finder extended attributes are cleared first, since codesign + refuses to sign over them. Args: app_path: The `.app` bundle to sign, e.g. `build/macos/MyApp.app`. @@ -580,34 +639,78 @@ def depth(path: Path) -> int: for bundle in sorted(find_nested_bundles(app_path), key=depth, reverse=True): log(f"Signing {bundle.relative_to(app_path)}") - _codesign(bundle, identity) + is_framework = bundle.suffix.lower() == ".framework" + _codesign( + bundle, identity, entitlements=None if is_framework else normalized + ) log(f"Signing {app_path.name}") _codesign(app_path, identity, entitlements=normalized) - verify_app(app_path, mach_o_files) + verify_app(app_path, mach_o_files, identity) return len(mach_o_files) -def verify_app(app_path: Path, mach_o_files: list[Path]) -> None: - """Deep-verify the bundle signature and assert every Mach-O is signed. +def _signature_matches(path: Path, identity: SigningIdentity) -> bool: + """Check that a binary's signature was produced by the given identity. + + A plain `codesign --verify` passes for signatures this tool never made + — the linker's `adhoc,linker-signed` stub every pip wheel ships with, + or a third party's ad-hoc signature — so integrity alone cannot prove a + file was actually (re-)signed. Provenance is read from + `codesign --display` instead: linker-signed stubs are rejected always, + real identities must appear in the certificate `Authority=` chain, and + the ad-hoc identity requires a plain `Signature=adhoc` (which is the + best available discriminator — a pre-existing non-linker ad-hoc + signature is indistinguishable from one made here). + + Args: + path: A Mach-O file inside the signed bundle. + identity: The identity the file should have been signed with. + + Returns: + True if the signature's provenance matches the identity. + """ + + # codesign --display prints signature details on stderr. + result = _run(["codesign", "--display", "--verbose=2", str(path)]) + if result.returncode != 0: + return False + info = result.stderr + if "linker-signed" in info: + return False + if identity.is_adhoc: + return "Signature=adhoc" in info + return f"Authority={identity.name}" in info + + +def verify_app( + app_path: Path, mach_o_files: list[Path], identity: SigningIdentity +) -> None: + """Deep-verify the bundle signature and assert every Mach-O was signed. A shallow `codesign -v` passes even when a nested seal is broken — the exact failure mode that produces "app is damaged" for end users — so strict deep verification plus per-file coverage is the acceptance bar. (`--deep` is deprecated for *signing* only; it remains Apple's - documented flag for verification.) + documented flag for verification.) Per-file coverage checks both + integrity (`codesign --verify`) and provenance + (`_signature_matches()`), because a file skipped by the signing passes + would still verify fine under its original wheel/linker signature — + and then be rejected by notarization or library validation. Args: app_path: The signed `.app` bundle. mach_o_files: Every Mach-O in the bundle (from - `find_mach_o_files()`); each one's signature is verified - individually so that a file missed by the signing passes cannot - slip through to notarization. + `find_mach_o_files()`); each one is verified individually so + that a file missed by the signing passes cannot slip through + to notarization. + identity: The identity the bundle was signed with. Raises: MacOSSigningError: If deep verification fails or any binary is - unsigned/invalid, listing the offending files. + unsigned, invalid, or not signed by `identity`, listing the + offending files. """ result = _run( @@ -620,12 +723,15 @@ def verify_app(app_path: Path, mach_o_files: list[Path]) -> None: unsigned = [] for f in mach_o_files: - if _run(["codesign", "--verify", str(f)]).returncode != 0: + if _run( + ["codesign", "--verify", str(f)] + ).returncode != 0 or not _signature_matches(f, identity): unsigned.append(f) if unsigned: listing = "\n".join(f" {f.relative_to(app_path)}" for f in unsigned) raise MacOSSigningError( - f"Mach-O binaries left unsigned or invalid in {app_path.name}:\n{listing}" + f"Mach-O binaries left unsigned, invalid, or not signed with " + f'"{identity.description}" in {app_path.name}:\n{listing}' ) diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index b33fb420b3..8646f234d3 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -24,13 +24,16 @@ MH_EXECUTE, MacOSSigningError, NotaryCredentials, + SigningIdentity, _is_bundle_main_binary, find_mach_o_files, find_nested_bundles, is_mach_o, mach_o_filetype, + notarize_and_staple, resolve_identity, sign_app, + verify_app, ) # Minimal file contents that `is_mach_o()` must classify correctly: thin @@ -41,6 +44,7 @@ MACH_O_64 = b"\xcf\xfa\xed\xfe" + b"\x00" * 12 MACH_O_32 = b"\xfe\xed\xfa\xce" + b"\x00" * 12 FAT_TWO_ARCHS = b"\xca\xfe\xba\xbe" + (2).to_bytes(4, "big") + b"\x00" * 8 +FAT_64 = b"\xca\xfe\xba\xbf" + (2).to_bytes(4, "big") + b"\x00" * 8 JAVA_CLASS = b"\xca\xfe\xba\xbe" + (52).to_bytes(4, "big") + b"\x00" * 8 # Mach-O filetype for a dynamic library — the "not an executable" case for @@ -90,6 +94,7 @@ def test_is_mach_o_detects_magic_numbers(tmp_path): assert is_mach_o(write(tmp_path / "thin64.so", MACH_O_64)) assert is_mach_o(write(tmp_path / "thin32.so", MACH_O_32)) assert is_mach_o(write(tmp_path / "fat.dylib", FAT_TWO_ARCHS)) + assert is_mach_o(write(tmp_path / "fat64.dylib", FAT_64)) assert not is_mach_o(write(tmp_path / "script.so", b"#!/bin/sh\necho hi\n")) assert not is_mach_o(write(tmp_path / "short.so", b"\xcf")) assert not is_mach_o(tmp_path / "missing.so") @@ -146,22 +151,104 @@ def test_mach_o_filetype_detection(tmp_path): assert mach_o_filetype(write(tmp_path / "x.txt", b"hello")) is None +def test_mach_o_filetype_fat64_and_swapped(tmp_path): + """fat64 and byte-swapped fat headers should be parsed to the right slice.""" + slice_offset = 4096 + # fat_arch_64: cputype(4) cpusubtype(4) offset(8) size(8) align(4) reserved(4) + fat64 = ( + b"\xca\xfe\xba\xbf" + + (1).to_bytes(4, "big") # nfat_arch + + (0x0100000C).to_bytes(4, "big") # cputype + + (0).to_bytes(4, "big") # cpusubtype + + slice_offset.to_bytes(8, "big") + + (32).to_bytes(8, "big") # size + + (12).to_bytes(4, "big") # align + + (0).to_bytes(4, "big") # reserved + ) + fat64_exe = write( + tmp_path / "fat64", + fat64 + b"\x00" * (slice_offset - len(fat64)) + thin_mach_o(MH_EXECUTE), + ) + # byte-swapped 32-bit fat header (FAT_CIGAM): all fields little-endian + cigam = ( + b"\xbe\xba\xfe\xca" + + (1).to_bytes(4, "little") + + (0x0100000C).to_bytes(4, "little") + + (0).to_bytes(4, "little") + + slice_offset.to_bytes(4, "little") + + (32).to_bytes(4, "little") + + (12).to_bytes(4, "little") + ) + cigam_lib = write( + tmp_path / "cigam", + cigam + b"\x00" * (slice_offset - len(cigam)) + thin_mach_o(MH_DYLIB), + ) + + assert mach_o_filetype(fat64_exe) == MH_EXECUTE + assert mach_o_filetype(cigam_lib) == MH_DYLIB + + +def canonical_framework(path: Path, binary: bytes = MACH_O_64) -> Path: + """Create a versioned framework layout codesign would accept. + + `/Versions/A/` plus the `Versions/Current -> A` symlink that + marks the layout as canonical — the shape Flutter and CocoaPods produce + and `_is_signable_framework()` requires. + + Args: + path: The `.framework` directory to create. + binary: Content for the framework's main binary. + + Returns: + The framework directory, for inline use in assertions. + """ + write(path / "Versions" / "A" / path.stem, binary) + (path / "Versions" / "Current").symlink_to("A") + return path + + def test_find_nested_bundles(tmp_path): """Frameworks and helper bundles should be discovered, the app root not.""" app = tmp_path / "Test.app" - fw = app / "Contents" / "Frameworks" / "Foo.framework" - write(fw / "Versions" / "A" / "Foo", MACH_O_64) + fw = canonical_framework(app / "Contents" / "Frameworks" / "Foo.framework") helper = app / "Contents" / "Frameworks" / "Helper.app" write(helper / "Contents" / "MacOS" / "Helper", MACH_O_64, executable=True) assert sorted(find_nested_bundles(app)) == sorted([fw, helper]) +def test_find_nested_bundles_skips_non_canonical_frameworks(tmp_path): + """Wheel-shipped framework layouts codesign cannot seal must be excluded. + + Wheels cannot contain symlinks, so frameworks installed from pip arrive + either without `Versions/Current` (codesign: "bundle format + unrecognized") or with `Current` de-symlinked into a real directory + (codesign: "bundle format is ambiguous"). Either would abort the whole + signing run if treated as a bundle; their binaries are signed + individually instead. + """ + app = tmp_path / "Test.app" + site = app / "Contents" / "Resources" / "py.bundle" / "site-packages" + # no Versions/Current at all + write(site / "NoCurrent.framework" / "Versions" / "A" / "NoCurrent", MACH_O_64) + # Current de-symlinked into a real directory + desym = site / "DeSym.framework" + write(desym / "Versions" / "A" / "DeSym", MACH_O_64) + write(desym / "Versions" / "Current" / "DeSym", MACH_O_64) + # flat framework with an Info.plist is canonical (iOS-style) — kept + flat = site / "Flat.framework" + write(flat / "Flat", MACH_O_64) + write(flat / "Info.plist", b"") + canonical = canonical_framework(site / "Good.framework") + + assert sorted(find_nested_bundles(app)) == sorted([flat, canonical]) + + def test_is_bundle_main_binary(tmp_path): """Bundle main binaries are signed with their bundle, everything else not.""" app = tmp_path / "Test.app" main = app / "Contents" / "MacOS" / "test" - fw = app / "Contents" / "Frameworks" / "Foo.framework" + fw = canonical_framework(app / "Contents" / "Frameworks" / "Foo.framework") assert _is_bundle_main_binary(main, app, main) # a helper tool next to the main executable is NOT covered by the app seal @@ -174,6 +261,24 @@ def test_is_bundle_main_binary(tmp_path): assert not _is_bundle_main_binary( app / "Contents" / "Resources" / "py.bundle" / "x.so", app, main ) + # a "Versions" directory elsewhere in the framework must not match + assert not _is_bundle_main_binary( + fw / "Resources" / "Versions" / "A" / "Foo", app, main + ) + + +def test_is_bundle_main_binary_ignores_non_canonical_frameworks(tmp_path): + """Binaries of unsealable frameworks must be signed individually. + + If the framework will not be signed as a bundle, excluding its main + binary from the individual pass would leave it entirely unsigned. + """ + app = tmp_path / "Test.app" + main = app / "Contents" / "MacOS" / "test" + fw = app / "Contents" / "Resources" / "Bad.framework" + binary = write(fw / "Versions" / "A" / "Bad", MACH_O_64) + + assert not _is_bundle_main_binary(binary, app, main) # Canned `security find-identity -v -p codesigning` output: two distinct @@ -259,6 +364,13 @@ def test_resolve_identity_deduplicates_multi_keychain_listings(monkeypatch): assert resolve_identity("a" * 40).name == full +def test_resolve_identity_security_failure(monkeypatch): + """A failing `security` invocation should produce an actionable error.""" + fake_security(monkeypatch, stdout="", returncode=1) + with pytest.raises(MacOSSigningError, match="Unable to list"): + resolve_identity("Developer ID Application: Jane Doe (TEAM123456)") + + def test_notary_credentials_args(): """Credential argument generation for both authentication mechanisms.""" assert NotaryCredentials(keychain_profile="flet").as_args() == [ @@ -270,6 +382,363 @@ def test_notary_credentials_args(): ).as_args() == ["--key", "key.p8", "--key-id", "KID", "--issuer", "ISS"] +# A real (non-ad-hoc) identity for tests that never touch the keychain. +DEV_ID = SigningIdentity( + sha1="a" * 40, name="Developer ID Application: Jane Doe (TEAM123456)" +) + + +def build_signable_app(tmp_path: Path) -> Path: + """Create the richest bundle layout the signing order must handle. + + Contains a main executable, a standalone helper tool, a canonical + framework with an extra library inside, a nested helper `.app`, a + resource-tree `.so`, and a non-canonical (wheel-style) framework — + every classification `sign_app()` distinguishes. + + Args: + tmp_path: Test-scoped temporary directory. + + Returns: + The `.app` bundle directory. + """ + app = tmp_path / "Test.app" + with_plist = { + "CFBundleExecutable": "test", + "CFBundleIdentifier": "dev.flet.signtest", + } + write(app / "Contents" / "MacOS" / "test", thin_mach_o(MH_EXECUTE), True) + (app / "Contents" / "Info.plist").write_bytes(plistlib.dumps(with_plist)) + write(app / "Contents" / "MacOS" / "helper", thin_mach_o(MH_EXECUTE), True) + fw = canonical_framework( + app / "Contents" / "Frameworks" / "Foo.framework", + binary=thin_mach_o(MH_DYLIB), + ) + write(fw / "Versions" / "A" / "Libraries" / "bar.dylib", thin_mach_o(MH_DYLIB)) + write( + app + / "Contents" + / "Frameworks" + / "Helper.app" + / "Contents" + / "MacOS" + / "Helper", + thin_mach_o(MH_EXECUTE), + True, + ) + write( + app / "Contents" / "Resources" / "py.bundle" / "site-packages" / "x.so", + thin_mach_o(MH_DYLIB), + ) + write( + app + / "Contents" + / "Resources" + / "py.bundle" + / "site-packages" + / "Bad.framework" + / "Versions" + / "A" + / "Bad", + thin_mach_o(MH_DYLIB), + ) + return app + + +def test_sign_app_order_and_entitlements_routing(tmp_path, monkeypatch): + """Inside-out order and the entitlements-per-target rules. + + Asserts the three signing passes (inner binaries, nested bundles, the + app last), that every binary is signed before its enclosing bundle, + and the entitlements routing: executables and helper bundles get them, + frameworks and libraries do not — including that the nested helper + `.app`'s bundle signature carries entitlements (a plain bundle re-sign + would strip the ones its main executable received in pass 1). + """ + app = build_signable_app(tmp_path) + entitlements = tmp_path / "Release.entitlements" + entitlements.write_bytes(plistlib.dumps({"com.apple.security.cs.allow-jit": True})) + + calls: list[tuple[Path, bool]] = [] + # the only remaining _run call is `xattr -cr`, which does not exist on + # non-mac platforms — stub it so this test runs everywhere + monkeypatch.setattr( + macos_sign, + "_run", + lambda args, timeout=None: subprocess.CompletedProcess(args, 0, "", ""), + ) + monkeypatch.setattr( + macos_sign, + "_codesign", + lambda target, identity, entitlements=None: calls.append( + (target, entitlements is not None) + ), + ) + verified: dict = {} + monkeypatch.setattr( + macos_sign, + "verify_app", + lambda app_path, mach_o_files, identity: verified.update( + app=app_path, files=mach_o_files, identity=identity + ), + ) + + count = sign_app(app, DEV_ID, entitlements=entitlements) + + targets = [t.relative_to(app) if t != app else Path(".") for t, _ in calls] + entitled = {(t.relative_to(app) if t != app else Path(".")): e for t, e in calls} + order = {t: i for i, t in enumerate(targets)} + + contents = Path("Contents") + fw = contents / "Frameworks" / "Foo.framework" + helper_app = contents / "Frameworks" / "Helper.app" + bad = ( + contents + / "Resources" + / "py.bundle" + / "site-packages" + / "Bad.framework" + / "Versions" + / "A" + / "Bad" + ) + + # every Mach-O accounted for: 7 discovered, 5 signed individually (the + # app's and the canonical framework's main binaries ride on bundle + # signatures), plus 2 bundle signatures and the app itself + assert count == 7 + assert len(verified["files"]) == 7 + assert verified["identity"] is DEV_ID + assert set(targets) == { + contents / "MacOS" / "helper", + helper_app / "Contents" / "MacOS" / "Helper", + fw / "Versions" / "A" / "Libraries" / "bar.dylib", + contents / "Resources" / "py.bundle" / "site-packages" / "x.so", + bad, + fw, + helper_app, + Path("."), + } + + # inside-out: binaries before their enclosing bundles, bundles before + # the app, the app strictly last + assert order[fw / "Versions" / "A" / "Libraries" / "bar.dylib"] < order[fw] + assert order[helper_app / "Contents" / "MacOS" / "Helper"] < order[helper_app] + assert order[fw] < order[Path(".")] + assert order[helper_app] < order[Path(".")] + assert order[Path(".")] == len(targets) - 1 + + # entitlements: executables, helper bundles, and the app get them; + # frameworks and libraries do not + assert entitled[contents / "MacOS" / "helper"] + assert entitled[helper_app / "Contents" / "MacOS" / "Helper"] + assert entitled[helper_app] + assert entitled[Path(".")] + assert not entitled[fw] + assert not entitled[fw / "Versions" / "A" / "Libraries" / "bar.dylib"] + assert not entitled[contents / "Resources" / "py.bundle" / "site-packages" / "x.so"] + # the wheel-style framework is not bundle-signed; its binary is signed + # individually, without entitlements + assert not entitled[bad] + + +# Signature detail blocks as `codesign --display --verbose=2` reports them +# (on stderr): the linker's stub signature every pip wheel ships with, a +# regular ad-hoc signature as produced by this module, and a Developer ID +# signature with its certificate chain. +DISPLAY_LINKER_SIGNED = ( + "Identifier=lib_pydantic_core.dylib\n" + "CodeDirectory v=20400 flags=0x20002(adhoc,linker-signed) hashes=1011+0\n" + "Signature=adhoc\n" + "TeamIdentifier=not set\n" +) +DISPLAY_ADHOC = ( + "Identifier=x\n" + "CodeDirectory v=20400 flags=0x2(adhoc) hashes=9+2\n" + "Signature=adhoc\n" + "TeamIdentifier=not set\n" +) +DISPLAY_DEV_ID = ( + "Identifier=x\n" + "CodeDirectory v=20500 flags=0x10000(runtime) hashes=9+2\n" + f"Authority={DEV_ID.name}\n" + "Authority=Developer ID Certification Authority\n" + "Authority=Apple Root CA\n" + "TeamIdentifier=TEAM123456\n" +) + + +def fake_verify_runner(monkeypatch, display_by_name: dict): + """Fake the codesign invocations `verify_app()` makes. + + Deep verification and per-file `--verify` always succeed, so the + per-file provenance check is isolated as the deciding factor. + + Args: + monkeypatch: pytest's monkeypatch fixture. + display_by_name: Maps file basename to the `--display` detail block + to report for it. + """ + + def fake_run(args, timeout=None): + assert args[0] == "codesign" + if "--display" in args: + return subprocess.CompletedProcess( + args, 0, "", display_by_name[Path(args[-1]).name] + ) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + + +def test_verify_app_rejects_files_not_signed_by_identity(tmp_path, monkeypatch): + """The coverage check must catch files that kept a foreign signature. + + A skipped file still carries the wheel's `linker-signed` stub — which + plain `codesign --verify` accepts — so provenance, not integrity, is + what the safety net has to assert. + """ + app = tmp_path / "Test.app" + good = write(app / "Contents" / "MacOS" / "test", MACH_O_64) + skipped = write(app / "Contents" / "Resources" / "skipped.so", MACH_O_64) + fake_verify_runner( + monkeypatch, + {"test": DISPLAY_DEV_ID, "skipped.so": DISPLAY_LINKER_SIGNED}, + ) + + with pytest.raises(MacOSSigningError, match=r"skipped\.so"): + verify_app(app, [good, skipped], DEV_ID) + + +def test_verify_app_rejects_wrong_authority(tmp_path, monkeypatch): + """A valid signature from a different identity is still a failure.""" + app = tmp_path / "Test.app" + binary = write(app / "Contents" / "MacOS" / "test", MACH_O_64) + fake_verify_runner(monkeypatch, {"test": DISPLAY_ADHOC}) + + with pytest.raises(MacOSSigningError, match="not signed with"): + verify_app(app, [binary], DEV_ID) + + +def test_verify_app_accepts_matching_signatures(tmp_path, monkeypatch): + """Both identity flavors pass when every file matches.""" + app = tmp_path / "Test.app" + binary = write(app / "Contents" / "MacOS" / "test", MACH_O_64) + + fake_verify_runner(monkeypatch, {"test": DISPLAY_DEV_ID}) + verify_app(app, [binary], DEV_ID) + + fake_verify_runner(monkeypatch, {"test": DISPLAY_ADHOC}) + verify_app(app, [binary], ADHOC) + + +def test_verify_app_rejects_linker_signed_for_adhoc(tmp_path, monkeypatch): + """Ad-hoc mode must still reject untouched linker-signed wheel stubs.""" + app = tmp_path / "Test.app" + binary = write(app / "Contents" / "Resources" / "x.so", MACH_O_64) + fake_verify_runner(monkeypatch, {"x.so": DISPLAY_LINKER_SIGNED}) + + with pytest.raises(MacOSSigningError, match=r"x\.so"): + verify_app(app, [binary], ADHOC) + + +def fake_notary_runner( + monkeypatch, + submit_stdout='{"status": "Accepted", "id": "sub-1"}', + submit_returncode=0, + submit_timeout=False, + ditto_returncode=0, + staple_returncode=0, +): + """Fake every external command `notarize_and_staple()` runs. + + Args: + monkeypatch: pytest's monkeypatch fixture. + submit_stdout: Fake `notarytool submit` JSON output. + submit_returncode: Fake `notarytool submit` exit code. + submit_timeout: Whether submit should raise `TimeoutExpired`. + ditto_returncode: Fake `ditto` exit code. + staple_returncode: Fake `stapler staple` exit code. + + Returns: + The list of invoked command lines, appended to as they happen. + """ + invocations: list[list[str]] = [] + + def fake_run(args, timeout=None): + invocations.append(args) + if args[0] == "ditto": + return subprocess.CompletedProcess(args, ditto_returncode, "", "ditto err") + if args[:3] == ["xcrun", "notarytool", "submit"]: + if submit_timeout: + raise subprocess.TimeoutExpired(args, timeout or 0) + return subprocess.CompletedProcess( + args, submit_returncode, submit_stdout, "" + ) + if args[:3] == ["xcrun", "notarytool", "log"]: + return subprocess.CompletedProcess(args, 0, "problems: 1 unsigned", "") + if args[:3] == ["xcrun", "stapler", "staple"]: + return subprocess.CompletedProcess(args, staple_returncode, "", "") + if args[:3] == ["xcrun", "stapler", "validate"]: + return subprocess.CompletedProcess(args, 0, "", "") + raise AssertionError(f"unexpected command: {args}") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + return invocations + + +CREDENTIALS = NotaryCredentials(keychain_profile="flet") + + +def test_notarize_and_staple_happy_path(tmp_path, monkeypatch): + """Archive, submit, staple, validate — in that order.""" + invocations = fake_notary_runner(monkeypatch) + + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) + + assert [i[0] if i[0] != "xcrun" else " ".join(i[1:3]) for i in invocations] == [ + "ditto", + "notarytool submit", + "stapler staple", + "stapler validate", + ] + + +def test_notarize_and_staple_archive_failure(tmp_path, monkeypatch): + """A ditto failure should fail before anything is submitted.""" + invocations = fake_notary_runner(monkeypatch, ditto_returncode=1) + + with pytest.raises(MacOSSigningError, match="Failed to archive"): + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) + assert len(invocations) == 1 + + +def test_notarize_and_staple_rejection_fetches_log(tmp_path, monkeypatch): + """On rejection, Apple's notarization log must surface in the error.""" + fake_notary_runner( + monkeypatch, submit_stdout='{"status": "Invalid", "id": "sub-1"}' + ) + + with pytest.raises(MacOSSigningError, match="problems: 1 unsigned"): + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) + + +def test_notarize_and_staple_timeout_gives_recovery_steps(tmp_path, monkeypatch): + """A submit timeout should explain how to recover, not just fail.""" + fake_notary_runner(monkeypatch, submit_timeout=True) + + with pytest.raises(MacOSSigningError, match="notarytool history"): + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS, timeout=60) + + +def test_notarize_and_staple_staple_failure(tmp_path, monkeypatch): + """An accepted submission with a failing staple is still an error.""" + fake_notary_runner(monkeypatch, staple_returncode=1) + + with pytest.raises(MacOSSigningError, match="Stapling failed"): + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) + + def find_real_shared_object() -> Path: """Locate a real Mach-O .so from the running interpreter's stdlib. diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 72800d64ca..d08253c4b2 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -577,7 +577,29 @@ Export your certificate and private key as a `.p12` file, then store it ## Mac App Store -Publishing to the Mac App Store requires the **App Sandbox** entitlement, an -*Apple Distribution* certificate, and `.pkg` packaging — a different pipeline -that `flet build` does not automate yet. The signing support above targets -**direct distribution** (your website, GitHub releases, etc.). +The signing support above targets **direct distribution** (your website, +GitHub releases, etc.). Publishing to the Mac App Store — including TestFlight +— is a different pipeline that `flet build` does not automate yet. It requires: + +- an *Apple Distribution* (not *Developer ID Application*) certificate, and a + *Mac Installer Distribution* certificate for the `.pkg`; +- a provisioning profile embedded at `Contents/embedded.provisionprofile`; +- the **App Sandbox** [entitlement](#entitlements) enabled: + + ```toml + [tool.flet.macos.entitlement] + "com.apple.security.app-sandbox" = true + ``` + +- `com.apple.application-identifier` and `com.apple.developer.team-identifier` + entitlements on the main executable, and `app-sandbox` + `inherit` + entitlements on helper executables; +- packaging with `productbuild` and uploading via Transporter or + `xcrun altool` — notarization does **not** apply to store submissions. + +When preparing a store build, also drop hardened-runtime exception +entitlements you don't strictly need — including the default +`com.apple.security.cs.allow-unsigned-executable-memory` (needed for +ctypes/cffi callbacks on Intel Macs; set it to `false` in +`[tool.flet.macos.entitlement]` if your app doesn't use them) — as App Review +scrutinizes each of them. From 40928066a12495f3a45dc9951f0716c0ae683c5a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 21 Jul 2026 20:14:31 +0200 Subject: [PATCH 06/28] Fix unreachable route-url-strategy fallbacks in `flet publish` argparse's default="path" meant options.route_url_strategy was always truthy, so the pyproject (tool.flet.web.route_url_strategy) and FLET_WEB_ROUTE_URL_STRATEGY fallbacks documented for `flet publish` were dead code. The default now lives at the end of the resolution chain, as in `flet build`. --- sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py index e51ae2cc04..868988fcbf 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py @@ -127,9 +127,9 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: dest="route_url_strategy", type=str.lower, choices=["path", "hash"], - default="path", + default=None, help="Controls how routes are handled in the browser " - "[env: FLET_WEB_ROUTE_URL_STRATEGY=]", + "(default: path) [env: FLET_WEB_ROUTE_URL_STRATEGY=]", ) parser.add_argument( "--pwa-background-color", From 951558027ff6d87ce030bef2bbcc797aae55e851 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 22 Jul 2026 18:12:43 +0200 Subject: [PATCH 07/28] Add Mac App Store / TestFlight support to `flet build macos` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New --macos-app-store, --macos-provisioning-profile and --macos-installer-identity options (pyproject: [tool.flet.macos.signing] app_store / provisioning_profile / installer_identity; env: FLET_MACOS_PROVISIONING_PROFILE / FLET_MACOS_INSTALLER_IDENTITY), verified end-to-end against App Store Connect and TestFlight on 2026-07-22 — the uploaded build installs via TestFlight and loads every bundled native extension under the App Sandbox, closing the scenario of #4543. The store lane differs from Developer ID signing in every dimension that matters, all handled by the new mode: - Apple Distribution identity without the hardened runtime (the store's containment model is the App Sandbox; Apple re-signs on delivery). - Entitlements are the template's with every com.apple.security.cs.* hardened-runtime exception stripped, App Sandbox forced on, and the application-identifier / team-identifier pair injected — derived from the certificate's Team ID and the built app's CFBundleIdentifier. Helper executables and nested helper bundles carry exactly the sandbox-inherit pair. - The provisioning profile is embedded at Contents/embedded.provisionprofile before signing so the app seal covers it, and its App ID is cross-checked against the bundle id up front — a mismatch otherwise surfaces only after upload as ITMS-90889. - The deliverable is a .pkg built with productbuild and signed with the installer certificate — a certificate type invisible under the codesigning keychain policy, so resolve_identity() gained a policy parameter and the installer identity resolves (fail-fast) before any signing work. - LSApplicationCategoryType is validated before signing (App Store validation hard-rejects without it — empirically a 409) and a missing ITSAppUsesNonExemptEncryption produces a warning, both pointing at --info-plist / [tool.flet.macos.info]. - app_store + notarize is rejected: store builds are not notarized. verify_app_store_app() asserts the embedded profile and the sealed application-identifier after signing (the TestFlight ITMS-90889 invariants), build_pkg() verifies the package signature with pkgutil, and the docs' Mac App Store section now covers the automated flow, the one-time portal setup, and the altool validate/upload commands. Unit tests: 28 -> 34. ITMS added to the typos allowlist. The provisioning profile is embedded BEFORE the xattr sweep: profiles are browser downloads carrying com.apple.quarantine, macOS propagates the attribute onto the embedded copy, and App Store Connect processing rejects any quarantined file in the package (error 91109, observed on the playground upload) — a check altool --validate-app does not perform, so it only surfaces after upload. --- CHANGELOG.md | 2 + sdk/python/_typos.toml | 2 + .../flet-cli/src/flet_cli/commands/build.py | 231 ++++++++++++++- .../src/flet_cli/commands/build_base.py | 29 +- .../flet-cli/src/flet_cli/utils/macos_sign.py | 274 ++++++++++++++++-- .../flet-cli/tests/test_macos_sign.py | 209 ++++++++++++- website/docs/publish/macos.md | 82 ++++-- 7 files changed, 773 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e616c25e3..66f3c8231e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle and standalone helper executables, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities, and without a configured identity nothing changes (the app keeps its ad-hoc signature). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-app-store` (or `[tool.flet.macos.signing].app_store`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded and cross-checked against the bundle id before signing, and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before any signing work: missing installer certificate or profile, a profile/bundle-id mismatch (the slow-to-surface ITMS-90889), a missing `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`), and combining `app_store` with `notarize` (store builds are not notarized) are all reported with the exact setting to fix. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. + ### Bug fixes * Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. diff --git a/sdk/python/_typos.toml b/sdk/python/_typos.toml index 0e89826b56..9de1f299c9 100644 --- a/sdk/python/_typos.toml +++ b/sdk/python/_typos.toml @@ -19,3 +19,5 @@ UDID = "UDID" udid = "udid" # Python package name (Mozilla CA bundle) certifi = "certifi" +# Apple App Store Connect error-code prefix (e.g. ITMS-90889) +ITMS = "ITMS" diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 0cf90b0494..0b951d6c23 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -1,6 +1,8 @@ import argparse import os +import plistlib import shutil +import tempfile from pathlib import Path from rich.console import Group @@ -12,9 +14,14 @@ from flet_cli.utils.macos_sign import ( MacOSSigningError, NotaryCredentials, + SigningIdentity, + build_pkg, + identity_team_id, notarize_and_staple, + profile_application_identifier, resolve_identity, sign_app, + verify_app_store_app, ) @@ -253,6 +260,19 @@ def sign_macos_app(self): if self.options.macos_notarize is not None else bool(self.get_pyproject("tool.flet.macos.signing.notarize")) ) + app_store = ( + self.options.macos_app_store + if self.options.macos_app_store is not None + else bool(self.get_pyproject("tool.flet.macos.signing.app_store")) + ) + + if app_store and notarize: + self.cleanup( + 1, + "App Store builds are not notarized — Apple reviews and " + "re-signs store builds itself. Remove --macos-notarize / " + "`[tool.flet.macos.signing].notarize`.", + ) if not identity: if notarize: @@ -262,6 +282,13 @@ def sign_macos_app(self): "--macos-signing-identity or set " "`[tool.flet.macos.signing].identity` in pyproject.toml.", ) + if app_store: + self.cleanup( + 1, + "App Store signing requires an Apple Distribution " + "identity. Pass --macos-signing-identity or set " + "`[tool.flet.macos.signing].identity` in pyproject.toml.", + ) return apps = sorted(self.out_dir.glob("*.app")) @@ -275,9 +302,8 @@ def sign_macos_app(self): # Release.entitlements is the single merged source of entitlements. # Signing without it would produce a hardened-runtime app missing the - # allow-jit/allow-unsigned-executable-memory exceptions Python needs — - # an app that signs fine and crashes at launch — so its absence is an - # error, not a fallback. + # allow-jit/allow-unsigned-executable-memory exceptions Python needs, producing + # an app that signs fine and crashes at launch. entitlements = self.flutter_dir / "macos" / "Runner" / "Release.entitlements" if not entitlements.is_file(): self.cleanup( @@ -299,6 +325,11 @@ def log(message: str): "Notarization requires a Developer ID identity; " 'ad-hoc ("-") signed apps cannot be notarized.', ) + + if app_store: + self._sign_macos_app_store(app_path, resolved, entitlements, log) + return + signed_count = sign_app( app_path, resolved, @@ -324,18 +355,196 @@ def log(message: str): except MacOSSigningError as e: self.cleanup(1, str(e)) + def _sign_macos_app_store( + self, + app_path: Path, + identity: SigningIdentity, + entitlements: Path, + log, + ): + """ + Sign for Mac App Store / TestFlight and build the installer package. + + The store lane differs from Developer ID signing in every dimension + that matters: the app is signed with an Apple Distribution identity + *without* the hardened runtime; entitlements are the template's with + all `com.apple.security.cs.*` hardened-runtime exceptions stripped, + App Sandbox forced on, and the `application-identifier` / + `team-identifier` pair injected; helper executables carry exactly + the sandbox-inherit pair; a provisioning profile is embedded; and + the deliverable is a `.pkg` signed with an installer certificate, + not a notarized `.app`. + + All prerequisites — installer identity, Team ID, provisioning + profile, and the `LSApplicationCategoryType` Info.plist key App + Store validation demands — are checked before any signing work, so + misconfiguration fails asap. + + Exits via `cleanup(1, ...)` on any failure. + """ + + assert self.options + assert self.get_pyproject + assert self.out_dir + assert self.python_app_path + + # Fail-fast prerequisite resolution, cheapest checks first. + if identity.is_adhoc: + self.cleanup( + 1, + 'App Store builds cannot be signed ad-hoc ("-"); use your ' + "Apple Distribution certificate.", + ) + if "Developer ID" in identity.name: + console.log( + f"[yellow]Warning: signing an App Store build with " + f'"{identity.name}" — App Store Connect only accepts Apple ' + "Distribution (or 3rd Party Mac Developer Application) " + "certificates.[/yellow]" + ) + team_id = identity_team_id(identity) + if not team_id: + self.cleanup( + 1, + f'Cannot determine the Team ID from identity "{identity.name}". ' + "App Store entitlements require it.", + ) + + installer_identity = ( + self.options.macos_installer_identity + or self.get_pyproject("tool.flet.macos.signing.installer_identity") + or os.getenv("FLET_MACOS_INSTALLER_IDENTITY") + ) + if not installer_identity: + self.cleanup( + 1, + "App Store builds need an installer certificate to sign the " + ".pkg. Pass --macos-installer-identity or set " + "`[tool.flet.macos.signing].installer_identity` " + '(e.g. "3rd Party Mac Developer Installer: ... (TEAMID)").', + ) + # Installer certs sign packages, not code — resolved under the + # `basic` policy, and before the signing pass so a typo fails fast. + installer = resolve_identity(installer_identity, policy="basic") + + profile = ( + self.options.macos_provisioning_profile + or self.get_pyproject("tool.flet.macos.signing.provisioning_profile") + or os.getenv("FLET_MACOS_PROVISIONING_PROFILE") + ) + if not profile: + self.cleanup( + 1, + "App Store builds need a Mac App Store provisioning profile. " + "Pass --macos-provisioning-profile or set " + "`[tool.flet.macos.signing].provisioning_profile`.", + ) + profile_path = Path(profile) + if not profile_path.is_absolute(): + profile_path = (self.python_app_path / profile_path).resolve() + if not profile_path.is_file(): + self.cleanup(1, f"Provisioning profile not found: {profile_path}") + + # Read the *built* app's bundle id — the authoritative value after + # all project/org/bundle-id resolution and templating. + info_path = app_path / "Contents" / "Info.plist" + info = plistlib.loads(info_path.read_bytes()) + bundle_id = info["CFBundleIdentifier"] + application_identifier = f"{team_id}.{bundle_id}" + + # App Store validation hard-requires a category (empirically: 409 + # "The Info.plist must contain a LSApplicationCategoryType key"). + if not info.get("LSApplicationCategoryType"): + self.cleanup( + 1, + "App Store submissions require the LSApplicationCategoryType " + "Info.plist key. Add it with --info-plist " + 'LSApplicationCategoryType="public.app-category." ' + "or `[tool.flet.macos.info]` in pyproject.toml.", + ) + if "ITSAppUsesNonExemptEncryption" not in info: + console.log( + "[yellow]Warning: ITSAppUsesNonExemptEncryption is not set in " + "Info.plist — App Store Connect will ask the export-compliance " + "question manually for every build. Set it with --info-plist " + "ITSAppUsesNonExemptEncryption=False if your app only uses " + "standard encryption.[/yellow]" + ) + + profile_app_id = profile_application_identifier(profile_path) + if profile_app_id is not None and profile_app_id not in ( + application_identifier, + f"{team_id}.*", + ): + self.cleanup( + 1, + f"The provisioning profile authorizes {profile_app_id!r} but " + f"the app's identifier is {application_identifier!r}. " + "TestFlight rejects mismatched builds (ITMS-90889); create a " + "profile for this bundle id.", + ) + + # Store entitlements: the template's, minus every hardened-runtime + # exception (meaningless without the hardened runtime, and scrutinized + # by App Review), with the sandbox and identifiers forced in. + with open(entitlements, "rb") as f: + app_entitlements = { + k: v + for k, v in plistlib.load(f).items() + if not k.startswith("com.apple.security.cs.") + } + app_entitlements["com.apple.security.app-sandbox"] = True + app_entitlements["com.apple.application-identifier"] = application_identifier + app_entitlements["com.apple.developer.team-identifier"] = team_id + helper_entitlements = { + "com.apple.security.app-sandbox": True, + "com.apple.security.inherit": True, + } + + with tempfile.TemporaryDirectory() as tmp: + app_ents_path = Path(tmp) / "app.entitlements" + app_ents_path.write_bytes(plistlib.dumps(app_entitlements)) + helper_ents_path = Path(tmp) / "helper.entitlements" + helper_ents_path.write_bytes(plistlib.dumps(helper_entitlements)) + + signed_count = sign_app( + app_path, + identity, + entitlements=app_ents_path, + helper_entitlements=helper_ents_path, + provisioning_profile=profile_path, + hardened_runtime=False, + log=log, + ) + verify_app_store_app(app_path, application_identifier) + console.log( + f"Signed [cyan]{app_path.name}[/cyan] for the App Store " + f"({signed_count} binaries, identity: {identity.description}) " + f"{self.emojis['checkmark']}" + ) + + self.update_status(f"[bold blue]Packaging [cyan]{app_path.stem}.pkg[/cyan]...") + pkg = build_pkg( + app_path, + installer, + self.out_dir / f"{app_path.stem}.pkg", + log=log, + ) + console.log( + f"Packaged [cyan]{pkg.name}[/cyan] for App Store Connect " + f"(installer identity: {installer.name}) {self.emojis['checkmark']}" + ) + def _macos_notary_credentials(self) -> NotaryCredentials: """ Resolve Apple notary service credentials. - A keychain profile is looked up first — `--macos-notary-profile`, - then `[tool.flet.macos.signing].notary_profile`, then the - `FLET_MACOS_NOTARY_PROFILE` environment variable — and only then - the `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store - Connect API key variables (all three required). A configured - profile deliberately outranks the `APPLE_API_*` variables, which - other tooling (Fastlane, CI images) may have exported ambiently, - possibly for a different Apple team. + A keychain profile is looked up first and if not set, then the + `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect + API key variables (all three required) are looked up next. + A configured profile deliberately outranks the `APPLE_API_*` variables, which + other tooling (Fastlane, CI images) may have exported ambiently, possibly for + a different Apple team. Returns: Credentials for `notarytool`; exits via `cleanup(1, ...)` with diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index d1d38d8f9d..a432ffe204 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -658,7 +658,8 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--macos-signing-identity", dest="macos_signing_identity", - help='"Developer ID Application" certificate name, its SHA-1 ' + help='"Developer ID Application" (direct distribution) or ' + '"Apple Distribution" (App Store) certificate name, its SHA-1 ' 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle ' "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", ) @@ -680,6 +681,32 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: "APPLE_API_ISSUER environment variables (macos only) " "[env: FLET_MACOS_NOTARY_PROFILE=]", ) + parser.add_argument( + "--macos-app-store", + dest="macos_app_store", + action=argparse.BooleanOptionalAction, + default=None, + help="Sign and package for Mac App Store / TestFlight distribution: " + "sandboxed signing without the hardened runtime, an embedded " + "provisioning profile, and a signed installer .pkg; mutually " + "exclusive with --macos-notarize (macos only)", + ) + parser.add_argument( + "--macos-provisioning-profile", + dest="macos_provisioning_profile", + help="Path to a Mac App Store provisioning profile " + "(.provisionprofile) to embed at Contents/embedded.provisionprofile; " + "required for App Store builds (macos only) " + "[env: FLET_MACOS_PROVISIONING_PROFILE=]", + ) + parser.add_argument( + "--macos-installer-identity", + dest="macos_installer_identity", + help='"3rd Party Mac Developer Installer" / "Mac Installer ' + 'Distribution" certificate name or SHA-1 fingerprint used to sign ' + "the App Store installer package; required for App Store builds " + "(macos only) [env: FLET_MACOS_INSTALLER_IDENTITY=]", + ) parser.add_argument( "--build-number", dest="build_number", diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 8439168f01..4e319157cb 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -24,6 +24,7 @@ import os import plistlib import re +import shutil import subprocess import tempfile from dataclasses import dataclass @@ -161,7 +162,7 @@ def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.Completed ) -def resolve_identity(identity: str) -> SigningIdentity: +def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdentity: """Resolve a user-provided identity against the keychain. Fails fast — with the list of available identities — instead of letting @@ -173,10 +174,15 @@ def resolve_identity(identity: str) -> SigningIdentity: full certificate name (e.g. `Developer ID Application: Jane Doe (TEAM123456)`), or any substring of the name that matches exactly one certificate (e.g. just the team ID). + policy: `security find-identity` policy to match against. + `codesigning` for certificates that sign code; `basic` for + installer certificates (`3rd Party Mac Developer Installer` / + `Mac Installer Distribution`), which sign packages, not code, + and are invisible under the codesigning policy. Returns: The matched identity; its SHA-1 fingerprint is what is ultimately - passed to `codesign`. + passed to `codesign` / `productbuild`. Raises: MacOSSigningError: If `security find-identity` fails, no identity @@ -187,10 +193,10 @@ def resolve_identity(identity: str) -> SigningIdentity: if identity == ADHOC_IDENTITY: return ADHOC - result = _run(["security", "find-identity", "-v", "-p", "codesigning"]) + result = _run(["security", "find-identity", "-v", "-p", policy]) if result.returncode != 0: raise MacOSSigningError( - f"Unable to list codesigning identities in the keychain:\n{result.stderr}" + f"Unable to list signing identities in the keychain:\n{result.stderr}" ) # Lines look like: ` 1) <40-hex SHA-1> "Developer ID Application: ... (TEAMID)"` @@ -217,19 +223,39 @@ def resolve_identity(identity: str) -> SigningIdentity: listing = ( "\n".join(f' {i.sha1} "{i.name}"' for i in available) if available - else " (no valid codesigning identities found)" + else " (no valid identities found)" ) problem = ( "matches multiple identities" if matches else "does not match any identity" ) raise MacOSSigningError( f'Signing identity "{identity}" {problem} in the keychain. ' - f"Valid codesigning identities:\n{listing}\n" + f'Valid identities for the "{policy}" policy:\n{listing}\n' "Pass the exact certificate name, its SHA-1 fingerprint, " 'or "-" for ad-hoc signing.' ) +def identity_team_id(identity: SigningIdentity) -> Optional[str]: + """Extract the Team ID from a certificate's common name. + + Apple-issued certificate names end in the team identifier, e.g. + `Apple Distribution: Jane Doe (TEAM123456)` — the value App Store + signing needs for the `com.apple.application-identifier` and + `com.apple.developer.team-identifier` entitlements. + + Args: + identity: A resolved signing identity. + + Returns: + The 10-character Team ID, or None for ad-hoc or non-Apple + certificates without one. + """ + + m = re.search(r"\(([A-Z0-9]{10})\)$", identity.name) + return m.group(1) if m else None + + def is_mach_o(path: Path) -> bool: """Check whether a file is a Mach-O binary by its magic number. @@ -511,7 +537,9 @@ def _normalized_entitlements(entitlements: Path, tmp_dir: str) -> Path: except (OSError, plistlib.InvalidFileException, ValueError) as e: raise MacOSSigningError(f"Invalid entitlements file {entitlements}: {e}") from e - normalized = Path(tmp_dir) / "entitlements.plist" + # Keep the original stem: several entitlements files (app + helper) may + # be normalized into the same directory and must not overwrite each other. + normalized = Path(tmp_dir) / f"{Path(entitlements).stem}-normalized.plist" with open(normalized, "wb") as f: plistlib.dump(values, f) return normalized @@ -521,15 +549,17 @@ def _codesign( target: Path, identity: SigningIdentity, entitlements: Optional[Path] = None, + hardened_runtime: bool = True, ) -> None: """Run one `codesign` invocation on a file or bundle. Always uses `--force` to replace the ad-hoc signature that Xcode/the - linker already put on the build output. For real identities the - hardened runtime (`--options runtime`) and a secure timestamp are - added — both notarization requirements; ad-hoc signatures get neither - (a timestamp cannot be issued for them, and the hardened runtime would - only hinder local development). + linker already put on the build output. For real identities a secure + timestamp is added, plus the hardened runtime (`--options runtime`) + unless disabled — notarization requires it, App Store distribution + forbids relying on it (sandbox is the store's containment model). + Ad-hoc signatures get neither (a timestamp cannot be issued for them, + and the hardened runtime would only hinder local development). Args: target: Mach-O file or bundle directory to sign. For a bundle, @@ -538,13 +568,18 @@ def _codesign( identity: Identity to sign with, from `resolve_identity()`. entitlements: Canonical entitlements plist to embed, or None to sign without entitlements (correct for libraries). + hardened_runtime: Whether real-identity signatures opt into the + hardened runtime. True for Developer ID / notarization; + False for App Store distribution. Raises: MacOSSigningError: If codesign exits non-zero, with its stderr. """ args = ["codesign", "--force", "--sign", identity.sha1] if not identity.is_adhoc: - args += ["--timestamp", "--options", "runtime"] + args.append("--timestamp") + if hardened_runtime: + args += ["--options", "runtime"] if entitlements is not None: args += ["--entitlements", str(entitlements)] args.append(str(target)) @@ -561,6 +596,10 @@ def sign_app( identity: SigningIdentity, entitlements: Optional[Union[str, Path]] = None, log: Callable[[str], None] = lambda message: None, + *, + helper_entitlements: Optional[Union[str, Path]] = None, + provisioning_profile: Optional[Union[str, Path]] = None, + hardened_runtime: bool = True, ) -> int: """Sign a .app bundle inside out and verify the result. @@ -593,6 +632,18 @@ def sign_app( signs without entitlements. log: Per-file progress callback (used for `-v` output); defaults to a no-op. + helper_entitlements: Separate entitlements plist for helper + executables and nested helper bundles instead of the app's. + App Store builds need this: helpers must carry exactly the + sandbox `inherit` pair, not the app's entitlement set. None + keeps the default (helpers share `entitlements`). + provisioning_profile: Provisioning profile copied to + `Contents/embedded.provisionprofile` before signing so the app + seal covers it — required for App Store distribution. + hardened_runtime: Whether to sign with the hardened runtime + (Developer ID / notarization). App Store builds pass False — + the store's containment model is the App Sandbox, and Apple + re-signs store binaries on delivery. Returns: The total number of Mach-O binaries discovered in the bundle — all @@ -600,9 +651,9 @@ def sign_app( verified. Raises: - MacOSSigningError: If `app_path` is not an app bundle, the - entitlements file is missing or invalid, any codesign - invocation fails, or the final verification fails. + MacOSSigningError: If `app_path` is not an app bundle, an + entitlements or profile file is missing or invalid, any + codesign invocation fails, or the final verification fails. """ app_path = Path(app_path).resolve() @@ -611,9 +662,33 @@ def sign_app( entitlements = Path(entitlements) if entitlements else None if entitlements and not entitlements.is_file(): raise MacOSSigningError(f"Entitlements file not found: {entitlements}") + helper_entitlements = Path(helper_entitlements) if helper_entitlements else None + if helper_entitlements and not helper_entitlements.is_file(): + raise MacOSSigningError( + f"Helper entitlements file not found: {helper_entitlements}" + ) + + # Embed the provisioning profile before anything is signed so the app + # bundle's resource seal (created last) covers it — and before the + # xattr sweep below, which must also scrub the embedded copy: profiles + # are downloaded from the developer portal, so the source file carries + # com.apple.quarantine, macOS propagates it onto the copy, and App + # Store Connect processing rejects any quarantined file in the package + # (error 91109) — a check `altool --validate-app` does NOT perform. + if provisioning_profile is not None: + provisioning_profile = Path(provisioning_profile) + if not provisioning_profile.is_file(): + raise MacOSSigningError( + f"Provisioning profile not found: {provisioning_profile}" + ) + shutil.copy( + provisioning_profile, app_path / "Contents" / "embedded.provisionprofile" + ) + log("Embedded provisioning profile") # Quarantine and Finder-info extended attributes make codesign fail with - # "resource fork, Finder information, or similar detritus not allowed". + # "resource fork, Finder information, or similar detritus not allowed", + # and App Store Connect rejects quarantined files outright. _run(["xattr", "-cr", str(app_path)]) mach_o_files = find_mach_o_files(app_path) @@ -631,21 +706,41 @@ def depth(path: Path) -> int: normalized = ( _normalized_entitlements(entitlements, tmp_dir) if entitlements else None ) + # Helpers default to the app's entitlements; App Store builds pass a + # dedicated sandbox-inherit plist instead. + normalized_helper = ( + _normalized_entitlements(helper_entitlements, tmp_dir) + if helper_entitlements + else normalized + ) for f in sorted(inner_binaries, key=depth, reverse=True): log(f"Signing {f.relative_to(app_path)}") is_executable = mach_o_filetype(f) == MH_EXECUTE - _codesign(f, identity, entitlements=normalized if is_executable else None) + _codesign( + f, + identity, + entitlements=normalized_helper if is_executable else None, + hardened_runtime=hardened_runtime, + ) for bundle in sorted(find_nested_bundles(app_path), key=depth, reverse=True): log(f"Signing {bundle.relative_to(app_path)}") is_framework = bundle.suffix.lower() == ".framework" _codesign( - bundle, identity, entitlements=None if is_framework else normalized + bundle, + identity, + entitlements=None if is_framework else normalized_helper, + hardened_runtime=hardened_runtime, ) log(f"Signing {app_path.name}") - _codesign(app_path, identity, entitlements=normalized) + _codesign( + app_path, + identity, + entitlements=normalized, + hardened_runtime=hardened_runtime, + ) verify_app(app_path, mach_o_files, identity) return len(mach_o_files) @@ -844,3 +939,142 @@ def notarize_and_staple( f"Staple validation failed for {app_path}:\n" f"{result.stdout.strip()}\n{result.stderr.strip()}" ) + + +def verify_app_store_app(app_path: Path, application_identifier: str) -> None: + """Assert the App Store-specific invariants of a signed bundle. + + Complements `verify_app()` (which checks seals and signing identity) + with the two things App Store Connect ingestion additionally requires + and TestFlight enforces (ITMS-90889): an embedded provisioning profile, + and the `com.apple.application-identifier` entitlement on the main + executable matching the profile's App ID. + + Args: + app_path: The signed `.app` bundle. + application_identifier: Expected value, `.`. + + Raises: + MacOSSigningError: If the profile is missing or the sealed + entitlements do not carry the expected identifier. + """ + + profile = app_path / "Contents" / "embedded.provisionprofile" + if not profile.is_file(): + raise MacOSSigningError( + f"App Store build is missing {profile} — the provisioning " + "profile was not embedded." + ) + + main_executable = _main_executable(app_path) + if main_executable is None: + raise MacOSSigningError( + f"Cannot determine the main executable of {app_path} from its Info.plist." + ) + # codesign prints the entitlements XML on stdout, diagnostics on stderr. + result = _run( + [ + "codesign", + "--display", + "--entitlements", + "-", + "--xml", + str(main_executable), + ] + ) + entitlements: dict = {} + with contextlib.suppress(plistlib.InvalidFileException, ValueError): + entitlements = plistlib.loads(result.stdout.encode()) + found = entitlements.get("com.apple.application-identifier") + if found != application_identifier: + raise MacOSSigningError( + "The main executable's sealed entitlements carry " + f"com.apple.application-identifier={found!r}, expected " + f"{application_identifier!r}. TestFlight rejects such builds " + "(ITMS-90889)." + ) + + +def build_pkg( + app_path: Union[str, Path], + installer_identity: SigningIdentity, + output: Union[str, Path], + log: Callable[[str], None] = lambda message: None, +) -> Path: + """Build the signed installer package App Store Connect ingests. + + Store submissions are uploaded as a `.pkg` produced by `productbuild` + and signed with an installer certificate (`3rd Party Mac Developer + Installer` / `Mac Installer Distribution`) — a different certificate + type from the one that signed the app; resolve it with + `resolve_identity(..., policy="basic")`. The result is verified with + `pkgutil --check-signature`. + + Args: + app_path: The signed (App Store-style) `.app` bundle. + installer_identity: Installer certificate identity. + output: Path of the `.pkg` to write; replaced if it exists. + log: Progress callback; defaults to a no-op. + + Returns: + The path of the signed package. + + Raises: + MacOSSigningError: If `productbuild` fails or the package's + signature does not verify. + """ + + app_path = Path(app_path).resolve() + output = Path(output).resolve() + output.unlink(missing_ok=True) + + log(f"Building installer package {output.name}") + result = _run( + [ + "productbuild", + "--component", + str(app_path), + "/Applications", + "--sign", + installer_identity.sha1, + str(output), + ] + ) + if result.returncode != 0: + raise MacOSSigningError( + f"productbuild failed for {app_path}:\n{result.stderr.strip()}" + ) + + result = _run(["pkgutil", "--check-signature", str(output)]) + if result.returncode != 0: + raise MacOSSigningError( + f"Installer package signature verification failed for {output}:\n" + f"{result.stdout.strip()}\n{result.stderr.strip()}" + ) + return output + + +def profile_application_identifier(profile: Union[str, Path]) -> Optional[str]: + """Read the App ID a provisioning profile authorizes. + + Used to catch a profile/bundle-id mismatch before uploading — App Store + Connect rejects mismatches only after processing (ITMS-90889), which is + a slow way to find a wrong file path. + + Args: + profile: A `.provisionprofile` file (CMS-wrapped plist). + + Returns: + The profile's `com.apple.application-identifier` entitlement (e.g. + `TEAM123456.com.example.app`), or None if the profile cannot be + read or parsed. + """ + + result = _run(["security", "cms", "-D", "-i", str(profile)]) + if result.returncode != 0: + return None + try: + values = plistlib.loads(result.stdout.encode()) + return values["Entitlements"]["com.apple.application-identifier"] + except (plistlib.InvalidFileException, ValueError, KeyError, TypeError): + return None diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index 8646f234d3..04ea5f1a9f 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -341,7 +341,7 @@ def test_resolve_identity_rejects_ambiguous_and_unknown(monkeypatch): def test_resolve_identity_empty_keychain(monkeypatch): """An empty keychain should produce an actionable error.""" fake_security(monkeypatch, stdout=" 0 valid identities found\n") - with pytest.raises(MacOSSigningError, match="no valid codesigning identities"): + with pytest.raises(MacOSSigningError, match="no valid identities"): resolve_identity("Developer ID Application: Jane Doe (TEAM123456)") @@ -470,7 +470,7 @@ def test_sign_app_order_and_entitlements_routing(tmp_path, monkeypatch): monkeypatch.setattr( macos_sign, "_codesign", - lambda target, identity, entitlements=None: calls.append( + lambda target, identity, entitlements=None, hardened_runtime=True: calls.append( (target, entitlements is not None) ), ) @@ -814,3 +814,208 @@ def test_sign_app_rejects_non_bundle(tmp_path): """Signing should refuse paths that are not .app bundles.""" with pytest.raises(MacOSSigningError, match="Not an app bundle"): sign_app(tmp_path, ADHOC) + + +# --------------------------------------------------------------------------- +# App Store (MAS) mode +# --------------------------------------------------------------------------- + +from flet_cli.utils.macos_sign import ( # noqa: E402 + build_pkg, + identity_team_id, + profile_application_identifier, + verify_app_store_app, +) + +APPLE_DIST = SigningIdentity( + sha1="c" * 40, name="Apple Distribution: Jane Doe (TEAM123456)" +) +INSTALLER = SigningIdentity( + sha1="d" * 40, name="3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" +) + + +def test_identity_team_id(): + """The Team ID comes from the certificate name's parenthesized suffix.""" + assert identity_team_id(APPLE_DIST) == "TEAM123456" + assert identity_team_id(DEV_ID) == "TEAM123456" + assert identity_team_id(ADHOC) is None + assert identity_team_id(SigningIdentity(sha1="e" * 40, name="Self Signed")) is None + + +def test_resolve_identity_uses_policy(monkeypatch): + """The find-identity policy must be forwarded — installer certs are + invisible under `codesigning` and resolve only under `basic`.""" + captured = {} + + def fake_run(args, timeout=None): + assert args[:3] == ["security", "find-identity", "-v"] + captured["policy"] = args[args.index("-p") + 1] + return subprocess.CompletedProcess( + args, 0, f' 1) {"d" * 40} "{INSTALLER.name}"\n', "" + ) + + monkeypatch.setattr(macos_sign, "_run", fake_run) + resolved = resolve_identity("3rd Party Mac Developer Installer", policy="basic") + assert captured["policy"] == "basic" + assert resolved.sha1 == "d" * 40 + + +def test_sign_app_mas_mode(tmp_path, monkeypatch): + """App Store signing: no hardened runtime, helper entitlements routing, + and the provisioning profile embedded before the first signature.""" + app = build_signable_app(tmp_path) + app_ents = tmp_path / "app.entitlements" + app_ents.write_bytes(plistlib.dumps({"com.apple.security.app-sandbox": True})) + helper_ents = tmp_path / "helper.entitlements" + helper_ents.write_bytes( + plistlib.dumps( + { + "com.apple.security.app-sandbox": True, + "com.apple.security.inherit": True, + } + ) + ) + profile = tmp_path / "test.provisionprofile" + profile.write_bytes(b"fake profile bytes") + + embedded = app / "Contents" / "embedded.provisionprofile" + codesign_calls = [] + + def fake_run(args, timeout=None): + if args[0] == "xattr": + # the xattr sweep must scrub the *embedded* profile copy too — + # profiles are browser downloads, and a quarantined file inside + # the package is rejected by App Store Connect processing + # (error 91109, empirically build 2 of the playground app) + assert embedded.is_file(), "profile embedded after the xattr sweep" + if args[0] == "codesign": + # the profile must already be in place when signing starts + assert embedded.is_file(), "profile embedded after signing began" + codesign_calls.append(args) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + monkeypatch.setattr( + macos_sign, "verify_app", lambda app_path, files, identity: None + ) + + sign_app( + app, + APPLE_DIST, + entitlements=app_ents, + helper_entitlements=helper_ents, + provisioning_profile=profile, + hardened_runtime=False, + ) + + assert embedded.read_bytes() == b"fake profile bytes" + assert codesign_calls, "nothing was signed" + for args in codesign_calls: + assert "--options" not in args, f"hardened runtime leaked into {args}" + assert "--timestamp" in args # real identity keeps secure timestamps + + def entitlements_arg(args): + return ( + args[args.index("--entitlements") + 1] if "--entitlements" in args else None + ) + + # entitlements are normalized to "-normalized.plist" copies + by_target = {Path(args[-1]): entitlements_arg(args) for args in codesign_calls} + helper = app / "Contents" / "MacOS" / "helper" + assert "helper-normalized" in by_target[helper] + assert "app-normalized" in by_target[app] + # nested helper bundle also carries the helper entitlements + helper_app = app / "Contents" / "Frameworks" / "Helper.app" + assert "helper-normalized" in by_target[helper_app] + + +def test_verify_app_store_app(tmp_path, monkeypatch): + """The store check needs the embedded profile and a matching sealed + application-identifier.""" + app = tmp_path / "Test.app" + write(app / "Contents" / "MacOS" / "test", MACH_O_64) + (app / "Contents" / "Info.plist").write_bytes( + plistlib.dumps({"CFBundleExecutable": "test"}) + ) + + with pytest.raises(MacOSSigningError, match="missing.*embedded.provisionprofile"): + verify_app_store_app(app, "TEAM123456.dev.example.app") + + write(app / "Contents" / "embedded.provisionprofile", b"profile") + + def fake_run_with(identifier): + def fake_run(args, timeout=None): + assert args[0] == "codesign" and "--entitlements" in args + xml = plistlib.dumps( + {"com.apple.application-identifier": identifier} + ).decode() + return subprocess.CompletedProcess(args, 0, xml, "") + + return fake_run + + monkeypatch.setattr(macos_sign, "_run", fake_run_with("TEAM123456.dev.example.app")) + verify_app_store_app(app, "TEAM123456.dev.example.app") + + monkeypatch.setattr(macos_sign, "_run", fake_run_with("TEAM123456.dev.other")) + with pytest.raises(MacOSSigningError, match="ITMS-90889"): + verify_app_store_app(app, "TEAM123456.dev.example.app") + + +def test_build_pkg(tmp_path, monkeypatch): + """productbuild + pkgutil signature check, with failure propagation.""" + app = tmp_path / "Test.app" + app.mkdir() + out = tmp_path / "Test.pkg" + invocations = [] + + def fake_run_rc(productbuild_rc=0, pkgutil_rc=0): + def fake_run(args, timeout=None): + invocations.append(args) + rc = productbuild_rc if args[0] == "productbuild" else pkgutil_rc + return subprocess.CompletedProcess(args, rc, "", "boom") + + return fake_run + + monkeypatch.setattr(macos_sign, "_run", fake_run_rc()) + assert build_pkg(app, INSTALLER, out) == out.resolve() + assert invocations[0][0] == "productbuild" + assert "--sign" in invocations[0] and "d" * 40 in invocations[0] + assert invocations[1][:2] == ["pkgutil", "--check-signature"] + + monkeypatch.setattr(macos_sign, "_run", fake_run_rc(productbuild_rc=1)) + with pytest.raises(MacOSSigningError, match="productbuild failed"): + build_pkg(app, INSTALLER, out) + + monkeypatch.setattr(macos_sign, "_run", fake_run_rc(pkgutil_rc=1)) + with pytest.raises(MacOSSigningError, match="signature verification failed"): + build_pkg(app, INSTALLER, out) + + +def test_profile_application_identifier(tmp_path, monkeypatch): + """The profile's App ID comes from its CMS-wrapped Entitlements dict.""" + plist_xml = plistlib.dumps( + { + "Name": "test profile", + "Entitlements": { + "com.apple.application-identifier": "TEAM123456.dev.example.app" + }, + } + ).decode() + + def fake_run(args, timeout=None): + assert args[:3] == ["security", "cms", "-D"] + return subprocess.CompletedProcess(args, 0, plist_xml, "") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + assert ( + profile_application_identifier(tmp_path / "p.provisionprofile") + == "TEAM123456.dev.example.app" + ) + + monkeypatch.setattr( + macos_sign, + "_run", + lambda args, timeout=None: subprocess.CompletedProcess(args, 1, "", "err"), + ) + assert profile_application_identifier(tmp_path / "p.provisionprofile") is None diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index d08253c4b2..673e0a5d6c 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -578,28 +578,66 @@ Export your certificate and private key as a `.p12` file, then store it ## Mac App Store The signing support above targets **direct distribution** (your website, -GitHub releases, etc.). Publishing to the Mac App Store — including TestFlight -— is a different pipeline that `flet build` does not automate yet. It requires: +GitHub releases, etc.). For the Mac App Store — including TestFlight — +`flet build macos` has a dedicated mode that signs with your *Apple +Distribution* certificate (sandboxed, without the hardened runtime), embeds +your provisioning profile, applies the store-mandated +`application-identifier`/`team-identifier` entitlements (helper executables +get the sandbox `inherit` pair), and produces a signed installer `.pkg` +ready for upload: -- an *Apple Distribution* (not *Developer ID Application*) certificate, and a - *Mac Installer Distribution* certificate for the `.pkg`; -- a provisioning profile embedded at `Contents/embedded.provisionprofile`; -- the **App Sandbox** [entitlement](#entitlements) enabled: +```toml +[tool.flet.macos.info] +# required by App Store validation +LSApplicationCategoryType = "public.app-category.productivity" +# answers the export-compliance question per build (standard encryption only) +ITSAppUsesNonExemptEncryption = false - ```toml - [tool.flet.macos.entitlement] - "com.apple.security.app-sandbox" = true - ``` +[tool.flet.macos.signing] +identity = "Apple Distribution" +app_store = true +provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" +installer_identity = "3rd Party Mac Developer Installer" +``` + +or on the command line: `--macos-app-store`, `--macos-provisioning-profile` +and `--macos-installer-identity` +(`[env: FLET_MACOS_PROVISIONING_PROFILE=]`, `[env: +FLET_MACOS_INSTALLER_IDENTITY=]`). Notarization does **not** apply to store +submissions and is rejected in combination with `app_store`. + +One-time setup in the [developer portal](https://developer.apple.com/account) +and [App Store Connect](https://appstoreconnect.apple.com): + +1. Certificates: *Apple Distribution* plus *Mac Installer Distribution* + (the latter appears in the keychain as `3rd Party Mac Developer + Installer` — it signs packages, not code, so `security find-identity -v + -p codesigning` does not list it; use `-p basic`). +2. An explicit App ID matching your app's bundle id, and a **Mac App + Store** provisioning profile for it (referencing the Apple Distribution + certificate) — the file you point `provisioning_profile` at. +3. An App Store Connect app record for the bundle id; note its numeric + Apple ID for the upload. + +The App Sandbox is enabled automatically for store builds, and every +hardened-runtime exception entitlement (`com.apple.security.cs.*`, +including the defaults) is stripped — they are meaningless without the +hardened runtime and scrutinized by App Review. + +Upload the `.pkg` with [Transporter](https://apps.apple.com/app/transporter/id1450874784) +or from the command line (the App Store Connect API key `.p8` goes in +`~/.appstoreconnect/private_keys/`): + +``` +xcrun altool --validate-app -f build/macos/MyApp.pkg -t macos \ + --apiKey --apiIssuer +xcrun altool --upload-package build/macos/MyApp.pkg -t macos \ + --apiKey --apiIssuer \ + --apple-id --bundle-id \ + --bundle-version --bundle-short-version-string +``` -- `com.apple.application-identifier` and `com.apple.developer.team-identifier` - entitlements on the main executable, and `app-sandbox` + `inherit` - entitlements on helper executables; -- packaging with `productbuild` and uploading via Transporter or - `xcrun altool` — notarization does **not** apply to store submissions. - -When preparing a store build, also drop hardened-runtime exception -entitlements you don't strictly need — including the default -`com.apple.security.cs.allow-unsigned-executable-memory` (needed for -ctypes/cffi callbacks on Intel Macs; set it to `false` in -`[tool.flet.macos.entitlement]` if your app doesn't use them) — as App Review -scrutinizes each of them. +Every upload needs a unique build number (`flet build macos +--build-number N`). After processing (minutes; failures arrive by email as +`ITMS-xxxx` codes), the build appears in the TestFlight tab of your app +record — internal testers can install it without beta review. From 78cbc2135a2cfa40ea6b18d4ac3213bf2ecbaef4 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 22 Jul 2026 21:36:25 +0200 Subject: [PATCH 08/28] Polish macOS signing after full review Fixes and tightening from an adversarial review of the complete signing change set (both distribution lanes), each finding verified before fixing: Correctness: - Prefix-wildcard provisioning profiles (TEAM.com.example.*) were falsely rejected by the profile/bundle-id cross-check, which only accepted exact and full-team wildcards. Wildcard matching now lives in profile_covers_application() with tests. - An installer identity that resolved (under the basic keychain policy) to a non-installer certificate failed only later, deep inside productbuild; it is now rejected up front with the certificate named. - Malformed Info.plist / Release.entitlements in the App Store lane raised raw tracebacks; both now produce clean, actionable errors. - _normalized_entitlements() could silently collide when two entitlements files shared a stem; the normalized copy's name now includes a source-path digest. - verify_app_store_app() misreported a codesign --display failure as an entitlement mismatch; the two cases are now distinct errors. - FLET_WEB_RENDERER / FLET_WEB_ROUTE_URL_STRATEGY env fallbacks bypassed the CLI options' lowercasing; values are now lowered for parity. Organization: - The store entitlements policy moved from the CLI command into macos_sign.app_store_entitlements() (unit-testable), with the helper sandbox-inherit pair as APP_STORE_HELPER_ENTITLEMENTS. - Module docstring now maps both distribution lanes; store-section divider added; duplicated cleanup blocks merged; missing type annotations added; over-explaining docstrings trimmed. Tests (96 -> 107): - --options runtime/--timestamp for real identities is now asserted (the property notarization depends on, previously untested). - New darwin e2e signs an App Store-shaped bundle with real codesign and proves the xattr sweep strips quarantine from the embedded profile (the ASC error-91109 regression) and that the seal covers the profile. - Previously untested error branches covered: missing/invalid entitlements and profile inputs, deep-verification failure, unreadable sealed entitlements, missing main executable, malformed profile plist, staple validation failure; notarize/productbuild happy paths now assert credential forwarding and the /Applications install root. - resolve_identity's default policy pinned; xattr sweep deletion now fails a test; MAS naming unified to App Store; imports and identity fixtures consolidated. Docs: - FLET_MACOS_PROVISIONING_PROFILE and FLET_MACOS_INSTALLER_IDENTITY added to the environment-variables reference (they were undocumented); FLET_MACOS_SIGNING_IDENTITY entry updated for Apple Distribution. - Mac App Store section links options/env vars instead of raw argparse notation; helper-bundle entitlements phrasing corrected; duplicate credentials explanation trimmed; missing bash fence tag added. - Changelog: MAS bullet gains the quarantine/91109 scrub; entries added for the FLET_WEB_* env fallbacks and the flet publish route-url-strategy fix the branch also carries. --- CHANGELOG.md | 9 +- .../flet-cli/src/flet_cli/commands/build.py | 87 ++--- .../src/flet_cli/commands/build_base.py | 5 +- .../flet-cli/src/flet_cli/commands/publish.py | 5 +- .../flet-cli/src/flet_cli/utils/macos_sign.py | 231 +++++++++---- .../flet-cli/tests/test_macos_sign.py | 326 ++++++++++++++++-- website/docs/publish/macos.md | 25 +- .../docs/reference/environment-variables.md | 26 +- 8 files changed, 559 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f3c8231e..4f3d9dc153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,17 @@ ### New features -* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle and standalone helper executables, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities, and without a configured identity nothing changes (the app keeps its ad-hoc signature). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities, and without a configured identity nothing changes (the app keeps its ad-hoc signature). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. -* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-app-store` (or `[tool.flet.macos.signing].app_store`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded and cross-checked against the bundle id before signing, and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before any signing work: missing installer certificate or profile, a profile/bundle-id mismatch (the slow-to-surface ITMS-90889), a missing `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`), and combining `app_store` with `notarize` (store builds are not notarized) are all reported with the exact setting to fix. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. +* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-app-store` (or `[tool.flet.macos.signing].app_store`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded, cross-checked against the bundle id before signing, and scrubbed of the `com.apple.quarantine` attribute browser downloads carry (App Store Connect processing rejects quarantined package contents with error 91109 — a check `altool --validate-app` does not perform), and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before any signing work: missing installer certificate or profile, a profile/bundle-id mismatch (the slow-to-surface ITMS-90889), a missing `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`), and combining `app_store` with `notarize` (store builds are not notarized) are all reported with the exact setting to fix. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. + +### Improvements + +* Web builds and `flet publish` now read the `FLET_WEB_RENDERER`, `FLET_WEB_ROUTE_URL_STRATEGY`, and `FLET_WEB_NO_CDN` environment variables as fallbacks behind the CLI options and `[tool.flet.web]` pyproject keys, matching the `[env: ...]` notation the options already advertised, by @ndonkoHenri. ### Bug fixes +* Fix `flet publish`'s documented `[tool.flet.web].route_url_strategy` and `FLET_WEB_ROUTE_URL_STRATEGY` fallbacks being unreachable: the `--route-url-strategy` option's argparse default of `"path"` made the CLI value always win. The default now applies at the end of the resolution chain, as in `flet build`, by @ndonkoHenri. * Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. ## 0.86.1 diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 0b951d6c23..8ef29b2657 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -4,6 +4,7 @@ import shutil import tempfile from pathlib import Path +from typing import Callable from rich.console import Group from rich.live import Live @@ -12,13 +13,16 @@ from flet_cli.commands.flutter_base import verbose1_style from flet_cli.utils.android import flutter_target_platforms from flet_cli.utils.macos_sign import ( + APP_STORE_HELPER_ENTITLEMENTS, MacOSSigningError, NotaryCredentials, SigningIdentity, + app_store_entitlements, build_pkg, identity_team_id, notarize_and_staple, profile_application_identifier, + profile_covers_application, resolve_identity, sign_app, verify_app_store_app, @@ -225,7 +229,7 @@ def run_flutter(self): f"[/cyan] {self.emojis['checkmark']}", ) - def sign_macos_app(self): + def sign_macos_app(self) -> None: """ Code-sign — and optionally notarize — the built macOS app bundle. @@ -275,20 +279,14 @@ def sign_macos_app(self): ) if not identity: - if notarize: + if notarize or app_store: + what = "Notarization" if notarize else "App Store signing" self.cleanup( 1, - "Notarization requires a code-signing identity. Pass " + f"{what} requires a code-signing identity. Pass " "--macos-signing-identity or set " "`[tool.flet.macos.signing].identity` in pyproject.toml.", ) - if app_store: - self.cleanup( - 1, - "App Store signing requires an Apple Distribution " - "identity. Pass --macos-signing-identity or set " - "`[tool.flet.macos.signing].identity` in pyproject.toml.", - ) return apps = sorted(self.out_dir.glob("*.app")) @@ -360,25 +358,25 @@ def _sign_macos_app_store( app_path: Path, identity: SigningIdentity, entitlements: Path, - log, - ): + log: Callable[[str], None], + ) -> None: """ Sign for Mac App Store / TestFlight and build the installer package. The store lane differs from Developer ID signing in every dimension that matters: the app is signed with an Apple Distribution identity - *without* the hardened runtime; entitlements are the template's with - all `com.apple.security.cs.*` hardened-runtime exceptions stripped, - App Sandbox forced on, and the `application-identifier` / - `team-identifier` pair injected; helper executables carry exactly - the sandbox-inherit pair; a provisioning profile is embedded; and - the deliverable is a `.pkg` signed with an installer certificate, - not a notarized `.app`. + *without* the hardened runtime; entitlements come from + `app_store_entitlements()` (sandbox on, identifiers in, `cs.*` + exceptions stripped); helper executables and nested helper bundles + carry the sandbox-inherit pair; a provisioning profile is embedded; + and the deliverable is a `.pkg` signed with an installer + certificate, not a notarized `.app`. All prerequisites — installer identity, Team ID, provisioning profile, and the `LSApplicationCategoryType` Info.plist key App Store validation demands — are checked before any signing work, so - misconfiguration fails asap. + misconfiguration fails in seconds rather than after the + multi-minute signing pass. Exits via `cleanup(1, ...)` on any failure. """ @@ -425,7 +423,17 @@ def _sign_macos_app_store( ) # Installer certs sign packages, not code — resolved under the # `basic` policy, and before the signing pass so a typo fails fast. + # That policy also lists application certs, so a wrong-but-unique + # match is possible; catching it here avoids a cryptic productbuild + # failure after the multi-minute signing pass. installer = resolve_identity(installer_identity, policy="basic") + if not installer.is_adhoc and "Installer" not in installer.name: + self.cleanup( + 1, + f'"{installer.name}" is not an installer certificate. Store ' + 'packages must be signed with a "3rd Party Mac Developer ' + 'Installer" / "Mac Installer Distribution" certificate.', + ) profile = ( self.options.macos_provisioning_profile @@ -448,8 +456,15 @@ def _sign_macos_app_store( # Read the *built* app's bundle id — the authoritative value after # all project/org/bundle-id resolution and templating. info_path = app_path / "Contents" / "Info.plist" - info = plistlib.loads(info_path.read_bytes()) - bundle_id = info["CFBundleIdentifier"] + try: + info = plistlib.loads(info_path.read_bytes()) + bundle_id = info["CFBundleIdentifier"] + except (OSError, plistlib.InvalidFileException, ValueError, KeyError) as e: + self.cleanup( + 1, + f"Cannot read CFBundleIdentifier from {info_path}: {e}. " + "The built app bundle is malformed; re-run the build.", + ) application_identifier = f"{team_id}.{bundle_id}" # App Store validation hard-requires a category (empirically: 409 @@ -472,9 +487,8 @@ def _sign_macos_app_store( ) profile_app_id = profile_application_identifier(profile_path) - if profile_app_id is not None and profile_app_id not in ( - application_identifier, - f"{team_id}.*", + if profile_app_id is not None and not profile_covers_application( + profile_app_id, application_identifier ): self.cleanup( 1, @@ -484,28 +498,17 @@ def _sign_macos_app_store( "profile for this bundle id.", ) - # Store entitlements: the template's, minus every hardened-runtime - # exception (meaningless without the hardened runtime, and scrutinized - # by App Review), with the sandbox and identifiers forced in. - with open(entitlements, "rb") as f: - app_entitlements = { - k: v - for k, v in plistlib.load(f).items() - if not k.startswith("com.apple.security.cs.") - } - app_entitlements["com.apple.security.app-sandbox"] = True - app_entitlements["com.apple.application-identifier"] = application_identifier - app_entitlements["com.apple.developer.team-identifier"] = team_id - helper_entitlements = { - "com.apple.security.app-sandbox": True, - "com.apple.security.inherit": True, - } + # MacOSSigningError from here on is handled by sign_macos_app's + # enclosing try block. + app_entitlements = app_store_entitlements( + entitlements, application_identifier, team_id + ) with tempfile.TemporaryDirectory() as tmp: app_ents_path = Path(tmp) / "app.entitlements" app_ents_path.write_bytes(plistlib.dumps(app_entitlements)) helper_ents_path = Path(tmp) / "helper.entitlements" - helper_ents_path.write_bytes(plistlib.dumps(helper_entitlements)) + helper_ents_path.write_bytes(plistlib.dumps(APP_STORE_HELPER_ENTITLEMENTS)) signed_count = sign_app( app_path, diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index a432ffe204..b91d49b49c 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -1341,7 +1341,8 @@ def _xml_attr_value(v): "route_url_strategy": ( self.options.route_url_strategy or self.get_pyproject("tool.flet.web.route_url_strategy") - or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") + # lowered for parity with the CLI option's type=str.lower + or (os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "").lower() or "path" ), # "canvaskit" (dart2js), not "auto": with "auto" Chromium browsers @@ -1352,7 +1353,7 @@ def _xml_attr_value(v): "web_renderer": ( self.options.web_renderer or self.get_pyproject("tool.flet.web.renderer") - or os.getenv("FLET_WEB_RENDERER") + or (os.getenv("FLET_WEB_RENDERER") or "").lower() or "canvaskit" ), "pwa_background_color": ( diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py index 868988fcbf..4ab4ae8ca4 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py @@ -372,13 +372,14 @@ def filter_tar(tarinfo: tarfile.TarInfo): web_renderer=WebRenderer( options.web_renderer or get_pyproject("tool.flet.web.renderer") - or os.getenv("FLET_WEB_RENDERER") + # lowered for parity with the CLI option's type=str.lower + or (os.getenv("FLET_WEB_RENDERER") or "").lower() or "canvaskit" ), route_url_strategy=RouteUrlStrategy( options.route_url_strategy or get_pyproject("tool.flet.web.route_url_strategy") - or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") + or (os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "").lower() or "path" ), no_cdn=no_cdn, diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 4e319157cb..93676f9139 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -7,19 +7,23 @@ in resource bundles (where the embedded Python stdlib and site-packages live), which is why binaries are discovered and signed individually. -Typical flow, as driven by `flet build macos`: +Two distribution lanes, as driven by `flet build macos`: -1. `resolve_identity()` — validate the requested identity against the - keychain before spending time on anything else. -2. `sign_app()` — discover, sign, and verify the bundle. -3. `notarize_and_staple()` — optional, requires a real (non-ad-hoc) - identity and `NotaryCredentials`. +- Developer ID (direct distribution): `resolve_identity()` → + `sign_app()` (hardened runtime) → optional `notarize_and_staple()`. +- Mac App Store / TestFlight: `resolve_identity()` (app + installer + certificates) → `profile_application_identifier()` / + `app_store_entitlements()` → `sign_app(hardened_runtime=False, + helper_entitlements=…, provisioning_profile=…)` → + `verify_app_store_app()` → `build_pkg()`. Store builds are never + notarized — Apple reviews and re-signs them. All failures raise `MacOSSigningError` with a user-actionable message; no other exception type is intentionally propagated. """ import contextlib +import hashlib import json import os import plistlib @@ -69,9 +73,8 @@ class SigningIdentity: """ The certificate's SHA-1 fingerprint (40 hex characters), or `-` for the ad-hoc pseudo-identity. Passed to `codesign --sign`; the fingerprint is - preferred over the name because it stays unambiguous when several - certificates share a name (e.g. a renewed certificate alongside an - expired one). + preferred over the name because it stays unambiguous when several certificates + share a name (e.g. a renewed certificate alongside an expired one). """ name: str @@ -124,10 +127,6 @@ def as_args(self) -> list[str]: Used for both `notarytool submit` and `notarytool log`, which must authenticate with the same credentials. - - Returns: - `["--keychain-profile", ...]` when a profile is set, otherwise - `["--key", ..., "--key-id", ..., "--issuer", ...]`. """ if self.keychain_profile: return ["--keychain-profile", self.keychain_profile] @@ -143,16 +142,10 @@ def as_args(self) -> list[str]: def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.CompletedProcess: - """Run a command, capturing text output and never raising on exit code. - - Args: - args: Full command line, executable first. - timeout: Seconds to wait before `subprocess.TimeoutExpired` is - raised; `None` waits indefinitely. Only the notarization - submit uses a timeout — everything else is local and fast. + """Run a command, capturing text output; callers inspect `returncode`. - Returns: - The completed process; callers decide how to treat `returncode`. + Only the notarization submit passes a `timeout` — everything else is + local and fast. """ return subprocess.run( args, @@ -165,9 +158,8 @@ def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.Completed def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdentity: """Resolve a user-provided identity against the keychain. - Fails fast — with the list of available identities — instead of letting - a typo'd identity surface later as an opaque `codesign` error for every - file in the bundle. + Fails fast — with the list of available identities — to avoid a typo'd identity + surface later as an opaque `codesign` error for every file in the bundle. Args: identity: `-` for ad-hoc signing, a 40-hex SHA-1 fingerprint, the @@ -228,14 +220,52 @@ def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdent problem = ( "matches multiple identities" if matches else "does not match any identity" ) + # The ad-hoc hint only makes sense for code-signing lookups — installer + # certificates have no ad-hoc equivalent. + hint = "Pass the exact certificate name or its SHA-1 fingerprint" + ( + ', or "-" for ad-hoc signing.' if policy == "codesigning" else "." + ) + # On a miss, check whether the identity exists but is invalid — without + # this, an expired certificate (invisible to `-v` but plainly visible in + # Keychain Access) reads as "no such identity", which is baffling. + invalid = "" if matches else _invalid_identity_reason(identity, policy) raise MacOSSigningError( f'Signing identity "{identity}" {problem} in the keychain. ' - f'Valid identities for the "{policy}" policy:\n{listing}\n' - "Pass the exact certificate name, its SHA-1 fingerprint, " - 'or "-" for ad-hoc signing.' + + invalid + + f'Valid identities for the "{policy}" policy:\n{listing}\n{hint}' ) +def _invalid_identity_reason(identity: str, policy: str) -> str: + """Explain a resolution miss caused by an existing-but-invalid identity. + + Re-queries the keychain without `-v` (which hides expired, revoked, and + untrusted certificates) and reports the status code `security` prints, + e.g. `CSSMERR_TP_CERT_EXPIRED`. + + Returns: + A sentence for the resolution error, or "" if the identity does not + exist at all (or the re-query fails). + """ + + result = _run(["security", "find-identity", "-p", policy]) + if result.returncode != 0: + return "" + for m in re.finditer( + r"([0-9A-Fa-f]{40})\s+\"([^\"]+)\"(?:\s+\((\w+)\))?", result.stdout + ): + sha1, name, status = m.group(1), m.group(2), m.group(3) + if identity.lower() == sha1.lower() or identity in name: + reason = f" ({status})" if status else "" + return ( + f'A matching certificate "{name}" exists but is not valid ' + f"for signing{reason} — expired, revoked, or untrusted. " + "Renew it at https://developer.apple.com/account/resources/" + "certificates.\n" + ) + return "" + + def identity_team_id(identity: SigningIdentity) -> Optional[str]: """Extract the Team ID from a certificate's common name. @@ -537,9 +567,11 @@ def _normalized_entitlements(entitlements: Path, tmp_dir: str) -> Path: except (OSError, plistlib.InvalidFileException, ValueError) as e: raise MacOSSigningError(f"Invalid entitlements file {entitlements}: {e}") from e - # Keep the original stem: several entitlements files (app + helper) may - # be normalized into the same directory and must not overwrite each other. - normalized = Path(tmp_dir) / f"{Path(entitlements).stem}-normalized.plist" + # Several entitlements files (app + helper) may be normalized into the + # same directory and must not overwrite each other — same-stem sources + # would collide, so the source path disambiguates the copy's name. + digest = hashlib.md5(str(Path(entitlements).resolve()).encode()).hexdigest()[:8] + normalized = Path(tmp_dir) / f"{Path(entitlements).stem}-normalized-{digest}.plist" with open(normalized, "wb") as f: plistlib.dump(values, f) return normalized @@ -941,6 +973,107 @@ def notarize_and_staple( ) +# --- Mac App Store / TestFlight ------------------------------------------- +# Store builds are sandboxed instead of hardened-runtime-protected, carry an +# embedded provisioning profile plus identifier entitlements, and ship as an +# installer .pkg — Apple re-signs everything on delivery. + +# Helper executables inside a sandboxed app must join the parent's sandbox +# rather than declare their own capabilities. +APP_STORE_HELPER_ENTITLEMENTS = { + "com.apple.security.app-sandbox": True, + "com.apple.security.inherit": True, +} + + +def profile_application_identifier(profile: Union[str, Path]) -> Optional[str]: + """Read the App ID a provisioning profile authorizes. + + Used to catch a profile/bundle-id mismatch before uploading — App Store + Connect rejects mismatches only after processing (ITMS-90889), which is + a slow way to find a wrong file path. + + Args: + profile: A `.provisionprofile` file (CMS-wrapped plist). + + Returns: + The profile's `com.apple.application-identifier` entitlement (e.g. + `TEAM123456.com.example.app` or a wildcard like + `TEAM123456.com.example.*`), or None if the profile cannot be + read or parsed. + """ + + result = _run(["security", "cms", "-D", "-i", str(profile)]) + if result.returncode != 0: + return None + try: + values = plistlib.loads(result.stdout.encode()) + return values["Entitlements"]["com.apple.application-identifier"] + except (plistlib.InvalidFileException, ValueError, KeyError, TypeError): + return None + + +def profile_covers_application( + profile_app_id: str, application_identifier: str +) -> bool: + """Check whether a profile's App ID covers an application identifier. + + Apple App IDs are either explicit (`TEAM.com.example.app`) or wildcard + (`TEAM.*`, `TEAM.com.example.*`) — a wildcard covers every identifier + sharing its prefix. + + Args: + profile_app_id: The profile's `com.apple.application-identifier`. + application_identifier: The app's `.`. + + Returns: + True if the profile authorizes this application identifier. + """ + + if profile_app_id.endswith("*"): + return application_identifier.startswith(profile_app_id[:-1]) + return profile_app_id == application_identifier + + +def app_store_entitlements( + entitlements: Union[str, Path], + application_identifier: str, + team_identifier: str, +) -> dict: + """Derive App Store app entitlements from a build's entitlements file. + + Every `com.apple.security.cs.*` hardened-runtime exception is stripped — + meaningless without the hardened runtime, and scrutinized by App Review — + while the App Sandbox and the identifier pair the store mandates + (TestFlight rejects builds without them, ITMS-90889) are forced in. + + Args: + entitlements: The rendered (Developer ID-style) entitlements plist. + application_identifier: `.`. + team_identifier: The 10-character Team ID. + + Returns: + The entitlements dict to sign the app bundle with. + + Raises: + MacOSSigningError: If the entitlements file cannot be parsed. + """ + + try: + with open(entitlements, "rb") as f: + values = { + k: v + for k, v in plistlib.load(f).items() + if not k.startswith("com.apple.security.cs.") + } + except (OSError, plistlib.InvalidFileException, ValueError, AttributeError) as e: + raise MacOSSigningError(f"Invalid entitlements file {entitlements}: {e}") from e + values["com.apple.security.app-sandbox"] = True + values["com.apple.application-identifier"] = application_identifier + values["com.apple.developer.team-identifier"] = team_identifier + return values + + def verify_app_store_app(app_path: Path, application_identifier: str) -> None: """Assert the App Store-specific invariants of a signed bundle. @@ -955,8 +1088,9 @@ def verify_app_store_app(app_path: Path, application_identifier: str) -> None: application_identifier: Expected value, `.`. Raises: - MacOSSigningError: If the profile is missing or the sealed - entitlements do not carry the expected identifier. + MacOSSigningError: If the profile is missing, the sealed + entitlements cannot be read, or they do not carry the expected + identifier. """ profile = app_path / "Contents" / "embedded.provisionprofile" @@ -982,6 +1116,11 @@ def verify_app_store_app(app_path: Path, application_identifier: str) -> None: str(main_executable), ] ) + if result.returncode != 0: + raise MacOSSigningError( + f"Cannot read the sealed entitlements of {main_executable}:\n" + f"{result.stderr.strip()}" + ) entitlements: dict = {} with contextlib.suppress(plistlib.InvalidFileException, ValueError): entitlements = plistlib.loads(result.stdout.encode()) @@ -1052,29 +1191,3 @@ def build_pkg( f"{result.stdout.strip()}\n{result.stderr.strip()}" ) return output - - -def profile_application_identifier(profile: Union[str, Path]) -> Optional[str]: - """Read the App ID a provisioning profile authorizes. - - Used to catch a profile/bundle-id mismatch before uploading — App Store - Connect rejects mismatches only after processing (ITMS-90889), which is - a slow way to find a wrong file path. - - Args: - profile: A `.provisionprofile` file (CMS-wrapped plist). - - Returns: - The profile's `com.apple.application-identifier` entitlement (e.g. - `TEAM123456.com.example.app`), or None if the profile cannot be - read or parsed. - """ - - result = _run(["security", "cms", "-D", "-i", str(profile)]) - if result.returncode != 0: - return None - try: - values = plistlib.loads(result.stdout.encode()) - return values["Entitlements"]["com.apple.application-identifier"] - except (plistlib.InvalidFileException, ValueError, KeyError, TypeError): - return None diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index 04ea5f1a9f..a502d164a8 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -3,7 +3,7 @@ Most tests are platform-independent: Mach-O discovery and classification are exercised against hand-crafted header bytes, and keychain identity resolution against canned `security find-identity` output (no real -certificates or keychains are touched). Only the two tests marked +certificates or keychains are touched). Only the tests marked `skipif(sys.platform != "darwin")` invoke the real `codesign` — ad-hoc signing needs no certificate, so they run on any Mac, including CI runners. """ @@ -26,21 +26,26 @@ NotaryCredentials, SigningIdentity, _is_bundle_main_binary, + app_store_entitlements, + build_pkg, find_mach_o_files, find_nested_bundles, + identity_team_id, is_mach_o, mach_o_filetype, notarize_and_staple, + profile_application_identifier, + profile_covers_application, resolve_identity, sign_app, verify_app, + verify_app_store_app, ) # Minimal file contents that `is_mach_o()` must classify correctly: thin -# 64-bit (little-endian file) and 32-bit (big-endian file) Mach-O headers, a -# fat header with a plausible architecture count, and a Java class file — -# which shares the fat magic `0xcafebabe` but carries its format version -# (>= 45) where a fat header carries `nfat_arch`. +# 64-bit (little-endian file) and 32-bit (big-endian file) Mach-O headers, +# fat headers with plausible architecture counts, and a Java class file +# (shares the fat magic — see `is_mach_o()`). MACH_O_64 = b"\xcf\xfa\xed\xfe" + b"\x00" * 12 MACH_O_32 = b"\xfe\xed\xfa\xce" + b"\x00" * 12 FAT_TWO_ARCHS = b"\xca\xfe\xba\xbe" + (2).to_bytes(4, "big") + b"\x00" * 8 @@ -308,6 +313,8 @@ def fake_security(monkeypatch, stdout=SECURITY_LISTING, returncode=0): def fake_run(args, timeout=None): assert args[0] == "security" + # pins resolve_identity's default policy for its pre-existing callers + assert args[args.index("-p") + 1] == "codesigning" return subprocess.CompletedProcess(args, returncode, stdout, "") monkeypatch.setattr(macos_sign, "_run", fake_run) @@ -382,10 +389,16 @@ def test_notary_credentials_args(): ).as_args() == ["--key", "key.p8", "--key-id", "KID", "--issuer", "ISS"] -# A real (non-ad-hoc) identity for tests that never touch the keychain. +# Real (non-ad-hoc) identities for tests that never touch the keychain. DEV_ID = SigningIdentity( sha1="a" * 40, name="Developer ID Application: Jane Doe (TEAM123456)" ) +APPLE_DIST = SigningIdentity( + sha1="c" * 40, name="Apple Distribution: Jane Doe (TEAM123456)" +) +INSTALLER = SigningIdentity( + sha1="d" * 40, name="3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" +) def build_signable_app(tmp_path: Path) -> Path: @@ -691,7 +704,7 @@ def fake_run(args, timeout=None): def test_notarize_and_staple_happy_path(tmp_path, monkeypatch): - """Archive, submit, staple, validate — in that order.""" + """Archive, submit, staple, validate — in that order, with credentials.""" invocations = fake_notary_runner(monkeypatch) notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) @@ -702,6 +715,9 @@ def test_notarize_and_staple_happy_path(tmp_path, monkeypatch): "stapler staple", "stapler validate", ] + submit = invocations[1] + assert submit[-2:] == ["--keychain-profile", "flet"] + assert "--wait" in submit def test_notarize_and_staple_archive_failure(tmp_path, monkeypatch): @@ -809,30 +825,46 @@ def test_sign_app_adhoc_end_to_end(tmp_path): assert result.returncode == 0, result.stderr -@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS codesign") def test_sign_app_rejects_non_bundle(tmp_path): - """Signing should refuse paths that are not .app bundles.""" + """Signing should refuse paths that are not .app bundles. + + Not darwin-gated: the check fires before any subprocess runs. + """ with pytest.raises(MacOSSigningError, match="Not an app bundle"): sign_app(tmp_path, ADHOC) -# --------------------------------------------------------------------------- -# App Store (MAS) mode -# --------------------------------------------------------------------------- +def test_sign_app_rejects_missing_inputs(tmp_path): + """Missing entitlements/helper-entitlements/profile files fail fast.""" + app = tmp_path / "Test.app" + write(app / "Contents" / "MacOS" / "test", MACH_O_64) -from flet_cli.utils.macos_sign import ( # noqa: E402 - build_pkg, - identity_team_id, - profile_application_identifier, - verify_app_store_app, -) + with pytest.raises(MacOSSigningError, match="Entitlements file not found"): + sign_app(app, ADHOC, entitlements=tmp_path / "missing.plist") + with pytest.raises(MacOSSigningError, match="Helper entitlements file not found"): + sign_app(app, ADHOC, helper_entitlements=tmp_path / "missing.plist") + with pytest.raises(MacOSSigningError, match="Provisioning profile not found"): + sign_app(app, ADHOC, provisioning_profile=tmp_path / "missing.provisionprofile") -APPLE_DIST = SigningIdentity( - sha1="c" * 40, name="Apple Distribution: Jane Doe (TEAM123456)" -) -INSTALLER = SigningIdentity( - sha1="d" * 40, name="3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" -) + +def test_sign_app_rejects_invalid_entitlements(tmp_path, monkeypatch): + """A non-plist entitlements file fails with a clean error, not a traceback.""" + app = tmp_path / "Test.app" + write(app / "Contents" / "MacOS" / "test", MACH_O_64) + bad = write(tmp_path / "bad.plist", b"not a plist at all") + monkeypatch.setattr( + macos_sign, + "_run", + lambda args, timeout=None: subprocess.CompletedProcess(args, 0, "", ""), + ) + + with pytest.raises(MacOSSigningError, match="Invalid entitlements file"): + sign_app(app, ADHOC, entitlements=bad) + + +# --------------------------------------------------------------------------- +# App Store mode +# --------------------------------------------------------------------------- def test_identity_team_id(): @@ -861,7 +893,7 @@ def fake_run(args, timeout=None): assert resolved.sha1 == "d" * 40 -def test_sign_app_mas_mode(tmp_path, monkeypatch): +def test_sign_app_app_store_mode(tmp_path, monkeypatch): """App Store signing: no hardened runtime, helper entitlements routing, and the provisioning profile embedded before the first signature.""" app = build_signable_app(tmp_path) @@ -881,14 +913,16 @@ def test_sign_app_mas_mode(tmp_path, monkeypatch): embedded = app / "Contents" / "embedded.provisionprofile" codesign_calls = [] + xattr_sweeps = [] def fake_run(args, timeout=None): if args[0] == "xattr": # the xattr sweep must scrub the *embedded* profile copy too — - # profiles are browser downloads, and a quarantined file inside - # the package is rejected by App Store Connect processing - # (error 91109, empirically build 2 of the playground app) + # profiles are browser downloads carrying com.apple.quarantine, + # and App Store Connect processing rejects any quarantined file + # in the package (error 91109, observed empirically) assert embedded.is_file(), "profile embedded after the xattr sweep" + xattr_sweeps.append(args) if args[0] == "codesign": # the profile must already be in place when signing starts assert embedded.is_file(), "profile embedded after signing began" @@ -909,6 +943,9 @@ def fake_run(args, timeout=None): hardened_runtime=False, ) + # the sweep itself must happen — deleting it would resurrect the 91109 + # App Store rejection + assert ["xattr", "-cr", str(app)] in xattr_sweeps assert embedded.read_bytes() == b"fake profile bytes" assert codesign_calls, "nothing was signed" for args in codesign_calls: @@ -979,7 +1016,9 @@ def fake_run(args, timeout=None): monkeypatch.setattr(macos_sign, "_run", fake_run_rc()) assert build_pkg(app, INSTALLER, out) == out.resolve() - assert invocations[0][0] == "productbuild" + assert invocations[0][:2] == ["productbuild", "--component"] + assert str(app.resolve()) in invocations[0] + assert "/Applications" in invocations[0] # the store-mandated install root assert "--sign" in invocations[0] and "d" * 40 in invocations[0] assert invocations[1][:2] == ["pkgutil", "--check-signature"] @@ -1019,3 +1058,232 @@ def fake_run(args, timeout=None): lambda args, timeout=None: subprocess.CompletedProcess(args, 1, "", "err"), ) assert profile_application_identifier(tmp_path / "p.provisionprofile") is None + + +def test_sign_app_hardened_runtime_default(tmp_path, monkeypatch): + """Developer ID signing must carry --options runtime and --timestamp. + + The one property production notarization depends on and no other test + asserts: without the hardened runtime flag every notarization fails. + """ + app = tmp_path / "Test.app" + write(app / "Contents" / "MacOS" / "test", thin_mach_o(MH_EXECUTE), True) + (app / "Contents" / "Info.plist").write_bytes( + plistlib.dumps({"CFBundleExecutable": "test"}) + ) + write(app / "Contents" / "Resources" / "x.so", thin_mach_o(MH_DYLIB)) + codesign_calls = [] + + def fake_run(args, timeout=None): + if args[0] == "codesign": + codesign_calls.append(args) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + monkeypatch.setattr( + macos_sign, "verify_app", lambda app_path, files, identity: None + ) + + sign_app(app, DEV_ID) + + assert codesign_calls + for args in codesign_calls: + assert "--timestamp" in args + assert args[args.index("--options") + 1] == "runtime" + + +def test_profile_covers_application(): + """Explicit and wildcard App IDs; wildcards cover matching prefixes.""" + assert profile_covers_application("T.dev.example.app", "T.dev.example.app") + assert profile_covers_application("T.*", "T.dev.example.app") + assert profile_covers_application("T.dev.example.*", "T.dev.example.app") + assert not profile_covers_application("T.dev.other.*", "T.dev.example.app") + assert not profile_covers_application("T.dev.example.app", "T.dev.example.two") + + +def test_app_store_entitlements(tmp_path): + """cs.* exceptions stripped; sandbox and identifiers forced in.""" + source = tmp_path / "Release.entitlements" + source.write_bytes( + plistlib.dumps( + { + "com.apple.security.cs.allow-jit": True, + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.network.client": True, + "com.apple.security.app-sandbox": False, + } + ) + ) + + values = app_store_entitlements(source, "T.dev.example.app", "T") + + assert values == { + "com.apple.security.network.client": True, + "com.apple.security.app-sandbox": True, + "com.apple.application-identifier": "T.dev.example.app", + "com.apple.developer.team-identifier": "T", + } + + bad = write(tmp_path / "bad.entitlements", b"not a plist") + with pytest.raises(MacOSSigningError, match="Invalid entitlements file"): + app_store_entitlements(bad, "T.dev.example.app", "T") + + +def test_verify_app_deep_verification_failure(tmp_path, monkeypatch): + """A broken bundle seal fails before any per-file check runs.""" + app = tmp_path / "Test.app" + binary = write(app / "Contents" / "MacOS" / "test", MACH_O_64) + + def fake_run(args, timeout=None): + rc = 1 if "--deep" in args else 0 + return subprocess.CompletedProcess(args, rc, "", "seal broken") + + monkeypatch.setattr(macos_sign, "_run", fake_run) + with pytest.raises(MacOSSigningError, match="Signature verification failed"): + verify_app(app, [binary], ADHOC) + + +def test_verify_app_store_app_unreadable_entitlements(tmp_path, monkeypatch): + """A codesign --display failure is reported as such, not as a mismatch.""" + app = tmp_path / "Test.app" + write(app / "Contents" / "MacOS" / "test", MACH_O_64) + (app / "Contents" / "Info.plist").write_bytes( + plistlib.dumps({"CFBundleExecutable": "test"}) + ) + write(app / "Contents" / "embedded.provisionprofile", b"profile") + monkeypatch.setattr( + macos_sign, + "_run", + lambda args, timeout=None: subprocess.CompletedProcess(args, 1, "", "no sig"), + ) + + with pytest.raises(MacOSSigningError, match="Cannot read the sealed entitlements"): + verify_app_store_app(app, "T.dev.example.app") + + +def test_verify_app_store_app_missing_main_executable(tmp_path): + """An unreadable Info.plist is a clean error, not a KeyError.""" + app = tmp_path / "Test.app" + write(app / "Contents" / "embedded.provisionprofile", b"profile") + + with pytest.raises(MacOSSigningError, match="Cannot determine the main executable"): + verify_app_store_app(app, "T.dev.example.app") + + +def test_profile_application_identifier_malformed(tmp_path, monkeypatch): + """Unparsable CMS output degrades to None, never an exception.""" + monkeypatch.setattr( + macos_sign, + "_run", + lambda args, timeout=None: subprocess.CompletedProcess( + args, 0, "not a plist", "" + ), + ) + assert profile_application_identifier(tmp_path / "p.provisionprofile") is None + + +def test_notarize_and_staple_validate_failure(tmp_path, monkeypatch): + """A stapled-but-unvalidatable ticket is still an error.""" + fake_notary_runner(monkeypatch) + happy_run = macos_sign._run + + def fake_run(args, timeout=None): + if args[:3] == ["xcrun", "stapler", "validate"]: + return subprocess.CompletedProcess(args, 1, "", "bad ticket") + return happy_run(args, timeout) + + monkeypatch.setattr(macos_sign, "_run", fake_run) + + with pytest.raises(MacOSSigningError, match="Staple validation failed"): + notarize_and_staple(tmp_path / "Test.app", CREDENTIALS) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS codesign") +def test_sign_app_app_store_end_to_end(tmp_path): + """App Store-shaped signing against real codesign, ad-hoc. + + Verifies the two effects unit mocks cannot: the xattr sweep strips a + real quarantine attribute from the embedded profile copy (App Store + Connect rejects quarantined package contents, error 91109 — observed), + and strict deep verification proves the bundle seal covers the profile. + """ + app = tmp_path / "Test.app" + macos_dir = app / "Contents" / "MacOS" + macos_dir.mkdir(parents=True) + shutil.copy(os.path.realpath(sys.executable), macos_dir / "test") + with open(app / "Contents" / "Info.plist", "wb") as f: + plistlib.dump( + { + "CFBundleExecutable": "test", + "CFBundleIdentifier": "dev.flet.signtest.mas", + "CFBundleName": "Test", + "CFBundlePackageType": "APPL", + }, + f, + ) + entitlements = tmp_path / "app.entitlements" + entitlements.write_bytes(plistlib.dumps({"com.apple.security.app-sandbox": True})) + profile = tmp_path / "test.provisionprofile" + profile.write_bytes(b"fake profile bytes") + subprocess.run( + ["xattr", "-w", "com.apple.quarantine", "0083;0;test;", str(profile)], + check=True, + ) + + sign_app( + app, + ADHOC, + entitlements=entitlements, + provisioning_profile=profile, + hardened_runtime=False, + ) + + embedded = app / "Contents" / "embedded.provisionprofile" + listed = subprocess.run( + ["xattr", str(embedded)], capture_output=True, text=True + ).stdout + assert "com.apple.quarantine" not in listed + result = subprocess.run( + ["codesign", "--verify", "--deep", "--strict", str(app)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # modifying the profile now must break the seal it is covered by + embedded.write_bytes(b"tampered") + result = subprocess.run( + ["codesign", "--verify", "--deep", "--strict", str(app)], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + + +def test_resolve_identity_explains_expired_certificate(monkeypatch): + """An existing-but-invalid certificate must be named, not read as absent. + + `security find-identity -v` hides expired/revoked certificates, so + without the second (unfiltered) query the error would claim no such + identity exists while the user sees it in Keychain Access. + """ + full = "Developer ID Application: Jane Doe (TEAM123456)" + + def fake_run(args, timeout=None): + assert args[0] == "security" + if "-v" in args: + return subprocess.CompletedProcess( + args, 0, " 0 valid identities found\n", "" + ) + return subprocess.CompletedProcess( + args, + 0, + f' 1) {"a" * 40} "{full}" (CSSMERR_TP_CERT_EXPIRED)\n', + "", + ) + + monkeypatch.setattr(macos_sign, "_run", fake_run) + with pytest.raises( + MacOSSigningError, + match=r"not valid for signing \(CSSMERR_TP_CERT_EXPIRED\)", + ): + resolve_identity(full) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 673e0a5d6c..0970af7c6c 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -432,8 +432,9 @@ When a real identity is configured, `flet build macos` will, after the build: [Apple requires](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-macos), with the **hardened runtime** enabled and a secure timestamp (both required for notarization). [Entitlements](#entitlements) are applied to the app - bundle and to standalone helper executables shipped by your dependencies; - libraries are signed without entitlements, per Apple guidance. + bundle and to helper executables and helper bundles shipped by your + dependencies; frameworks and libraries are signed without entitlements, + per Apple guidance. 2. Verify the result with `codesign --verify --deep --strict` and check that no binary was left unsigned. @@ -452,10 +453,7 @@ service (a malware scan, typically a few minutes), after which the resulting Apple's notary service accepts two kinds of credentials: your **Apple ID** with an [app-specific password](https://support.apple.com/102654), or an **App Store Connect API key** (a `.p8` key file with its key ID and issuer -ID). Flet can receive them through either of two channels — a keychain -profile is not a different kind of credential, just the same secrets stored -once in the macOS keychain under a name instead of being passed on every -invocation: +ID). Flet can receive either kind through two channels: - **Keychain profile** (best for local development) — a one-time interactive setup that saves either credential kind into the keychain: @@ -600,11 +598,14 @@ provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" installer_identity = "3rd Party Mac Developer Installer" ``` -or on the command line: `--macos-app-store`, `--macos-provisioning-profile` -and `--macos-installer-identity` -(`[env: FLET_MACOS_PROVISIONING_PROFILE=]`, `[env: -FLET_MACOS_INSTALLER_IDENTITY=]`). Notarization does **not** apply to store -submissions and is rejected in combination with `app_store`. +The same settings are available as the `--macos-app-store`, +`--macos-provisioning-profile` and `--macos-installer-identity` command-line +options and the +[`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) +and +[`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) +environment variables. Notarization does **not** apply to store submissions +and is rejected in combination with `app_store`. One-time setup in the [developer portal](https://developer.apple.com/account) and [App Store Connect](https://appstoreconnect.apple.com): @@ -628,7 +629,7 @@ Upload the `.pkg` with [Transporter](https://apps.apple.com/app/transporter/id14 or from the command line (the App Store Connect API key `.p8` goes in `~/.appstoreconnect/private_keys/`): -``` +```bash xcrun altool --validate-app -f build/macos/MyApp.pkg -t macos \ --apiKey --apiIssuer xcrun altool --upload-package build/macos/MyApp.pkg -t macos \ diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index ae6a4a3610..c931ba68f9 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -147,24 +147,36 @@ Set to `true` to start app with the main window hidden. Defaults to `False`. +### `FLET_MACOS_INSTALLER_IDENTITY` + +Installer certificate ("3rd Party Mac Developer Installer" / +"Mac Installer Distribution" name or SHA-1 fingerprint) +[used](../publish/macos.md#mac-app-store) by `flet build` to sign the +installer `.pkg` of a Mac App Store build. + ### `FLET_MACOS_NOTARY_PROFILE` Name of the `notarytool` keychain profile (created with `xcrun notarytool store-credentials`) [used](../publish/macos.md#notarization) by `flet build` to authenticate with the Apple notary service when notarizing a macOS app. -A profile is not a separate kind of credential — it is the same Apple ID or -App Store Connect API key credentials, stored once in the macOS keychain -under a name. Alternatively, set the `APPLE_API_KEY` (path to the `.p8` -file), `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` environment variables to -pass an App Store Connect API key inline; a configured profile takes +Alternatively, set the `APPLE_API_KEY` (path to the `.p8` file), +`APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` environment variables to pass an +App Store Connect API key inline; a configured profile takes [precedence](../publish/macos.md#credentials) over them. +### `FLET_MACOS_PROVISIONING_PROFILE` + +Path to the Mac App Store provisioning profile (`.provisionprofile`) +[embedded](../publish/macos.md#mac-app-store) by `flet build` into App Store +builds at `Contents/embedded.provisionprofile`. + ### `FLET_MACOS_SIGNING_IDENTITY` Code-signing identity [used](../publish/macos.md#code-signing) by `flet build` -to sign the macOS app bundle: a "Developer ID Application" certificate name, -its SHA-1 fingerprint, or `-` for ad-hoc signing. +to sign the macOS app bundle: a "Developer ID Application" (direct +distribution) or "Apple Distribution" (App Store) certificate name, its +SHA-1 fingerprint, or `-` for ad-hoc signing. When not configured (here, via the CLI, or in `pyproject.toml`), the built app keeps its default ad-hoc signature. From 33906ad41c4b1067a2796b268decd73b6b362cbb Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 15:17:55 +0200 Subject: [PATCH 09/28] Type-scoped identity resolution with per-lane auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build mode fully determines the certificate type Apple accepts — Developer ID Application for notarized direct distribution, Apple Distribution (or the legacy 3rd Party Mac Developer Application) for the App Store, an installer certificate for the store .pkg — so resolve_identity() now takes the lane's acceptable type prefixes and scopes matching to them: - A partial identity like a bare team ID stays unambiguous per lane even when the keychain holds certificates of several types (previously it matched all of them and errored). - An explicit identity of the wrong type gets a precise error naming the matched certificate and the required type, replacing the store lane's after-the-fact warning and the installer lane's post-resolution name check. - Lanes that require an identity anyway (--macos-notarize, --macos-app-store, and the store installer certificate) auto-discover it when exactly one certificate of the required type exists — the previous hard 'requires an identity' errors become successes in the common single-team case, with the chosen identity logged. Several candidates or none still fail with the candidate list (including the expired-certificate explanation). Discovery never triggers on a plain build: no identity there still means ad-hoc, unchanged. The plain signing lane stays unscoped — Apple Development and corporate certificates are legitimate for local/internal distribution. Tests: 108 -> 115; docs and changelog updated. --- CHANGELOG.md | 2 +- .../flet-cli/src/flet_cli/commands/build.py | 68 +++++----- .../flet-cli/src/flet_cli/utils/macos_sign.py | 122 ++++++++++++++---- .../flet-cli/tests/test_macos_sign.py | 98 ++++++++++++++ website/docs/publish/macos.md | 15 ++- .../docs/reference/environment-variables.md | 6 +- 6 files changed, 249 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3d9dc153..53d39911a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New features -* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities, and without a configured identity nothing changes (the app keeps its ad-hoc signature). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities (an expired or revoked certificate is called out by name and status instead of appearing missing), and without a configured identity a plain build keeps its ad-hoc signature — while `--macos-notarize` and `--macos-app-store` builds, which require an identity anyway, scope resolution to the certificate type Apple accepts for the lane and **auto-discover** it when the keychain holds exactly one (so a bare team ID also stays unambiguous per lane). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. * **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-app-store` (or `[tool.flet.macos.signing].app_store`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded, cross-checked against the bundle id before signing, and scrubbed of the `com.apple.quarantine` attribute browser downloads carry (App Store Connect processing rejects quarantined package contents with error 91109 — a check `altool --validate-app` does not perform), and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before any signing work: missing installer certificate or profile, a profile/bundle-id mismatch (the slow-to-surface ITMS-90889), a missing `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`), and combining `app_store` with `notarize` (store builds are not notarized) are all reported with the exact setting to fix. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 8ef29b2657..6ce247141a 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -13,7 +13,10 @@ from flet_cli.commands.flutter_base import verbose1_style from flet_cli.utils.android import flutter_target_platforms from flet_cli.utils.macos_sign import ( + APP_STORE_CERTIFICATE_TYPES, APP_STORE_HELPER_ENTITLEMENTS, + DEVELOPER_ID_CERTIFICATE_TYPES, + INSTALLER_CERTIFICATE_TYPES, MacOSSigningError, NotaryCredentials, SigningIdentity, @@ -278,15 +281,10 @@ def sign_macos_app(self) -> None: "`[tool.flet.macos.signing].notarize`.", ) - if not identity: - if notarize or app_store: - what = "Notarization" if notarize else "App Store signing" - self.cleanup( - 1, - f"{what} requires a code-signing identity. Pass " - "--macos-signing-identity or set " - "`[tool.flet.macos.signing].identity` in pyproject.toml.", - ) + # Notarize and App Store builds require an identity anyway, so an + # unset one auto-discovers (resolve_identity with types, below). + # A plain build without an identity keeps its ad-hoc signature. + if not identity and not (notarize or app_store): return apps = sorted(self.out_dir.glob("*.app")) @@ -316,7 +314,21 @@ def log(message: str): self.update_status(f"[bold blue]Signing [cyan]{app_path.name}[/cyan]...") try: - resolved = resolve_identity(identity) + # Each lane scopes resolution to the certificate type Apple's + # services accept for it — which also lets an unset identity + # auto-discover the only candidate. The plain lane stays + # unscoped: Apple Development or corporate certificates are + # legitimate there. + if app_store: + resolved = resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) + elif notarize: + resolved = resolve_identity( + identity, types=DEVELOPER_ID_CERTIFICATE_TYPES + ) + else: + resolved = resolve_identity(identity) + if not identity: + console.log(f"Signing identity: {resolved.name}") if notarize and resolved.is_adhoc: self.cleanup( 1, @@ -393,13 +405,6 @@ def _sign_macos_app_store( 'App Store builds cannot be signed ad-hoc ("-"); use your ' "Apple Distribution certificate.", ) - if "Developer ID" in identity.name: - console.log( - f"[yellow]Warning: signing an App Store build with " - f'"{identity.name}" — App Store Connect only accepts Apple ' - "Distribution (or 3rd Party Mac Developer Application) " - "certificates.[/yellow]" - ) team_id = identity_team_id(identity) if not team_id: self.cleanup( @@ -413,27 +418,22 @@ def _sign_macos_app_store( or self.get_pyproject("tool.flet.macos.signing.installer_identity") or os.getenv("FLET_MACOS_INSTALLER_IDENTITY") ) - if not installer_identity: - self.cleanup( - 1, - "App Store builds need an installer certificate to sign the " - ".pkg. Pass --macos-installer-identity or set " - "`[tool.flet.macos.signing].installer_identity` " - '(e.g. "3rd Party Mac Developer Installer: ... (TEAMID)").', - ) # Installer certs sign packages, not code — resolved under the - # `basic` policy, and before the signing pass so a typo fails fast. - # That policy also lists application certs, so a wrong-but-unique - # match is possible; catching it here avoids a cryptic productbuild - # failure after the multi-minute signing pass. - installer = resolve_identity(installer_identity, policy="basic") - if not installer.is_adhoc and "Installer" not in installer.name: + # `basic` policy, scoped to installer types (the policy also lists + # application certs), before the signing pass so a typo fails fast. + # An unset identity auto-discovers the only installer certificate. + installer = resolve_identity( + installer_identity, policy="basic", types=INSTALLER_CERTIFICATE_TYPES + ) + if installer.is_adhoc: self.cleanup( 1, - f'"{installer.name}" is not an installer certificate. Store ' - 'packages must be signed with a "3rd Party Mac Developer ' - 'Installer" / "Mac Installer Distribution" certificate.', + "Store packages cannot be signed ad-hoc; use your " + '"3rd Party Mac Developer Installer" / "Mac Installer ' + 'Distribution" certificate.', ) + if not installer_identity: + console.log(f"Installer identity: {installer.name}") profile = ( self.options.macos_provisioning_profile diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 93676f9139..9f1a5ebe36 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -155,7 +155,26 @@ def _run(args: list[str], timeout: Optional[int] = None) -> subprocess.Completed ) -def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdentity: +# Certificate-type prefixes per distribution lane. The build mode fully +# determines the acceptable type (Apple's services reject everything else), +# so lanes scope resolution to these and can auto-discover when exactly one +# candidate exists. Aliases reflect Apple's renames over the years. +DEVELOPER_ID_CERTIFICATE_TYPES = ["Developer ID Application"] +APP_STORE_CERTIFICATE_TYPES = [ + "Apple Distribution", + "3rd Party Mac Developer Application", +] +INSTALLER_CERTIFICATE_TYPES = [ + "3rd Party Mac Developer Installer", + "Mac Installer Distribution", +] + + +def resolve_identity( + identity: Optional[str], + policy: str = "codesigning", + types: Optional[list[str]] = None, +) -> SigningIdentity: """Resolve a user-provided identity against the keychain. Fails fast — with the list of available identities — to avoid a typo'd identity @@ -165,25 +184,35 @@ def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdent identity: `-` for ad-hoc signing, a 40-hex SHA-1 fingerprint, the full certificate name (e.g. `Developer ID Application: Jane Doe (TEAM123456)`), or any substring of the name that matches - exactly one certificate (e.g. just the team ID). + exactly one certificate (e.g. just the team ID). None or empty + **auto-discovers**: allowed only with `types`, and succeeds when + exactly one certificate of an acceptable type exists. policy: `security find-identity` policy to match against. `codesigning` for certificates that sign code; `basic` for - installer certificates (`3rd Party Mac Developer Installer` / - `Mac Installer Distribution`), which sign packages, not code, - and are invisible under the codesigning policy. + installer certificates, which sign packages, not code, and are + invisible under the codesigning policy. + types: Acceptable certificate-name prefixes (one of the + `*_CERTIFICATE_TYPES` constants). Scopes matching to the types + the distribution lane can actually use, so e.g. a bare team ID + resolves even when the keychain holds certificates of several + types. None matches any type (the plain signing lane, where + Apple Development or corporate certificates are legitimate). Returns: The matched identity; its SHA-1 fingerprint is what is ultimately passed to `codesign` / `productbuild`. Raises: - MacOSSigningError: If `security find-identity` fails, no identity - matches, or the value matches more than one certificate. + MacOSSigningError: If `security find-identity` fails, nothing + matches, the match is ambiguous, or an explicit identity is of + the wrong certificate type for the lane. """ - identity = identity.strip() + identity = (identity or "").strip() if identity == ADHOC_IDENTITY: return ADHOC + assert identity or types, "auto-discovery requires certificate types" + types_desc = " / ".join(f'"{t}"' for t in types) if types else "" result = _run(["security", "find-identity", "-v", "-p", policy]) if result.returncode != 0: @@ -200,9 +229,14 @@ def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdent seen.setdefault( m.group(1).lower(), SigningIdentity(sha1=m.group(1), name=m.group(2)) ) - available = list(seen.values()) + unscoped = list(seen.values()) + available = ( + [i for i in unscoped if i.name.startswith(tuple(types))] if types else unscoped + ) - if re.fullmatch(r"[0-9A-Fa-f]{40}", identity): + if not identity: + matches = available + elif re.fullmatch(r"[0-9A-Fa-f]{40}", identity): matches = [i for i in available if i.sha1.lower() == identity.lower()] else: matches = [i for i in available if i.name == identity] @@ -212,40 +246,80 @@ def resolve_identity(identity: str, policy: str = "codesigning") -> SigningIdent if len(matches) == 1: return matches[0] + # An explicit identity that exists but is of the wrong type deserves a + # precise error, not "no such identity". + if identity and types and not matches: + wrong_type = next( + ( + i + for i in unscoped + if identity.lower() == i.sha1.lower() or identity in i.name + ), + None, + ) + if wrong_type is not None: + raise MacOSSigningError( + f'"{identity}" matches "{wrong_type.name}", which is not a ' + f"{types_desc} certificate — this operation requires one." + ) + listing = ( "\n".join(f' {i.sha1} "{i.name}"' for i in available) if available else " (no valid identities found)" ) - problem = ( - "matches multiple identities" if matches else "does not match any identity" - ) + if types: + one, many = f"{types_desc} certificate", f"{types_desc} certificates" + else: + one, many = "identity", "identities" + if not identity: + problem = ( + f"Found several {many} in the keychain" + if matches + else f"No {one} found in the keychain" + ) + subject = "" + else: + problem = ( + f'Signing identity "{identity}" matches multiple {many}' + if matches + else f'Signing identity "{identity}" does not match any {one}' + ) + subject = " in the keychain" # The ad-hoc hint only makes sense for code-signing lookups — installer # certificates have no ad-hoc equivalent. hint = "Pass the exact certificate name or its SHA-1 fingerprint" + ( - ', or "-" for ad-hoc signing.' if policy == "codesigning" else "." + ', or "-" for ad-hoc signing.' if policy == "codesigning" and not types else "." ) - # On a miss, check whether the identity exists but is invalid — without - # this, an expired certificate (invisible to `-v` but plainly visible in - # Keychain Access) reads as "no such identity", which is baffling. - invalid = "" if matches else _invalid_identity_reason(identity, policy) + # On a miss, check whether a matching-but-invalid certificate exists — + # without this, an expired certificate (invisible to `-v` but plainly + # visible in Keychain Access) reads as "no such identity". + invalid = "" if matches else _invalid_identity_reason(identity, policy, types) raise MacOSSigningError( - f'Signing identity "{identity}" {problem} in the keychain. ' + f"{problem}{subject}. " + invalid + f'Valid identities for the "{policy}" policy:\n{listing}\n{hint}' ) -def _invalid_identity_reason(identity: str, policy: str) -> str: +def _invalid_identity_reason( + identity: str, policy: str, types: Optional[list[str]] = None +) -> str: """Explain a resolution miss caused by an existing-but-invalid identity. Re-queries the keychain without `-v` (which hides expired, revoked, and untrusted certificates) and reports the status code `security` prints, e.g. `CSSMERR_TP_CERT_EXPIRED`. + Args: + identity: The identity input to match, or "" (auto-discovery) to + match any certificate of an acceptable type. + policy: `security find-identity` policy. + types: Acceptable certificate-name prefixes, or None for any. + Returns: - A sentence for the resolution error, or "" if the identity does not - exist at all (or the re-query fails). + A sentence for the resolution error, or "" if no matching + certificate exists at all (or the re-query fails). """ result = _run(["security", "find-identity", "-p", policy]) @@ -255,7 +329,9 @@ def _invalid_identity_reason(identity: str, policy: str) -> str: r"([0-9A-Fa-f]{40})\s+\"([^\"]+)\"(?:\s+\((\w+)\))?", result.stdout ): sha1, name, status = m.group(1), m.group(2), m.group(3) - if identity.lower() == sha1.lower() or identity in name: + if types and not name.startswith(tuple(types)): + continue + if not identity or identity.lower() == sha1.lower() or identity in name: reason = f" ({status})" if status else "" return ( f'A matching certificate "{name}" exists but is not valid ' diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index a502d164a8..3233da15fc 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -21,6 +21,9 @@ from flet_cli.utils import macos_sign from flet_cli.utils.macos_sign import ( ADHOC, + APP_STORE_CERTIFICATE_TYPES, + DEVELOPER_ID_CERTIFICATE_TYPES, + INSTALLER_CERTIFICATE_TYPES, MH_EXECUTE, MacOSSigningError, NotaryCredentials, @@ -1287,3 +1290,98 @@ def fake_run(args, timeout=None): match=r"not valid for signing \(CSSMERR_TP_CERT_EXPIRED\)", ): resolve_identity(full) + + +# Both certificate types under one team — the state every keychain reaches +# after enrolling for both distribution lanes. +TWO_LANE_LISTING = ( + f' 1) {"a" * 40} "Developer ID Application: Jane Doe (TEAM123456)"\n' + f' 2) {"c" * 40} "Apple Distribution: Jane Doe (TEAM123456)"\n' + " 2 valid identities found\n" +) + + +def test_resolve_identity_type_scoping_disambiguates(monkeypatch): + """A team ID resolves once matching is scoped to the lane's cert type. + + Unscoped, it matches every certificate the team owns and errors; each + lane's type filter makes it unique again. + """ + fake_security(monkeypatch, stdout=TWO_LANE_LISTING) + + with pytest.raises(MacOSSigningError, match="matches multiple identities"): + resolve_identity("TEAM123456") + dev = resolve_identity("TEAM123456", types=DEVELOPER_ID_CERTIFICATE_TYPES) + assert dev.name.startswith("Developer ID Application") + store = resolve_identity("TEAM123456", types=APP_STORE_CERTIFICATE_TYPES) + assert store.name.startswith("Apple Distribution") + + +def test_resolve_identity_rejects_wrong_type(monkeypatch): + """An explicit identity of the wrong type gets a precise error.""" + fake_security(monkeypatch, stdout=TWO_LANE_LISTING) + + with pytest.raises(MacOSSigningError, match="which is not a .* certificate"): + resolve_identity( + "Developer ID Application: Jane Doe (TEAM123456)", + types=APP_STORE_CERTIFICATE_TYPES, + ) + + +def test_resolve_identity_auto_discovery(monkeypatch): + """No identity + exactly one certificate of the required type: use it.""" + fake_security(monkeypatch, stdout=TWO_LANE_LISTING) + + assert resolve_identity(None, types=DEVELOPER_ID_CERTIFICATE_TYPES).sha1 == "a" * 40 + assert resolve_identity("", types=APP_STORE_CERTIFICATE_TYPES).sha1 == "c" * 40 + + +def test_resolve_identity_auto_discovery_none_found(monkeypatch): + """Discovery with no certificate of the type: actionable error.""" + fake_security(monkeypatch, stdout=TWO_LANE_LISTING) + + with pytest.raises(MacOSSigningError, match=r"No .*Installer.* certificate found"): + resolve_identity(None, types=INSTALLER_CERTIFICATE_TYPES) + + +def test_resolve_identity_auto_discovery_ambiguous(monkeypatch): + """Discovery with several certs of the type: refuse to guess.""" + listing = ( + f' 1) {"a" * 40} "Developer ID Application: Jane Doe (TEAM123456)"\n' + f' 2) {"b" * 40} "Developer ID Application: Jane Doe (OTHERTEAM1)"\n' + " 2 valid identities found\n" + ) + fake_security(monkeypatch, stdout=listing) + + with pytest.raises(MacOSSigningError, match="Found several"): + resolve_identity(None, types=DEVELOPER_ID_CERTIFICATE_TYPES) + + +def test_resolve_identity_adhoc_bypasses_types(): + """Explicit "-" never touches the keychain, types or not.""" + assert resolve_identity("-", types=APP_STORE_CERTIFICATE_TYPES).is_adhoc + + +def test_resolve_identity_auto_discovery_expired(monkeypatch): + """Discovery finding only an expired cert of the type says so.""" + + def fake_run(args, timeout=None): + assert args[0] == "security" + if "-v" in args: + return subprocess.CompletedProcess( + args, 0, " 0 valid identities found\n", "" + ) + return subprocess.CompletedProcess( + args, + 0, + f' 1) {"a" * 40} "Developer ID Application: Jane Doe (TEAM123456)" ' + "(CSSMERR_TP_CERT_EXPIRED)\n", + "", + ) + + monkeypatch.setattr(macos_sign, "_run", fake_run) + with pytest.raises( + MacOSSigningError, + match=r"not valid for signing \(CSSMERR_TP_CERT_EXPIRED\)", + ): + resolve_identity(None, types=DEVELOPER_ID_CERTIFICATE_TYPES) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 0970af7c6c..5b9c21a96f 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -514,6 +514,13 @@ Notarization must still be turned on with `--macos-notarize` (or +With `--macos-notarize`, the signing identity may be omitted entirely: the +build requires a "Developer ID Application" certificate anyway, so it +auto-discovers yours when the keychain holds exactly one (and errors with +the candidate list when it holds several). For the same reason, a partial +identity such as your team ID only has to be unique *among Developer ID +Application certificates*, not among all your certificates. + If notarization is rejected, the build fails and prints Apple's notarization log, which lists the exact offending files. @@ -604,8 +611,12 @@ options and the [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) and [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) -environment variables. Notarization does **not** apply to store submissions -and is rejected in combination with `app_store`. +environment variables. Both identities may be omitted: store builds only +accept one certificate type each ("Apple Distribution" for the app, an +installer certificate for the `.pkg`), so the build auto-discovers yours +when the keychain holds exactly one of the required type. Notarization does +**not** apply to store submissions and is rejected in combination with +`app_store`. One-time setup in the [developer portal](https://developer.apple.com/account) and [App Store Connect](https://appstoreconnect.apple.com): diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index c931ba68f9..e0fd6ba8bf 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -178,8 +178,10 @@ to sign the macOS app bundle: a "Developer ID Application" (direct distribution) or "Apple Distribution" (App Store) certificate name, its SHA-1 fingerprint, or `-` for ad-hoc signing. -When not configured (here, via the CLI, or in `pyproject.toml`), -the built app keeps its default ad-hoc signature. +When not configured (here, via the CLI, or in `pyproject.toml`), a plain +build keeps its default ad-hoc signature, while `--macos-notarize` and +`--macos-app-store` builds auto-discover the certificate of the type they +require when the keychain holds exactly one. ### `FLET_MAX_UPLOAD_SIZE` From 80455d62746f80afedef60161c313f74f94f5b0e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 15:44:49 +0200 Subject: [PATCH 10/28] Document resolve_identity's algorithm in its docstring Step-by-step map of the resolution algorithm (ad-hoc short-circuit, validity filtering via find-identity -v, fingerprint dedup, type scoping, selection precedence, failure diagnosis order), the rationale for never auto-picking between two valid same-name certificates, and a corrected Returns section (it returns the identity, not its fingerprint). Body comments added for the load-bearing -v semantics and the exact-name- before-substring precedence. --- .../flet-cli/src/flet_cli/utils/macos_sign.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index 9f1a5ebe36..b8a1a38ce0 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -180,6 +180,39 @@ def resolve_identity( Fails fast — with the list of available identities — to avoid a typo'd identity surface later as an opaque `codesign` error for every file in the bundle. + The algorithm: + + 1. `-` returns the ad-hoc pseudo-identity without touching the + keychain (and without a type check — ad-hoc has no certificate). + 2. List **valid** identities: `security find-identity -v -p `. + The `-v` filter is load-bearing — expired, revoked, and untrusted + certificates, and certificates whose private key is missing, never + enter the candidate pool, so validity needs no handling later. The + common renewal case (valid + expired certificate sharing one name) + therefore resolves to the valid one automatically. + 3. Deduplicate candidates by fingerprint — a certificate installed in + several keychains (e.g. login and System, typical on CI) is listed + once per keychain and must not read as ambiguous. + 4. Scope candidates to the `types` prefixes, when given. + 5. Select, first rule that applies: + a. empty `identity` — every scoped candidate (auto-discovery); + b. 40 hex chars — case-insensitive fingerprint equality; + c. otherwise — exact name equality, falling back to + substring-of-name only when the exact pass found nothing. + 6. Exactly one candidate wins and is returned. Anything else fails + with the most specific diagnosis available: an explicit identity + that matches only outside the `types` scope → wrong-certificate-type + error naming the match; zero matches → the unfiltered listing is + consulted (`_invalid_identity_reason()`) so an expired certificate + is called out instead of appearing missing; and every failure + carries the valid-candidate listing plus how to disambiguate. + + Two valid same-name certificates are deliberately never auto-picked + (step 6 refuses): the "right" one depends on state this function + cannot see, e.g. which certificate the provisioning profile + references — a silent pick would fail much later, at notarization or + store-upload time. + Args: identity: `-` for ad-hoc signing, a 40-hex SHA-1 fingerprint, the full certificate name (e.g. `Developer ID Application: Jane Doe @@ -199,8 +232,9 @@ def resolve_identity( Apple Development or corporate certificates are legitimate). Returns: - The matched identity; its SHA-1 fingerprint is what is ultimately - passed to `codesign` / `productbuild`. + The matched identity. Callers sign with its SHA-1 fingerprint, not + its name, so a resolution can never re-ambiguate inside + `codesign` / `productbuild`. Raises: MacOSSigningError: If `security find-identity` fails, nothing @@ -214,6 +248,8 @@ def resolve_identity( assert identity or types, "auto-discovery requires certificate types" types_desc = " / ".join(f'"{t}"' for t in types) if types else "" + # -v restricts the listing to valid identities (not expired/revoked/ + # untrusted, private key present) — validity is handled entirely here. result = _run(["security", "find-identity", "-v", "-p", policy]) if result.returncode != 0: raise MacOSSigningError( @@ -234,6 +270,9 @@ def resolve_identity( [i for i in unscoped if i.name.startswith(tuple(types))] if types else unscoped ) + # Selection precedence: discovery / fingerprint / exact name / substring. + # Exact-name runs before substring so a full CN can never be hijacked by + # being a substring of another certificate's name. if not identity: matches = available elif re.fullmatch(r"[0-9A-Fa-f]{40}", identity): From 19d4e0100845940b6cf88f19222d6d7763f1422e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 16:08:16 +0200 Subject: [PATCH 11/28] Mention auto-discovery in the identity option help texts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --macos-installer-identity's help still said 'required for App Store builds', stale since auto-discovery; both identity options and the FLET_MACOS_INSTALLER_IDENTITY reference entry now state the when-unset behavior. (--macos-provisioning-profile stays 'required' — profiles are not discoverable.) --- .../packages/flet-cli/src/flet_cli/commands/build_base.py | 7 +++++-- website/docs/reference/environment-variables.md | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index b91d49b49c..433e6266eb 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -660,7 +660,9 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: dest="macos_signing_identity", help='"Developer ID Application" (direct distribution) or ' '"Apple Distribution" (App Store) certificate name, its SHA-1 ' - 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle ' + 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle; ' + "when unset, --macos-notarize and --macos-app-store builds " + "auto-discover the only certificate of the required type " "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", ) parser.add_argument( @@ -704,7 +706,8 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: dest="macos_installer_identity", help='"3rd Party Mac Developer Installer" / "Mac Installer ' 'Distribution" certificate name or SHA-1 fingerprint used to sign ' - "the App Store installer package; required for App Store builds " + "the App Store installer package; when unset, the only installer " + "certificate in the keychain is auto-discovered " "(macos only) [env: FLET_MACOS_INSTALLER_IDENTITY=]", ) parser.add_argument( diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index e0fd6ba8bf..5f6eee9090 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -152,7 +152,8 @@ Defaults to `False`. Installer certificate ("3rd Party Mac Developer Installer" / "Mac Installer Distribution" name or SHA-1 fingerprint) [used](../publish/macos.md#mac-app-store) by `flet build` to sign the -installer `.pkg` of a Mac App Store build. +installer `.pkg` of a Mac App Store build. When not configured, the only +installer certificate in the keychain is auto-discovered. ### `FLET_MACOS_NOTARY_PROFILE` From 011a5854ef8a7c8ace95f42ca6a7bbab67d1112e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 17:41:44 +0200 Subject: [PATCH 12/28] Detail the macOS signing docs to the iOS page's standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac App Store section now matches the page's own per-option convention and the iOS page's depth: portal walkthroughs a newcomer can follow (both distribution certificates with the find-identity -p basic gotcha, explicit App ID registration, Mac App Store Connect profile creation, the App Store Connect app record and where its numeric Apple ID lives), a three-channel configuration example, and dedicated sections with Resolution order for app_store, the provisioning profile, and the installer identity. A new Identity auto-discovery section states the semantics precisely — an identity is 'not configured' only when the CLI option, pyproject key, and environment variable are all unset; resolution always runs first and configured values are never silently replaced — and the notarize section, the signing-identity resolution default, and the CLI help texts (which wrongly implied CLI-only 'unset', and --macos-notarize still claimed to require --macos-signing-identity) now defer to it. The Credentials section documents both notarytool store-credentials flavors — App Store Connect API key (--key/--key-id/--issuer) and Apple ID with app-specific password — including where each credential is created and that the .p8 can be downloaded only once. The credentials resolution order gains its missing terminal Default entry. Proofread adversarially against the implementation: all eight Resolution order lists, the discovery semantics, both credential commands, the altool invocations, and every cross-page anchor verified. --- .../src/flet_cli/commands/build_base.py | 13 +- website/docs/publish/macos.md | 281 ++++++++++++++---- 2 files changed, 230 insertions(+), 64 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 433e6266eb..bf02e20c0d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -661,8 +661,9 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: help='"Developer ID Application" (direct distribution) or ' '"Apple Distribution" (App Store) certificate name, its SHA-1 ' 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle; ' - "when unset, --macos-notarize and --macos-app-store builds " - "auto-discover the only certificate of the required type " + "when not configured (CLI, pyproject.toml, or env), --macos-notarize " + "and --macos-app-store builds auto-discover the only certificate " + "of the required type " "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", ) parser.add_argument( @@ -671,8 +672,8 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: action=argparse.BooleanOptionalAction, default=None, help="Submit the signed app to the Apple notary service and staple " - "the ticket; requires --macos-signing-identity and notary " - "credentials (macos only)", + "the ticket; requires notary credentials, while the signing " + "identity is auto-discovered when not configured (macos only)", ) parser.add_argument( "--macos-notary-profile", @@ -706,8 +707,8 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: dest="macos_installer_identity", help='"3rd Party Mac Developer Installer" / "Mac Installer ' 'Distribution" certificate name or SHA-1 fingerprint used to sign ' - "the App Store installer package; when unset, the only installer " - "certificate in the keychain is auto-discovered " + "the App Store installer package; when not configured, the only " + "installer certificate in the keychain is auto-discovered " "(macos only) [env: FLET_MACOS_INSTALLER_IDENTITY=]", ) parser.add_argument( diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 5b9c21a96f..3c83f092d3 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -423,7 +423,10 @@ The signing identity is determined in the following order of precedence: 2. `[tool.flet.macos.signing].identity` 3. [`FLET_MACOS_SIGNING_IDENTITY`](../reference/environment-variables.md#flet_macos_signing_identity) environment variable -4. Default: none — the app keeps its ad-hoc signature and no signing step runs. +4. Default: none — a plain build keeps its ad-hoc signature and no signing + step runs, while [notarize](#notarization) and + [App Store](#mac-app-store) builds + [auto-discover](#identity-auto-discovery) the certificate. When a real identity is configured, `flet build macos` will, after the build: @@ -439,7 +442,37 @@ When a real identity is configured, `flet build macos` will, after the build: no binary was left unsigned. The build fails with an actionable error if the identity is not found in the -keychain, if any file fails to sign, or if verification fails. +keychain, if any file fails to sign, or if verification fails. An expired or +revoked certificate is called out by name and status instead of appearing +missing. + +### Identity auto-discovery + +An identity counts as *not configured* only when the CLI option, the +`pyproject.toml` key, **and** the environment variable are all unset — the +[resolution order](#resolution-order-2) above runs first, and any configured +value is matched as given, never silently replaced. + +With no identity configured anywhere, build modes that cannot proceed +without one discover it from the keychain: + +- [`--macos-notarize`](#notarization) uses your **Developer ID Application** + certificate; +- [`--macos-app-store`](#mac-app-store) uses your **Apple Distribution** + certificate (or its legacy equivalent, `3rd Party Mac Developer + Application`) for the app and your **installer certificate** for the + `.pkg`. + +Discovery succeeds when the keychain holds exactly one valid certificate of +the required type — the chosen identity is printed in the build output. +With several candidates (for example, certificates from two teams), the +build fails with the candidate list; configure the certificate name or +SHA-1 fingerprint explicitly. Plain builds (neither notarize nor App Store +mode) never auto-discover. + +Certificate types also scope *explicit* identities in these modes: a +partial identity such as your team ID only has to be unique among +certificates of the required type, not among all your certificates. ## Notarization @@ -450,13 +483,35 @@ service (a malware scan, typically a few minutes), after which the resulting ### Credentials -Apple's notary service accepts two kinds of credentials: your **Apple ID** -with an [app-specific password](https://support.apple.com/102654), or an -**App Store Connect API key** (a `.p8` key file with its key ID and issuer -ID). Flet can receive either kind through two channels: +Apple's notary service accepts two kinds of credentials — get whichever +suits you: -- **Keychain profile** (best for local development) — a one-time interactive - setup that saves either credential kind into the keychain: +- **App Store Connect API key** (recommended; also reusable for + [store uploads](#uploading)) — in App Store Connect, open + [Users and Access → Integrations](https://appstoreconnect.apple.com/access/integrations/api) + → **App Store Connect API** → **Team Keys** → **+**. Name the key, give it + the **Developer** role, then download the `AuthKey_.p8` file — + possible **only once** — and note the key's **Key ID** and the **Issuer + ID** shown at the top of the page. +- **Apple ID + app-specific password** — at + [account.apple.com](https://account.apple.com) → **Sign-In and Security** → + **App-Specific Passwords** → **+**, generate a + [password](https://support.apple.com/102654) dedicated to notarization + (your regular Apple ID password won't work with `notarytool`). + +Flet can receive either kind through two channels: + +- **Keychain profile** (best for local development) — a one-time setup that + saves the credential into the macOS keychain under a name of your choice. + With an API key: + + ```bash + xcrun notarytool store-credentials flet-notary \ + --key ~/keys/AuthKey_ABC123DEFG.p8 --key-id ABC123DEFG \ + --issuer 12345678-90ab-cdef-1234-567890abcdef + ``` + + or with an Apple ID (prompts for the app-specific password): ```bash xcrun notarytool store-credentials flet-notary \ @@ -482,6 +537,7 @@ Credentials are determined in the following order of precedence: environment variable 4. The `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER` environment variables (all three must be set) +5. Default: none — notarizing builds fail without credentials. A configured profile deliberately outranks the `APPLE_API_*` variables, which other tooling (Fastlane, CI images) may have exported for a different team. @@ -514,12 +570,8 @@ Notarization must still be turned on with `--macos-notarize` (or -With `--macos-notarize`, the signing identity may be omitted entirely: the -build requires a "Developer ID Application" certificate anyway, so it -auto-discovers yours when the keychain holds exactly one (and errors with -the candidate list when it holds several). For the same reason, a partial -identity such as your team ID only has to be unique *among Developer ID -Application certificates*, not among all your certificates. +With `--macos-notarize`, the signing identity may be omitted entirely — +see [Identity auto-discovery](#identity-auto-discovery). If notarization is rejected, the build fails and prints Apple's notarization log, which lists the exact offending files. @@ -584,13 +636,82 @@ Export your certificate and private key as a `.p12` file, then store it The signing support above targets **direct distribution** (your website, GitHub releases, etc.). For the Mac App Store — including TestFlight — -`flet build macos` has a dedicated mode that signs with your *Apple -Distribution* certificate (sandboxed, without the hardened runtime), embeds -your provisioning profile, applies the store-mandated -`application-identifier`/`team-identifier` entitlements (helper executables -get the sandbox `inherit` pair), and produces a signed installer `.pkg` -ready for upload: +`flet build macos` has a dedicated mode that produces a signed installer +`.pkg` ready for App Store Connect. In this mode the app is signed with +your *Apple Distribution* certificate — sandboxed, without the hardened +runtime — your provisioning profile is embedded, the store-mandated +`application-identifier`/`team-identifier` entitlements are applied (helper +executables and helper bundles get the sandbox `inherit` pair), and every +hardened-runtime +exception entitlement (`com.apple.security.cs.*`, including the defaults) +is stripped: they are meaningless without the hardened runtime and +scrutinized by App Review. Notarization does **not** apply to store +submissions and is rejected in combination with `app_store`. + +### Store prerequisites + +One-time setup, requiring an +[Apple Developer Program](https://developer.apple.com/programs/) membership. + +#### Creating the distribution certificates + +Store builds need two certificates. Create both under +[Certificates](https://developer.apple.com/account/resources/certificates/list) +→ **+** (if you don't have a certificate request file yet, see +[Generating a CSR](ios.md#generating-a-certificate-signing-request-csr) — +the process is identical for macOS): + +1. **Apple Distribution** — signs the app bundle. +2. **Mac Installer Distribution** — signs the installer `.pkg`. It appears + in your keychain as `3rd Party Mac Developer Installer`, and because it + signs packages rather than code, `security find-identity -v -p + codesigning` does not list it. Verify it with: + + ```bash + security find-identity -v -p basic + ``` + +Download each certificate and double-click it to install it — with its +private key — into your login keychain. + +#### Registering an App ID + +Under [Identifiers](https://developer.apple.com/account/resources/identifiers/list) +→ **+** → **App IDs** → type **App**, register an **explicit** App ID whose +bundle ID exactly matches your app's (by default `.` +from `pyproject.toml`). No extra capabilities are needed. +#### Creating the provisioning profile + +Under [Profiles](https://developer.apple.com/account/resources/profiles/list) +→ **+**, select **Mac App Store Connect** (under *Distribution*), then: + +1. Select the App ID registered above. +2. Select your **Apple Distribution** certificate. +3. Name the profile and click **Generate**. +4. Download the `.provisionprofile` file and keep it with your project — + it contains no secrets (it is a document signed *by Apple* authorizing + your App ID and team), so it is safe to commit. + +#### Creating the App Store Connect app record + +In [App Store Connect](https://appstoreconnect.apple.com) → **My Apps** → +**+** → **New App**: platform **macOS**, the bundle ID from above, any name +and SKU. Then note the app's numeric **Apple ID** under **App Information → +General Information** — command-line uploads are keyed to it. + +### Building for the App Store + + + +```bash +flet build macos --macos-app-store \ + --macos-provisioning-profile certs/MyApp_MacAppStore.provisionprofile \ + --info-plist LSApplicationCategoryType="public.app-category.productivity" \ + ITSAppUsesNonExemptEncryption=False +``` + + ```toml [tool.flet.macos.info] # required by App Store validation @@ -599,46 +720,86 @@ LSApplicationCategoryType = "public.app-category.productivity" ITSAppUsesNonExemptEncryption = false [tool.flet.macos.signing] -identity = "Apple Distribution" app_store = true provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" -installer_identity = "3rd Party Mac Developer Installer" ``` + + +```dotenv +FLET_MACOS_PROVISIONING_PROFILE="certs/MyApp_MacAppStore.provisionprofile" +``` +App Store mode must still be turned on with `--macos-app-store` (or +`[tool.flet.macos.signing].app_store = true`); this toggle has no +environment-variable equivalent. + + + +[`LSApplicationCategoryType`](https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype) +is required — App Store validation rejects the package without it. +`ITSAppUsesNonExemptEncryption = false` is optional but answers the +export-compliance question once and for all; without it, App Store Connect +asks manually for every uploaded build. Both are ordinary +[Info.plist](#infoplist) keys. + +Neither signing identity appears in the examples above: both are +[auto-discovered](#identity-auto-discovery) when not configured. To pin +them explicitly, use `[tool.flet.macos.signing].identity` / +[`--macos-signing-identity`](#signing-the-app) for the app certificate and +`installer_identity` / `--macos-installer-identity` for the `.pkg` +certificate. + +#### Resolution order + +Whether to build for the App Store is determined in the following order of +precedence: + +1. [`--macos-app-store`](../cli/flet-build.md#--macos-app-store) / + `--no-macos-app-store` +2. `[tool.flet.macos.signing].app_store` +3. Default: `false` + +### Provisioning profile + +The profile created [above](#creating-the-provisioning-profile). A relative +path resolves against the project directory (where `pyproject.toml` lives). +The build embeds it at `Contents/embedded.provisionprofile` — sealed by the +app's signature — and fails fast when the profile's App ID does not cover +the app's bundle ID, a mismatch that would otherwise surface only after +upload as `ITMS-90889`. + +#### Resolution order + +The provisioning profile is determined in the following order of precedence: + +1. [`--macos-provisioning-profile`](../cli/flet-build.md#--macos-provisioning-profile) +2. `[tool.flet.macos.signing].provisioning_profile` +3. [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) + environment variable +4. Default: none — App Store builds fail without one. + +### Installer identity + +The certificate that signs the `.pkg` — the exact certificate name (as +listed by `security find-identity -v -p basic`), its SHA-1 fingerprint, or +a unique substring, matched only among installer certificates. + +#### Resolution order + +The installer identity is determined in the following order of precedence: + +1. [`--macos-installer-identity`](../cli/flet-build.md#--macos-installer-identity) +2. `[tool.flet.macos.signing].installer_identity` +3. [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) + environment variable +4. Default: none — the certificate is + [auto-discovered](#identity-auto-discovery). -The same settings are available as the `--macos-app-store`, -`--macos-provisioning-profile` and `--macos-installer-identity` command-line -options and the -[`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) -and -[`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) -environment variables. Both identities may be omitted: store builds only -accept one certificate type each ("Apple Distribution" for the app, an -installer certificate for the `.pkg`), so the build auto-discovers yours -when the keychain holds exactly one of the required type. Notarization does -**not** apply to store submissions and is rejected in combination with -`app_store`. - -One-time setup in the [developer portal](https://developer.apple.com/account) -and [App Store Connect](https://appstoreconnect.apple.com): - -1. Certificates: *Apple Distribution* plus *Mac Installer Distribution* - (the latter appears in the keychain as `3rd Party Mac Developer - Installer` — it signs packages, not code, so `security find-identity -v - -p codesigning` does not list it; use `-p basic`). -2. An explicit App ID matching your app's bundle id, and a **Mac App - Store** provisioning profile for it (referencing the Apple Distribution - certificate) — the file you point `provisioning_profile` at. -3. An App Store Connect app record for the bundle id; note its numeric - Apple ID for the upload. - -The App Sandbox is enabled automatically for store builds, and every -hardened-runtime exception entitlement (`com.apple.security.cs.*`, -including the defaults) is stripped — they are meaningless without the -hardened runtime and scrutinized by App Review. +### Uploading Upload the `.pkg` with [Transporter](https://apps.apple.com/app/transporter/id1450874784) -or from the command line (the App Store Connect API key `.p8` goes in -`~/.appstoreconnect/private_keys/`): +or from the command line, authenticating with the same +[App Store Connect API key](#credentials) used for notarization — `altool` +reads the `.p8` file from `~/.appstoreconnect/private_keys/`: ```bash xcrun altool --validate-app -f build/macos/MyApp.pkg -t macos \ @@ -649,7 +810,11 @@ xcrun altool --upload-package build/macos/MyApp.pkg -t macos \ --bundle-version --bundle-short-version-string ``` -Every upload needs a unique build number (`flet build macos ---build-number N`). After processing (minutes; failures arrive by email as -`ITMS-xxxx` codes), the build appears in the TestFlight tab of your app -record — internal testers can install it without beta review. +`` is the app record's Apple ID +[noted earlier](#creating-the-app-store-connect-app-record), and every +upload needs a unique build number (`flet build macos --build-number N`). +After processing (minutes; failures arrive by email as `ITMS-xxxx` codes), +the build appears in the **TestFlight** tab of your app record — internal +testers can install it without beta review. Note that `--validate-app` +does not catch everything processing checks, so a clean upload is only +confirmed once processing completes. From 0fa489c57d3178bf733448c8eacdcc7244ec8f7c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 20:25:07 +0200 Subject: [PATCH 13/28] Phrase auto-discovery by build mode, not by CLI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'With --macos-notarize, the signing identity may be omitted' implied the behavior was tied to the CLI flag, but notarization (and App Store mode) can equally be enabled via their pyproject keys — the docs, the env-var reference, and the --macos-signing-identity help now say 'notarizing / App Store builds' instead of naming flags. --- .../packages/flet-cli/src/flet_cli/commands/build_base.py | 6 +++--- website/docs/publish/macos.md | 6 +++--- website/docs/reference/environment-variables.md | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index bf02e20c0d..4e3524616b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -661,9 +661,9 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: help='"Developer ID Application" (direct distribution) or ' '"Apple Distribution" (App Store) certificate name, its SHA-1 ' 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle; ' - "when not configured (CLI, pyproject.toml, or env), --macos-notarize " - "and --macos-app-store builds auto-discover the only certificate " - "of the required type " + "when not configured (CLI, pyproject.toml, or env), notarizing and " + "App Store builds auto-discover the only certificate of the " + "required type " "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", ) parser.add_argument( diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 3c83f092d3..f41f22a427 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -456,9 +456,9 @@ value is matched as given, never silently replaced. With no identity configured anywhere, build modes that cannot proceed without one discover it from the keychain: -- [`--macos-notarize`](#notarization) uses your **Developer ID Application** +- [notarizing builds](#notarization) use your **Developer ID Application** certificate; -- [`--macos-app-store`](#mac-app-store) uses your **Apple Distribution** +- [App Store builds](#mac-app-store) use your **Apple Distribution** certificate (or its legacy equivalent, `3rd Party Mac Developer Application`) for the app and your **installer certificate** for the `.pkg`. @@ -570,7 +570,7 @@ Notarization must still be turned on with `--macos-notarize` (or -With `--macos-notarize`, the signing identity may be omitted entirely — +When notarizing, the signing identity may be omitted entirely — see [Identity auto-discovery](#identity-auto-discovery). If notarization is rejected, the build fails and prints Apple's notarization diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 5f6eee9090..d7a7a928f7 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -180,9 +180,9 @@ distribution) or "Apple Distribution" (App Store) certificate name, its SHA-1 fingerprint, or `-` for ad-hoc signing. When not configured (here, via the CLI, or in `pyproject.toml`), a plain -build keeps its default ad-hoc signature, while `--macos-notarize` and -`--macos-app-store` builds auto-discover the certificate of the type they -require when the keychain holds exactly one. +build keeps its default ad-hoc signature, while notarizing and App Store +builds auto-discover the certificate of the type they require when the +keychain holds exactly one. ### `FLET_MAX_UPLOAD_SIZE` From ffdbcfec30468163fcc436551d43e76b45015097 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 20:36:34 +0200 Subject: [PATCH 14/28] Link forward references in the App Store build section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build example and the identity-pinning paragraph referenced the provisioning profile and installer identity before their sections; they now link ahead. The pinning paragraph also links each identity's section instead of enumerating two of its three configuration channels (the env vars were missing) — the sections carry the full resolution order. --- website/docs/publish/macos.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index f41f22a427..183ee6bcbb 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -739,14 +739,14 @@ is required — App Store validation rejects the package without it. `ITSAppUsesNonExemptEncryption = false` is optional but answers the export-compliance question once and for all; without it, App Store Connect asks manually for every uploaded build. Both are ordinary -[Info.plist](#infoplist) keys. +[Info.plist](#infoplist) keys. The +[provisioning profile](#provisioning-profile) setting is detailed below. Neither signing identity appears in the examples above: both are [auto-discovered](#identity-auto-discovery) when not configured. To pin -them explicitly, use `[tool.flet.macos.signing].identity` / -[`--macos-signing-identity`](#signing-the-app) for the app certificate and -`installer_identity` / `--macos-installer-identity` for the `.pkg` -certificate. +them explicitly, configure the [signing identity](#signing-the-app) for +the app certificate and the [installer identity](#installer-identity) for +the `.pkg` certificate. #### Resolution order From 18e94c3539274454dd01536c55c8530bb195d920 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 20:40:21 +0200 Subject: [PATCH 15/28] Order the App Store section as a pipeline: options before build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Building for the App Store' now follows the provisioning-profile and installer-identity sections, so every setting the build example uses is defined before it appears — prerequisites → options → build → upload — and the 'detailed below' forward pointer becomes unnecessary. --- website/docs/publish/macos.md | 75 +++++++++++++++++------------------ 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 183ee6bcbb..1ff93ddcd3 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -700,6 +700,42 @@ In [App Store Connect](https://appstoreconnect.apple.com) → **My Apps** → and SKU. Then note the app's numeric **Apple ID** under **App Information → General Information** — command-line uploads are keyed to it. +### Provisioning profile + +The profile created [above](#creating-the-provisioning-profile). A relative +path resolves against the project directory (where `pyproject.toml` lives). +The build embeds it at `Contents/embedded.provisionprofile` — sealed by the +app's signature — and fails fast when the profile's App ID does not cover +the app's bundle ID, a mismatch that would otherwise surface only after +upload as `ITMS-90889`. + +#### Resolution order + +The provisioning profile is determined in the following order of precedence: + +1. [`--macos-provisioning-profile`](../cli/flet-build.md#--macos-provisioning-profile) +2. `[tool.flet.macos.signing].provisioning_profile` +3. [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) + environment variable +4. Default: none — App Store builds fail without one. + +### Installer identity + +The certificate that signs the `.pkg` — the exact certificate name (as +listed by `security find-identity -v -p basic`), its SHA-1 fingerprint, or +a unique substring, matched only among installer certificates. + +#### Resolution order + +The installer identity is determined in the following order of precedence: + +1. [`--macos-installer-identity`](../cli/flet-build.md#--macos-installer-identity) +2. `[tool.flet.macos.signing].installer_identity` +3. [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) + environment variable +4. Default: none — the certificate is + [auto-discovered](#identity-auto-discovery). + ### Building for the App Store @@ -739,8 +775,7 @@ is required — App Store validation rejects the package without it. `ITSAppUsesNonExemptEncryption = false` is optional but answers the export-compliance question once and for all; without it, App Store Connect asks manually for every uploaded build. Both are ordinary -[Info.plist](#infoplist) keys. The -[provisioning profile](#provisioning-profile) setting is detailed below. +[Info.plist](#infoplist) keys. Neither signing identity appears in the examples above: both are [auto-discovered](#identity-auto-discovery) when not configured. To pin @@ -758,42 +793,6 @@ precedence: 2. `[tool.flet.macos.signing].app_store` 3. Default: `false` -### Provisioning profile - -The profile created [above](#creating-the-provisioning-profile). A relative -path resolves against the project directory (where `pyproject.toml` lives). -The build embeds it at `Contents/embedded.provisionprofile` — sealed by the -app's signature — and fails fast when the profile's App ID does not cover -the app's bundle ID, a mismatch that would otherwise surface only after -upload as `ITMS-90889`. - -#### Resolution order - -The provisioning profile is determined in the following order of precedence: - -1. [`--macos-provisioning-profile`](../cli/flet-build.md#--macos-provisioning-profile) -2. `[tool.flet.macos.signing].provisioning_profile` -3. [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) - environment variable -4. Default: none — App Store builds fail without one. - -### Installer identity - -The certificate that signs the `.pkg` — the exact certificate name (as -listed by `security find-identity -v -p basic`), its SHA-1 fingerprint, or -a unique substring, matched only among installer certificates. - -#### Resolution order - -The installer identity is determined in the following order of precedence: - -1. [`--macos-installer-identity`](../cli/flet-build.md#--macos-installer-identity) -2. `[tool.flet.macos.signing].installer_identity` -3. [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) - environment variable -4. Default: none — the certificate is - [auto-discovered](#identity-auto-discovery). - ### Uploading Upload the `.pkg` with [Transporter](https://apps.apple.com/app/transporter/id1450874784) From 532f75e9d7129ab720df72bc7505e2ed07c6a89d Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 20:45:21 +0200 Subject: [PATCH 16/28] Link every option mention; give APPLE_API_* their reference entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Systematic sweep of macos.md for config options named in prose without a link (the build-number sentence in Uploading was the trigger — it named only the CLI flag; it now links the Versioning docs). Also linked: the notarize/app-store toggles in the env-var tab notes, the compile flags in the Troubleshooting table, and the APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER trio, which had no environment-variables reference entries at all and now do. Unlinked-by-convention cases left alone: pyproject keys (no anchor target exists) and --no-* forms adjacent to their linked positive option. --- website/docs/publish/macos.md | 30 ++++++++++++------- .../docs/reference/environment-variables.md | 17 +++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 1ff93ddcd3..9d748824bf 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -522,10 +522,15 @@ Flet can receive either kind through two channels: secrets never appear in your shell history, environment, or `pyproject.toml`. - **Environment variables** (best for CI) — pass an App Store Connect API key - inline on each run by setting `APPLE_API_KEY` (path to the `.p8` file), - `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Nothing is stored on the - machine, which suits ephemeral CI runners where no keychain profile exists — - inject the values from your repository secrets. + inline on each run by setting + [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key) + (path to the `.p8` file), + [`APPLE_API_KEY_ID`](../reference/environment-variables.md#apple_api_key_id), + and + [`APPLE_API_ISSUER`](../reference/environment-variables.md#apple_api_issuer). + Nothing is stored on the machine, which suits ephemeral CI runners where + no keychain profile exists — inject the values from your repository + secrets. #### Resolution order @@ -535,8 +540,11 @@ Credentials are determined in the following order of precedence: 2. `[tool.flet.macos.signing].notary_profile` 3. [`FLET_MACOS_NOTARY_PROFILE`](../reference/environment-variables.md#flet_macos_notary_profile) environment variable -4. The `APPLE_API_KEY`, `APPLE_API_KEY_ID` and `APPLE_API_ISSUER` environment - variables (all three must be set) +4. The [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key), + [`APPLE_API_KEY_ID`](../reference/environment-variables.md#apple_api_key_id) + and + [`APPLE_API_ISSUER`](../reference/environment-variables.md#apple_api_issuer) + environment variables (all three must be set) 5. Default: none — notarizing builds fail without credentials. A configured profile deliberately outranks the `APPLE_API_*` variables, which @@ -565,7 +573,8 @@ notary_profile = "flet-notary" FLET_MACOS_SIGNING_IDENTITY="Developer ID Application: Jane Doe (TEAM123456)" FLET_MACOS_NOTARY_PROFILE="flet-notary" ``` -Notarization must still be turned on with `--macos-notarize` (or +Notarization must still be turned on with +[`--macos-notarize`](../cli/flet-build.md#--macos-notarize) (or `[tool.flet.macos.signing].notarize = true`); this toggle has no environment-variable equivalent. @@ -626,7 +635,7 @@ Export your certificate and private key as a `.p12` file, then store it | Symptom | Cause and fix | |---------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `"MyApp" is damaged and can't be opened` on users' Macs | The bundle was modified after signing — most commonly the app writes next to its own files at runtime. Write user data to `os.getcwd()` (Flet points it at a writable location) instead of paths derived from `__file__`. Also triggered by building with `--no-compile-app`/`--no-compile-packages`, which lets Python create `__pycache__` inside the bundle at runtime. | +| `"MyApp" is damaged and can't be opened` on users' Macs | The bundle was modified after signing — most commonly the app writes next to its own files at runtime. Write user data to `os.getcwd()` (Flet points it at a writable location) instead of paths derived from `__file__`. Also triggered by building with [`--no-compile-app`](../cli/flet-build.md#--compile-app)/[`--no-compile-packages`](../cli/flet-build.md#--compile-packages), which lets Python create `__pycache__` inside the bundle at runtime. | | `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use [`apple-actions/import-codesign-certs`](https://github.com/apple-actions/import-codesign-certs), which handles it. | | Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | | `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | @@ -764,7 +773,8 @@ provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" ```dotenv FLET_MACOS_PROVISIONING_PROFILE="certs/MyApp_MacAppStore.provisionprofile" ``` -App Store mode must still be turned on with `--macos-app-store` (or +App Store mode must still be turned on with +[`--macos-app-store`](../cli/flet-build.md#--macos-app-store) (or `[tool.flet.macos.signing].app_store = true`); this toggle has no environment-variable equivalent. @@ -811,7 +821,7 @@ xcrun altool --upload-package build/macos/MyApp.pkg -t macos \ `` is the app record's Apple ID [noted earlier](#creating-the-app-store-connect-app-record), and every -upload needs a unique build number (`flet build macos --build-number N`). +upload needs a unique [build number](index.md#build-number). After processing (minutes; failures arrive by email as `ITMS-xxxx` codes), the build appears in the **TestFlight** tab of your app record — internal testers can install it without beta review. Note that `--validate-app` diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index d7a7a928f7..24baca0e8e 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -7,6 +7,23 @@ To set a boolean `True`, use one of the following string values: `"true"`, `"1"` Any other value will be interpreted as `False`. ::: +### `APPLE_API_ISSUER` + +Issuer ID of an App Store Connect API key. Together with `APPLE_API_KEY` +and `APPLE_API_KEY_ID`, passes Apple notary-service +[credentials](../publish/macos.md#credentials) inline to `flet build` — +best for CI; a configured notary profile takes precedence. + +### `APPLE_API_KEY` + +Path to an App Store Connect API private key (`.p8`) file. See +[`APPLE_API_ISSUER`](#apple_api_issuer). + +### `APPLE_API_KEY_ID` + +Key ID of an App Store Connect API key. See +[`APPLE_API_ISSUER`](#apple_api_issuer). + ### `FLET_ANDROID_SIGNING_KEY_ALIAS` Android signing key alias used by From 573090c4bb35a0a9db0d18177eaac0e1b98f461f Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Thu, 23 Jul 2026 22:10:47 +0200 Subject: [PATCH 17/28] Replace lane booleans with a single --macos-distribution selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notarize/app_store boolean pair encoded a four-state lane space (ad-hoc, sign-only, Developer ID + notarize, App Store) as a 2x2 grid with an invalid cell that had to be policed by a mutual-exclusion error, and booleans compose badly across the CLI > pyproject > env layers: turning one lane on could not turn the other lane's pyproject 'true' off, so every lane flip needed a paired --no-* negation — and shipping on both channels from one pyproject was impossible. The lane is now one value — --macos-distribution {none,developer-id, app-store} / [tool.flet.macos.signing].distribution — so conflicting lanes are inexpressible, a CLI value wholesale-replaces the pyproject value, and one pyproject can hold both channels' settings (they are naturally lane-scoped) with a single flag flipping between them: flet build macos --macos-distribution app-store The resolved value is validated wherever it came from — argparse choices= only guards the CLI layer, and a pyproject typo must fail loudly rather than fall through to a silently ad-hoc build. The signing configuration is also validated BEFORE the build starts (preflight_macos_signing): identity resolution against the keychain, notary credentials, the provisioning profile, and the store category now fail in seconds instead of after the multi-minute Flutter build. The signing step re-checks everything; the preflight is purely an early exit and a no-op for unsigned builds. Both lanes re-verified end-to-end with pyproject-only distribution config: developer-id (auto-discovered identity, 216 binaries, notarized, stapled, 7/7 verification) and app-store (both identities auto-discovered, store entitlements sealed, signed .pkg). Tests 115 -> 126 — the lane dispatch and preflight gain their first coverage. Docs: new Distribution lanes section with the single resolution order and a 'Shipping on both channels' recipe; notarize and App Store sections, CI example, and changelog updated to the lane vocabulary. --- CHANGELOG.md | 4 +- .../flet-cli/src/flet_cli/commands/build.py | 210 ++++++++++--- .../src/flet_cli/commands/build_base.py | 33 +- .../tests/test_build_macos_signing.py | 296 ++++++++++++++++++ .../src/flet_permission_handler/types.py | 40 +-- website/docs/publish/macos.md | 105 ++++--- 6 files changed, 554 insertions(+), 134 deletions(-) create mode 100644 sdk/python/packages/flet-cli/tests/test_build_macos_signing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d39911a8..955f84bfb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ### New features -* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Add `--macos-notarize` (or `[tool.flet.macos.signing].notarize`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities (an expired or revoked certificate is called out by name and status instead of appearing missing), and without a configured identity a plain build keeps its ad-hoc signature — while `--macos-notarize` and `--macos-app-store` builds, which require an identity anyway, scope resolution to the certificate type Apple accepts for the lane and **auto-discover** it when the keychain holds exactly one (so a bare team ID also stays unambiguous per lane). The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Select the distribution lane with `--macos-distribution developer-id` (or `[tool.flet.macos.signing].distribution`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities (an expired or revoked certificate is called out by name and status instead of appearing missing), and without a configured identity a plain build keeps its ad-hoc signature — while the `developer-id` and `app-store` distribution lanes, which require an identity anyway, scope resolution to the certificate type Apple accepts and **auto-discover** it when the keychain holds exactly one (so a bare team ID also stays unambiguous per lane). The whole signing configuration is additionally **validated before the build starts** — a typo'd or expired identity, missing notary credentials, or a missing store prerequisite fails in seconds instead of after the multi-minute Flutter build. The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. -* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-app-store` (or `[tool.flet.macos.signing].app_store`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded, cross-checked against the bundle id before signing, and scrubbed of the `com.apple.quarantine` attribute browser downloads carry (App Store Connect processing rejects quarantined package contents with error 91109 — a check `altool --validate-app` does not perform), and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before any signing work: missing installer certificate or profile, a profile/bundle-id mismatch (the slow-to-surface ITMS-90889), a missing `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`), and combining `app_store` with `notarize` (store builds are not notarized) are all reported with the exact setting to fix. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. +* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-distribution app-store` (or `[tool.flet.macos.signing].distribution`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded, cross-checked against the bundle id before signing, and scrubbed of the `com.apple.quarantine` attribute browser downloads carry (App Store Connect processing rejects quarantined package contents with error 91109 — a check `altool --validate-app` does not perform), and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before the build starts: a missing installer certificate, profile, or `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`) and a profile/bundle-id mismatch (the slow-to-surface ITMS-90889) are all reported with the exact setting to fix. Because the lane is a single selector rather than per-lane toggles, conflicting lanes are inexpressible, and one `pyproject.toml` can hold both a `developer-id` and an `app-store` configuration — their lane-specific settings don't collide — with the CLI flipping between them per build. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. ### Improvements diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 6ce247141a..e3e669c3f9 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -97,6 +97,8 @@ def handle(self, options: argparse.Namespace) -> None: self.validate_target_platform() self.validate_entry_point() self.setup_template_data() + if self.target_platform == "macos": + self.preflight_macos_signing() self.create_flutter_project() self.package_python_app() self.register_flutter_extensions() @@ -232,17 +234,154 @@ def run_flutter(self): f"[/cyan] {self.emojis['checkmark']}", ) + # Valid values of the macOS distribution lane selector. A single enum — + # instead of per-lane booleans — makes conflicting lanes inexpressible + # and lets one pyproject hold both lanes' settings, with the CLI + # flipping between them per build. + MACOS_DISTRIBUTIONS = ("none", "developer-id", "app-store") + + def resolve_macos_distribution(self) -> str: + """ + Resolve and validate the macOS distribution lane. + + `choices=` only validates the CLI layer, so the resolved value is + checked again here — a typo in `pyproject.toml` (e.g. `app_store`) + must fail loudly, not fall through to a silently ad-hoc build. + + Returns: + One of `MACOS_DISTRIBUTIONS`; exits via `cleanup(1, ...)` on an + invalid configured value. + """ + + assert self.options + assert self.get_pyproject + + distribution = ( + self.options.macos_distribution + or self.get_pyproject("tool.flet.macos.signing.distribution") + or "none" + ) + if distribution not in self.MACOS_DISTRIBUTIONS: + self.cleanup( + 1, + f"Invalid macOS distribution {distribution!r}. Valid values " + f"for --macos-distribution / " + f"`[tool.flet.macos.signing].distribution`: " + f"{', '.join(self.MACOS_DISTRIBUTIONS)}.", + ) + return distribution + + def _macos_store_profile_path(self) -> Path: + """ + Resolve the configured Mac App Store provisioning profile to a path. + + Relative paths resolve against the project directory. Exits via + `cleanup(1, ...)` when no profile is configured or the file does + not exist. + """ + + assert self.options + assert self.get_pyproject + assert self.python_app_path + + profile = ( + self.options.macos_provisioning_profile + or self.get_pyproject("tool.flet.macos.signing.provisioning_profile") + or os.getenv("FLET_MACOS_PROVISIONING_PROFILE") + ) + if not profile: + self.cleanup( + 1, + "App Store builds need a Mac App Store provisioning profile. " + "Pass --macos-provisioning-profile or set " + "`[tool.flet.macos.signing].provisioning_profile`.", + ) + profile_path = Path(profile) + if not profile_path.is_absolute(): + profile_path = (self.python_app_path / profile_path).resolve() + if not profile_path.is_file(): + self.cleanup(1, f"Provisioning profile not found: {profile_path}") + return profile_path + + def preflight_macos_signing(self) -> None: + """ + Validate the signing configuration before any build work. + + Signing runs after the multi-minute Flutter build, so configuration + mistakes — a typo'd, ambiguous, or expired identity, missing notary + credentials, a missing provisioning profile or store category — + would otherwise surface only at the very end. This resolves the + same settings the signing step will use and fails in seconds + instead. The signing step re-checks everything (the keychain is + the authority and can change); this is purely an early exit, and a + no-op for builds with no signing configured. + + Exits via `cleanup(1, ...)` on any configuration error. + """ + + assert self.options + assert self.get_pyproject + assert self.template_data + + distribution = self.resolve_macos_distribution() + identity = ( + self.options.macos_signing_identity + or self.get_pyproject("tool.flet.macos.signing.identity") + or os.getenv("FLET_MACOS_SIGNING_IDENTITY") + ) + if not identity and distribution == "none": + return + + try: + if distribution == "app-store": + resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) + resolve_identity( + self.options.macos_installer_identity + or self.get_pyproject("tool.flet.macos.signing.installer_identity") + or os.getenv("FLET_MACOS_INSTALLER_IDENTITY"), + policy="basic", + types=INSTALLER_CERTIFICATE_TYPES, + ) + elif distribution == "developer-id": + resolve_identity(identity, types=DEVELOPER_ID_CERTIFICATE_TYPES) + self._macos_notary_credentials() + else: + resolve_identity(identity) + except MacOSSigningError as e: + self.cleanup(1, str(e)) + + if distribution == "app-store": + self._macos_store_profile_path() + # The authoritative check reads the built app's Info.plist; this + # one catches the common case — the key not configured at all — + # before the build. + if not self.template_data["options"]["info_plist"].get( + "LSApplicationCategoryType" + ): + self.cleanup( + 1, + "App Store submissions require the LSApplicationCategoryType " + "Info.plist key. Add it with --info-plist " + 'LSApplicationCategoryType="public.app-category." ' + "or `[tool.flet.macos.info]` in pyproject.toml.", + ) + def sign_macos_app(self) -> None: """ - Code-sign — and optionally notarize — the built macOS app bundle. + Code-sign and package the built macOS app bundle for its + distribution lane. Runs after `copy_build_output()` and operates on the final `.app` - in the output directory, i.e. the artifact users distribute. + in the output directory, i.e. the artifact users distribute. The + lane comes from `resolve_macos_distribution()`: - No-op unless a signing identity is configured; without one, the app keeps the - default ad-hoc signature produced by the Flutter build. Notarization is - additionally gated and requires a real (non-ad-hoc) identity plus notary - credentials. + - `none` (default) — sign only when a signing identity is + configured; without one, the app keeps the ad-hoc signature + produced by the Flutter build. + - `developer-id` — sign with the hardened runtime, notarize, and + staple for direct distribution. + - `app-store` — sandboxed store signing plus a signed installer + `.pkg` (see `_sign_macos_app_store()`). The app-bundle signature re-applies the entitlements from the template-generated `Release.entitlements` — re-signing replaces the @@ -257,34 +396,17 @@ def sign_macos_app(self) -> None: assert self.out_dir assert self.flutter_dir + distribution = self.resolve_macos_distribution() identity = ( self.options.macos_signing_identity or self.get_pyproject("tool.flet.macos.signing.identity") or os.getenv("FLET_MACOS_SIGNING_IDENTITY") ) - notarize = ( - self.options.macos_notarize - if self.options.macos_notarize is not None - else bool(self.get_pyproject("tool.flet.macos.signing.notarize")) - ) - app_store = ( - self.options.macos_app_store - if self.options.macos_app_store is not None - else bool(self.get_pyproject("tool.flet.macos.signing.app_store")) - ) - - if app_store and notarize: - self.cleanup( - 1, - "App Store builds are not notarized — Apple reviews and " - "re-signs store builds itself. Remove --macos-notarize / " - "`[tool.flet.macos.signing].notarize`.", - ) - # Notarize and App Store builds require an identity anyway, so an - # unset one auto-discovers (resolve_identity with types, below). - # A plain build without an identity keeps its ad-hoc signature. - if not identity and not (notarize or app_store): + # Distribution lanes require an identity anyway, so an unset one + # auto-discovers (resolve_identity with types, below). A plain + # build without an identity keeps its ad-hoc signature. + if not identity and distribution == "none": return apps = sorted(self.out_dir.glob("*.app")) @@ -319,9 +441,9 @@ def log(message: str): # auto-discover the only candidate. The plain lane stays # unscoped: Apple Development or corporate certificates are # legitimate there. - if app_store: + if distribution == "app-store": resolved = resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) - elif notarize: + elif distribution == "developer-id": resolved = resolve_identity( identity, types=DEVELOPER_ID_CERTIFICATE_TYPES ) @@ -329,14 +451,14 @@ def log(message: str): resolved = resolve_identity(identity) if not identity: console.log(f"Signing identity: {resolved.name}") - if notarize and resolved.is_adhoc: + if distribution == "developer-id" and resolved.is_adhoc: self.cleanup( 1, - "Notarization requires a Developer ID identity; " - 'ad-hoc ("-") signed apps cannot be notarized.', + "Developer ID distribution requires a Developer ID " + 'identity; ad-hoc ("-") signed apps cannot be notarized.', ) - if app_store: + if distribution == "app-store": self._sign_macos_app_store(app_path, resolved, entitlements, log) return @@ -351,7 +473,7 @@ def log(message: str): f"identity: {resolved.description}) {self.emojis['checkmark']}" ) - if notarize: + if distribution == "developer-id": credentials = self._macos_notary_credentials() self.update_status( f"[bold blue]Notarizing [cyan]{app_path.name}[/cyan] " @@ -435,23 +557,7 @@ def _sign_macos_app_store( if not installer_identity: console.log(f"Installer identity: {installer.name}") - profile = ( - self.options.macos_provisioning_profile - or self.get_pyproject("tool.flet.macos.signing.provisioning_profile") - or os.getenv("FLET_MACOS_PROVISIONING_PROFILE") - ) - if not profile: - self.cleanup( - 1, - "App Store builds need a Mac App Store provisioning profile. " - "Pass --macos-provisioning-profile or set " - "`[tool.flet.macos.signing].provisioning_profile`.", - ) - profile_path = Path(profile) - if not profile_path.is_absolute(): - profile_path = (self.python_app_path / profile_path).resolve() - if not profile_path.is_file(): - self.cleanup(1, f"Provisioning profile not found: {profile_path}") + profile_path = self._macos_store_profile_path() # Read the *built* app's bundle id — the authoritative value after # all project/org/bundle-id resolution and templating. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 4e3524616b..a87d282291 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -661,19 +661,22 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: help='"Developer ID Application" (direct distribution) or ' '"Apple Distribution" (App Store) certificate name, its SHA-1 ' 'fingerprint, or "-" for ad-hoc, used to code-sign the app bundle; ' - "when not configured (CLI, pyproject.toml, or env), notarizing and " - "App Store builds auto-discover the only certificate of the " - "required type " + "when not configured (CLI, pyproject.toml, or env), developer-id " + "and app-store distribution builds auto-discover the only " + "certificate of the required type " "(macos only) [env: FLET_MACOS_SIGNING_IDENTITY=]", ) parser.add_argument( - "--macos-notarize", - dest="macos_notarize", - action=argparse.BooleanOptionalAction, - default=None, - help="Submit the signed app to the Apple notary service and staple " - "the ticket; requires notary credentials, while the signing " - "identity is auto-discovered when not configured (macos only)", + "--macos-distribution", + dest="macos_distribution", + type=str.lower, + choices=["none", "developer-id", "app-store"], + help="Distribution channel to sign and package for: 'developer-id' " + "signs with the hardened runtime, notarizes and staples for direct " + "distribution; 'app-store' produces a sandboxed build with an " + "embedded provisioning profile and a signed installer .pkg for " + "App Store Connect / TestFlight; 'none' (default) signs only when " + "a signing identity is configured (macos only)", ) parser.add_argument( "--macos-notary-profile", @@ -684,16 +687,6 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: "APPLE_API_ISSUER environment variables (macos only) " "[env: FLET_MACOS_NOTARY_PROFILE=]", ) - parser.add_argument( - "--macos-app-store", - dest="macos_app_store", - action=argparse.BooleanOptionalAction, - default=None, - help="Sign and package for Mac App Store / TestFlight distribution: " - "sandboxed signing without the hardened runtime, an embedded " - "provisioning profile, and a signed installer .pkg; mutually " - "exclusive with --macos-notarize (macos only)", - ) parser.add_argument( "--macos-provisioning-profile", dest="macos_provisioning_profile", diff --git a/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py new file mode 100644 index 0000000000..5dea706416 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py @@ -0,0 +1,296 @@ +"""Tests for `flet build macos`'s signing orchestration. + +Covers the distribution-lane dispatch and the pre-build preflight in +`flet_cli.commands.build.Command` — the layer between the CLI options and +`flet_cli.utils.macos_sign` (which has its own suite). The command object +is constructed without argparse and driven with fake options/pyproject +values; every `macos_sign` entry point is stubbed, so no keychain, +codesign, or network is touched. +""" + +import argparse +import plistlib +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from flet_cli.commands import build as build_module +from flet_cli.commands.build import Command +from flet_cli.utils.macos_sign import MacOSSigningError, SigningIdentity + +DEV_ID = SigningIdentity( + sha1="a" * 40, name="Developer ID Application: Jane Doe (TEAM123456)" +) +APPLE_DIST = SigningIdentity( + sha1="c" * 40, name="Apple Distribution: Jane Doe (TEAM123456)" +) + + +class Exit(Exception): + """Captures `cleanup(1, message)` calls, which normally sys.exit.""" + + def __init__(self, code, message): + self.code = code + self.message = message + super().__init__(f"cleanup({code}): {message}") + + +def make_command( + tmp_path: Path, + options: dict | None = None, + pyproject: dict | None = None, + info_plist: dict | None = None, +) -> Command: + """Build a Command instance wired for signing tests, without argparse. + + Args: + tmp_path: Test-scoped directory; receives out_dir and flutter_dir. + options: CLI option values; unset signing options default to None. + pyproject: Fake `tool.flet.*` values, keyed by full dotted path. + info_plist: Resolved Info.plist dict for `template_data`. + + Returns: + A command whose `cleanup` raises `Exit` instead of exiting. + """ + cmd = object.__new__(Command) + defaults = dict( + macos_distribution=None, + macos_signing_identity=None, + macos_notary_profile=None, + macos_provisioning_profile=None, + macos_installer_identity=None, + ) + defaults.update(options or {}) + cmd.options = argparse.Namespace(**defaults) + config = pyproject or {} + cmd.get_pyproject = lambda key=None: config.get(key) + cmd.template_data = {"options": {"info_plist": info_plist or {}}} + cmd.python_app_path = tmp_path + cmd.out_dir = tmp_path / "out" + cmd.out_dir.mkdir(exist_ok=True) + cmd.rel_out_dir = "out" + cmd.flutter_dir = tmp_path / "flutter" + entitlements = cmd.flutter_dir / "macos" / "Runner" / "Release.entitlements" + entitlements.parent.mkdir(parents=True, exist_ok=True) + entitlements.write_bytes(plistlib.dumps({"com.apple.security.cs.allow-jit": True})) + cmd.verbose = 0 + cmd.emojis = {"checkmark": ""} + cmd.update_status = lambda *args, **kwargs: None + + def cleanup(code, message=None, **kwargs): + raise Exit(code, message) + + cmd.cleanup = cleanup + return cmd + + +def forbid_keychain(monkeypatch): + """Make any identity resolution fail the test.""" + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda *a, **k: pytest.fail("resolve_identity called unexpectedly"), + ) + + +# --------------------------------------------------------------------------- +# resolve_macos_distribution +# --------------------------------------------------------------------------- + + +def test_distribution_resolution_order(tmp_path): + """CLI beats pyproject; default is 'none'.""" + assert make_command(tmp_path).resolve_macos_distribution() == "none" + assert ( + make_command( + tmp_path, pyproject={"tool.flet.macos.signing.distribution": "developer-id"} + ).resolve_macos_distribution() + == "developer-id" + ) + assert ( + make_command( + tmp_path, + options={"macos_distribution": "app-store"}, + pyproject={"tool.flet.macos.signing.distribution": "developer-id"}, + ).resolve_macos_distribution() + == "app-store" + ) + + +def test_distribution_rejects_invalid_configured_value(tmp_path): + """A pyproject typo must fail loudly, not fall through to an ad-hoc build. + + argparse `choices=` only protects the CLI layer. + """ + cmd = make_command( + tmp_path, pyproject={"tool.flet.macos.signing.distribution": "app_store"} + ) + with pytest.raises(Exit, match="Invalid macOS distribution 'app_store'"): + cmd.resolve_macos_distribution() + + +# --------------------------------------------------------------------------- +# preflight_macos_signing (pre-build fail-fast) +# --------------------------------------------------------------------------- + + +def test_preflight_noop_without_signing_config(tmp_path, monkeypatch): + """A build with no signing configured must not touch the keychain.""" + forbid_keychain(monkeypatch) + make_command(tmp_path).preflight_macos_signing() + + +def test_preflight_developer_id_resolves_identity_and_credentials( + tmp_path, monkeypatch +): + """The developer-id lane pre-resolves the identity and notary credentials.""" + calls = [] + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda identity, policy="codesigning", types=None: ( + calls.append(types), + DEV_ID, + )[1], + ) + cmd = make_command( + tmp_path, + pyproject={"tool.flet.macos.signing.distribution": "developer-id"}, + options={"macos_notary_profile": "flet-notary"}, + ) + cmd.preflight_macos_signing() + assert calls == [build_module.DEVELOPER_ID_CERTIFICATE_TYPES] + + # missing credentials must fail before the build + cmd = make_command( + tmp_path, pyproject={"tool.flet.macos.signing.distribution": "developer-id"} + ) + with pytest.raises(Exit, match="credentials"): + cmd.preflight_macos_signing() + + +def test_preflight_developer_id_surfaces_identity_errors(tmp_path, monkeypatch): + """Keychain resolution failures fail the build in seconds, not minutes.""" + + def raise_ambiguous(*args, **kwargs): + raise MacOSSigningError("matches multiple identities") + + monkeypatch.setattr(build_module, "resolve_identity", raise_ambiguous) + cmd = make_command( + tmp_path, + pyproject={"tool.flet.macos.signing.distribution": "developer-id"}, + options={"macos_notary_profile": "flet-notary"}, + ) + with pytest.raises(Exit, match="matches multiple identities"): + cmd.preflight_macos_signing() + + +def test_preflight_app_store_requirements(tmp_path, monkeypatch): + """The app-store lane pre-checks both identities, profile, and category.""" + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda identity, policy="codesigning", types=None: APPLE_DIST, + ) + profile = tmp_path / "test.provisionprofile" + + def command(**overrides): + return make_command( + tmp_path, + pyproject={ + "tool.flet.macos.signing.distribution": "app-store", + "tool.flet.macos.signing.provisioning_profile": str(profile), + }, + **overrides, + ) + + with pytest.raises(Exit, match="Provisioning profile not found"): + command().preflight_macos_signing() + + profile.write_bytes(b"profile") + with pytest.raises(Exit, match="LSApplicationCategoryType"): + command().preflight_macos_signing() + + command( + info_plist={"LSApplicationCategoryType": "public.app-category.utilities"} + ).preflight_macos_signing() + + +# --------------------------------------------------------------------------- +# sign_macos_app lane dispatch +# --------------------------------------------------------------------------- + + +def stub_lanes(monkeypatch, cmd): + """Stub every lane body, returning a recorder of what ran.""" + ran = SimpleNamespace(signed=None, notarized=False, store=False) + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda identity, policy="codesigning", types=None: DEV_ID, + ) + monkeypatch.setattr( + build_module, + "sign_app", + lambda *a, **k: setattr(ran, "signed", a[1]) or 1, + ) + monkeypatch.setattr( + build_module, + "notarize_and_staple", + lambda *a, **k: setattr(ran, "notarized", True), + ) + cmd._macos_notary_credentials = lambda: object() + cmd._sign_macos_app_store = lambda *a, **k: setattr(ran, "store", True) + return ran + + +def test_dispatch_none_without_identity_is_noop(tmp_path, monkeypatch): + """Bare builds never sign and never touch the keychain.""" + cmd = make_command(tmp_path) + forbid_keychain(monkeypatch) + cmd.sign_macos_app() + + +def test_dispatch_none_with_identity_signs_only(tmp_path, monkeypatch): + """The plain lane signs but never notarizes.""" + cmd = make_command(tmp_path, options={"macos_signing_identity": "Developer ID"}) + (cmd.out_dir / "Test.app").mkdir() + ran = stub_lanes(monkeypatch, cmd) + cmd.sign_macos_app() + assert ran.signed is DEV_ID and not ran.notarized and not ran.store + + +def test_dispatch_developer_id_signs_and_notarizes(tmp_path, monkeypatch): + """The developer-id lane is sign + notarize, no store packaging.""" + cmd = make_command( + tmp_path, pyproject={"tool.flet.macos.signing.distribution": "developer-id"} + ) + (cmd.out_dir / "Test.app").mkdir() + ran = stub_lanes(monkeypatch, cmd) + cmd.sign_macos_app() + assert ran.signed is DEV_ID and ran.notarized and not ran.store + + +def test_dispatch_app_store_routes_to_store_lane(tmp_path, monkeypatch): + """The app-store lane delegates wholesale to _sign_macos_app_store.""" + cmd = make_command( + tmp_path, pyproject={"tool.flet.macos.signing.distribution": "app-store"} + ) + (cmd.out_dir / "Test.app").mkdir() + ran = stub_lanes(monkeypatch, cmd) + cmd.sign_macos_app() + assert ran.store and ran.signed is None and not ran.notarized + + +def test_dispatch_cli_flips_lane_over_pyproject(tmp_path, monkeypatch): + """The one-pyproject-two-lanes workflow: CLI lane wins wholesale.""" + cmd = make_command( + tmp_path, + options={"macos_distribution": "app-store"}, + pyproject={"tool.flet.macos.signing.distribution": "developer-id"}, + ) + (cmd.out_dir / "Test.app").mkdir() + ran = stub_lanes(monkeypatch, cmd) + cmd.sign_macos_app() + assert ran.store and not ran.notarized diff --git a/sdk/python/packages/flet-permission-handler/src/flet_permission_handler/types.py b/sdk/python/packages/flet-permission-handler/src/flet_permission_handler/types.py index db05d5e40a..beb6dd8ed8 100644 --- a/sdk/python/packages/flet-permission-handler/src/flet_permission_handler/types.py +++ b/sdk/python/packages/flet-permission-handler/src/flet_permission_handler/types.py @@ -78,7 +78,7 @@ class Permission(Enum): user's shared collection. Note: - Only supported on Android 10+ (API 29+) only. + Only supported on Android 10+ (API 29+). """ ACCESS_NOTIFICATION_POLICY = "accessNotificationPolicy" @@ -89,7 +89,7 @@ class Permission(Enum): Example: Allows app to turn on and off do-not-disturb. Note: - Only supported on Android Marshmallow+ (API 23+) only. + Only supported on Android Marshmallow+ (API 23+). """ ACTIVITY_RECOGNITION = "activityRecognition" @@ -97,7 +97,7 @@ class Permission(Enum): Permission for accessing the activity recognition. Note: - Only supported on Android 10+ (API 29+) only. + Only supported on Android 10+ (API 29+). """ APP_TRACKING_TRANSPARENCY = "appTrackingTransparency" @@ -108,7 +108,7 @@ class Permission(Enum): websites. Note: - Only supported on iOS only. + Only supported on iOS. """ ASSISTANT = "assistant" @@ -123,7 +123,7 @@ class Permission(Enum): Permission for accessing the device's audio files from external storage. Note: - Only supported on Android 13+ (API 33+) only. + Only supported on Android 13+ (API 33+). """ BACKGROUND_REFRESH = "backgroundRefresh" @@ -131,7 +131,7 @@ class Permission(Enum): Permission for reading the current background refresh status. Note: - Only supported on iOS only. + Only supported on iOS. """ BLUETOOTH = "bluetooth" @@ -153,7 +153,7 @@ class Permission(Enum): Allows the user to make this device discoverable to other Bluetooth devices. Note: - Only supported on Android 12+ (API 31+) only. + Only supported on Android 12+ (API 31+). """ BLUETOOTH_CONNECT = "bluetoothConnect" @@ -162,7 +162,7 @@ class Permission(Enum): Allows the user to connect with already paired Bluetooth devices. Note: - Only supported on Android 12+ (API 31+) only. + Only supported on Android 12+ (API 31+). """ BLUETOOTH_SCAN = "bluetoothScan" @@ -170,7 +170,7 @@ class Permission(Enum): Permission for scanning for Bluetooth devices. Note: - Only supported on Android 12+ (API 31+) only. + Only supported on Android 12+ (API 31+). """ CALENDAR_FULL_ACCESS = "calendarFullAccess" @@ -210,7 +210,7 @@ class Permission(Enum): Allow for sending notifications that override the ringer. Note: - Only supported on iOS only. + Only supported on iOS. """ IGNORE_BATTERY_OPTIMIZATIONS = "ignoreBatteryOptimizations" @@ -218,7 +218,7 @@ class Permission(Enum): Permission for accessing ignore battery optimizations. Note: - Only supported on Android only. + Only supported on Android. """ LOCATION = "location" @@ -269,7 +269,7 @@ class Permission(Enum): https://support.google.com/googleplay/android-developer/answer/9214102#zippy= Note: - Only supported on Android 11+ (API 30+) only. + Only supported on Android 11+ (API 30+). """ # noqa: E501 MEDIA_LIBRARY = "mediaLibrary" @@ -290,7 +290,7 @@ class Permission(Enum): Permission for connecting to nearby devices via Wi-Fi. Note: - Only supported on Android 13+ (API 33+) only. + Only supported on Android 13+ (API 33+). """ NOTIFICATION = "notification" @@ -303,7 +303,7 @@ class Permission(Enum): Permission for accessing the device's phone state. Note: - Only supported on Android only. + Only supported on Android. """ PHOTOS = "photos" @@ -329,7 +329,7 @@ class Permission(Enum): Permission for accessing the device's reminders. Note: - Only supported on iOS only. + Only supported on iOS. """ REQUEST_INSTALL_PACKAGES = "requestInstallPackages" @@ -337,7 +337,7 @@ class Permission(Enum): Permission for requesting installing packages. Note: - Only supported on Android Marshmallow+ (API 23+) only. + Only supported on Android Marshmallow+ (API 23+). """ SCHEDULE_EXACT_ALARM = "scheduleExactAlarm" @@ -345,7 +345,7 @@ class Permission(Enum): Permission for scheduling exact alarms. Note: - Only supported on Android 12+ (API 31+) only. + Only supported on Android 12+ (API 31+). """ SENSORS = "sensors" @@ -362,7 +362,7 @@ class Permission(Enum): Permission for accessing the device's sensors in background. Note: - Only supported on Android 13+ (API 33+) only. + Only supported on Android 13+ (API 33+). """ SMS = "sms" @@ -408,7 +408,7 @@ class Permission(Enum): Allows an app to create windows shown on top of all other apps. Note: - Only supported on Android only. + Only supported on Android. """ UNKNOWN = "unknown" @@ -421,5 +421,5 @@ class Permission(Enum): Permission for accessing the device's video files from external storage. Note: - Only supported on Android 13+ (API 33+) only. + Only supported on Android 13+ (API 33+). """ diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 9d748824bf..53125d6c71 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -474,6 +474,43 @@ Certificate types also scope *explicit* identities in these modes: a partial identity such as your team ID only has to be unique among certificates of the required type, not among all your certificates. +### Distribution lanes + +`flet build macos` signs and packages for one of three lanes, selected by a +single setting: + +- `none` (default) — sign only when a [signing identity](#signing-the-app) + is configured; without one, the app keeps its ad-hoc signature. +- `developer-id` — sign with the hardened runtime, [notarize](#notarization) + and staple for direct distribution. +- `app-store` — sandboxed store signing plus a signed installer `.pkg` for + the [Mac App Store](#mac-app-store). + +#### Resolution order + +The distribution lane is determined in the following order of precedence: + +1. [`--macos-distribution`](../cli/flet-build.md#--macos-distribution) +2. `[tool.flet.macos.signing].distribution` +3. Default: `none` + +#### Shipping on both channels + +One `pyproject.toml` can hold both lanes' settings — the notary profile, +provisioning profile, and installer identity are each read only by their +own lane, and store [Info.plist](#infoplist) keys are harmless in +Developer ID builds. Leave the identities to +[auto-discovery](#identity-auto-discovery) (a pinned identity fits only one +lane), set your default lane in `pyproject.toml`, and flip it per build: + +```bash +flet build macos --macos-distribution app-store +``` + +Both lanes write to the same output directory by default and each build +replaces it — pass `--output` per lane to keep a notarized `.app` and a +store `.pkg` side by side. + ## Notarization A Developer-ID-signed app must also be **notarized** by Apple for Gatekeeper to @@ -556,26 +593,25 @@ other tooling (Fastlane, CI images) may have exported for a different team. ```bash flet build macos \ - --macos-signing-identity "Developer ID Application: Jane Doe (TEAM123456)" \ - --macos-notarize --macos-notary-profile flet-notary + --macos-distribution developer-id --macos-notary-profile flet-notary ``` ```toml [tool.flet.macos.signing] -identity = "Developer ID Application: Jane Doe (TEAM123456)" -notarize = true +distribution = "developer-id" notary_profile = "flet-notary" ``` ```dotenv -FLET_MACOS_SIGNING_IDENTITY="Developer ID Application: Jane Doe (TEAM123456)" FLET_MACOS_NOTARY_PROFILE="flet-notary" ``` -Notarization must still be turned on with -[`--macos-notarize`](../cli/flet-build.md#--macos-notarize) (or -`[tool.flet.macos.signing].notarize = true`); this toggle has no environment-variable equivalent. +The lane must still be selected with +[`--macos-distribution`](../cli/flet-build.md#--macos-distribution) +`developer-id` (or `[tool.flet.macos.signing].distribution`); the +[distribution lane](#distribution-lanes) has no environment-variable +equivalent. @@ -585,15 +621,6 @@ see [Identity auto-discovery](#identity-auto-discovery). If notarization is rejected, the build fails and prints Apple's notarization log, which lists the exact offending files. -#### Resolution order - -Whether to notarize is determined in the following order of precedence: - -1. [`--macos-notarize`](../cli/flet-build.md#--macos-notarize) / - `--no-macos-notarize` -2. `[tool.flet.macos.signing].notarize` -3. Default: `false` - ### Distributing Ship the signed, notarized, and stapled `.app` in a **DMG** (recommended) or a @@ -628,7 +655,7 @@ Export your certificate and private key as a `.p12` file, then store it APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: | echo "${{ secrets.APPLE_API_KEY_P8 }}" > AuthKey.p8 - flet build macos --macos-notarize + flet build macos --macos-distribution developer-id ``` ### Troubleshooting @@ -655,7 +682,7 @@ hardened-runtime exception entitlement (`com.apple.security.cs.*`, including the defaults) is stripped: they are meaningless without the hardened runtime and scrutinized by App Review. Notarization does **not** apply to store -submissions and is rejected in combination with `app_store`. +submissions — the `app-store` [lane](#distribution-lanes) never notarizes. ### Store prerequisites @@ -750,7 +777,7 @@ The installer identity is determined in the following order of precedence: ```bash -flet build macos --macos-app-store \ +flet build macos --macos-distribution app-store \ --macos-provisioning-profile certs/MyApp_MacAppStore.provisionprofile \ --info-plist LSApplicationCategoryType="public.app-category.productivity" \ ITSAppUsesNonExemptEncryption=False @@ -765,7 +792,7 @@ LSApplicationCategoryType = "public.app-category.productivity" ITSAppUsesNonExemptEncryption = false [tool.flet.macos.signing] -app_store = true +distribution = "app-store" provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" ``` @@ -773,19 +800,24 @@ provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" ```dotenv FLET_MACOS_PROVISIONING_PROFILE="certs/MyApp_MacAppStore.provisionprofile" ``` -App Store mode must still be turned on with -[`--macos-app-store`](../cli/flet-build.md#--macos-app-store) (or -`[tool.flet.macos.signing].app_store = true`); this toggle has no -environment-variable equivalent. +The lane must still be selected with +[`--macos-distribution`](../cli/flet-build.md#--macos-distribution) +`app-store` (or `[tool.flet.macos.signing].distribution`); the +[distribution lane](#distribution-lanes) has no environment-variable +equivalent. -[`LSApplicationCategoryType`](https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype) -is required — App Store validation rejects the package without it. -`ITSAppUsesNonExemptEncryption = false` is optional but answers the -export-compliance question once and for all; without it, App Store Connect -asks manually for every uploaded build. Both are ordinary -[Info.plist](#infoplist) keys. +- Setting [`LSApplicationCategoryType`](https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype) + is required — App Store validation rejects the package without it. +- Setting [`ITSAppUsesNonExemptEncryption`](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption) + is optional but answers the export-compliance question once and for all. + If it's not set, App Store Connect walks you through an export compliance + questionnaire every time you upload a new version of your app. + If set to `false` indicates that your app does not use encryption, which can help + streamline the submission process. + +Both are ordinary [Info Property List](#infoplist) keys. Neither signing identity appears in the examples above: both are [auto-discovered](#identity-auto-discovery) when not configured. To pin @@ -793,15 +825,8 @@ them explicitly, configure the [signing identity](#signing-the-app) for the app certificate and the [installer identity](#installer-identity) for the `.pkg` certificate. -#### Resolution order - -Whether to build for the App Store is determined in the following order of -precedence: - -1. [`--macos-app-store`](../cli/flet-build.md#--macos-app-store) / - `--no-macos-app-store` -2. `[tool.flet.macos.signing].app_store` -3. Default: `false` +The lane's [resolution order](#distribution-lanes) is shared by all +distribution channels. ### Uploading From fa29ac3cdfa40549001dbdc510cd3fd6911c9002 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 04:18:48 +0200 Subject: [PATCH 18/28] Per-lane signing subtables: [tool.flet.macos.signing.] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every signing key except the lane selector itself may now live in a per-lane subtable that overrides the flat key when its lane builds — resolution is CLI option > lane subtable > flat key > environment variable (the lane deliberately beats the flat key, the opposite of iOS's export_methods, where per-lane values silently lose to flat ones). This closes the one real gap auto-discovery leaves in the one-pyproject-two-lanes workflow: keychains where discovery cannot pick (e.g. certificates from several teams) can pin a different identity per lane. For keys only one lane reads (notary_profile, provisioning profile, installer identity) the subtable and flat forms are equivalent, so configs can be grouped by lane for readability. The 'none' lane has no subtable, and a misnamed subtable (e.g. signing.app_store) fails the build instead of being silently ignored — same fail-loud philosophy as the distribution value itself. All five lookup sites route through one macos_signing_setting() helper. Tests 126 -> 130: full precedence chain, per-lane identity pinning flipped by --macos-distribution, the fully lane-organized pyproject, and misnamed-subtable rejection. Docs: the Per-lane settings section (tabbed, with the two-run CLI counterpart — invocations need no lane syntax since an invocation is single-lane), lane steps in the four affected resolution orders, and the dual-lane section renamed to 'Switching lanes'. --- .../flet-cli/src/flet_cli/commands/build.py | 110 +++++++++++---- .../tests/test_build_macos_signing.py | 130 ++++++++++++++++++ website/docs/publish/macos.md | 114 +++++++++++---- 3 files changed, 299 insertions(+), 55 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index e3e669c3f9..9fe1c7fa0b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -4,7 +4,7 @@ import shutil import tempfile from pathlib import Path -from typing import Callable +from typing import Callable, Optional from rich.console import Group from rich.live import Live @@ -246,11 +246,14 @@ def resolve_macos_distribution(self) -> str: `choices=` only validates the CLI layer, so the resolved value is checked again here — a typo in `pyproject.toml` (e.g. `app_store`) - must fail loudly, not fall through to a silently ad-hoc build. + must fail loudly, not fall through to a silently ad-hoc build. For + the same reason, per-lane subtable names under + `[tool.flet.macos.signing]` are validated: a misnamed subtable + would otherwise be silently ignored. Returns: One of `MACOS_DISTRIBUTIONS`; exits via `cleanup(1, ...)` on an - invalid configured value. + invalid configured value or a misnamed lane subtable. """ assert self.options @@ -269,8 +272,55 @@ def resolve_macos_distribution(self) -> str: f"`[tool.flet.macos.signing].distribution`: " f"{', '.join(self.MACOS_DISTRIBUTIONS)}.", ) + for key, value in (self.get_pyproject("tool.flet.macos.signing") or {}).items(): + if isinstance(value, dict) and key not in self.MACOS_DISTRIBUTIONS: + self.cleanup( + 1, + f"Unknown lane subtable `[tool.flet.macos.signing.{key}]` " + f"— it would be silently ignored. Valid lane names: " + f"{', '.join(d for d in self.MACOS_DISTRIBUTIONS if d != 'none')}.", + ) return distribution + def macos_signing_setting( + self, + cli_value: Optional[str], + distribution: str, + key: str, + env_var: Optional[str] = None, + ) -> Optional[str]: + """ + Resolve a signing setting with per-lane awareness. + + Precedence: CLI option > `[tool.flet.macos.signing.]` + subtable > flat `[tool.flet.macos.signing]` key > environment + variable. The lane subtable deliberately beats the flat key — a + per-lane value that lost to a generic one would be pointless + (iOS's `export_methods` gets this backwards). + + Args: + cli_value: The already-parsed CLI option value, or None. + distribution: The resolved lane; `none` has no subtable. + key: Key name under `[tool.flet.macos.signing]`. + env_var: Environment variable fallback, if the setting has one. + + Returns: + The resolved value, or None when the setting is not configured. + """ + + assert self.get_pyproject + + return ( + cli_value + or ( + self.get_pyproject(f"tool.flet.macos.signing.{distribution}.{key}") + if distribution != "none" + else None + ) + or self.get_pyproject(f"tool.flet.macos.signing.{key}") + or (os.getenv(env_var) if env_var else None) + ) + def _macos_store_profile_path(self) -> Path: """ Resolve the configured Mac App Store provisioning profile to a path. @@ -284,10 +334,11 @@ def _macos_store_profile_path(self) -> Path: assert self.get_pyproject assert self.python_app_path - profile = ( - self.options.macos_provisioning_profile - or self.get_pyproject("tool.flet.macos.signing.provisioning_profile") - or os.getenv("FLET_MACOS_PROVISIONING_PROFILE") + profile = self.macos_signing_setting( + cli_value=self.options.macos_provisioning_profile, + distribution="app-store", + key="provisioning_profile", + env_var="FLET_MACOS_PROVISIONING_PROFILE", ) if not profile: self.cleanup( @@ -324,10 +375,11 @@ def preflight_macos_signing(self) -> None: assert self.template_data distribution = self.resolve_macos_distribution() - identity = ( - self.options.macos_signing_identity - or self.get_pyproject("tool.flet.macos.signing.identity") - or os.getenv("FLET_MACOS_SIGNING_IDENTITY") + identity = self.macos_signing_setting( + cli_value=self.options.macos_signing_identity, + distribution=distribution, + key="identity", + env_var="FLET_MACOS_SIGNING_IDENTITY", ) if not identity and distribution == "none": return @@ -336,9 +388,12 @@ def preflight_macos_signing(self) -> None: if distribution == "app-store": resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) resolve_identity( - self.options.macos_installer_identity - or self.get_pyproject("tool.flet.macos.signing.installer_identity") - or os.getenv("FLET_MACOS_INSTALLER_IDENTITY"), + self.macos_signing_setting( + cli_value=self.options.macos_installer_identity, + distribution=distribution, + key="installer_identity", + env_var="FLET_MACOS_INSTALLER_IDENTITY", + ), policy="basic", types=INSTALLER_CERTIFICATE_TYPES, ) @@ -397,10 +452,11 @@ def sign_macos_app(self) -> None: assert self.flutter_dir distribution = self.resolve_macos_distribution() - identity = ( - self.options.macos_signing_identity - or self.get_pyproject("tool.flet.macos.signing.identity") - or os.getenv("FLET_MACOS_SIGNING_IDENTITY") + identity = self.macos_signing_setting( + cli_value=self.options.macos_signing_identity, + distribution=distribution, + key="identity", + env_var="FLET_MACOS_SIGNING_IDENTITY", ) # Distribution lanes require an identity anyway, so an unset one @@ -535,10 +591,11 @@ def _sign_macos_app_store( "App Store entitlements require it.", ) - installer_identity = ( - self.options.macos_installer_identity - or self.get_pyproject("tool.flet.macos.signing.installer_identity") - or os.getenv("FLET_MACOS_INSTALLER_IDENTITY") + installer_identity = self.macos_signing_setting( + cli_value=self.options.macos_installer_identity, + distribution="app-store", + key="installer_identity", + env_var="FLET_MACOS_INSTALLER_IDENTITY", ) # Installer certs sign packages, not code — resolved under the # `basic` policy, scoped to installer types (the policy also lists @@ -663,10 +720,11 @@ def _macos_notary_credentials(self) -> NotaryCredentials: assert self.options assert self.get_pyproject - profile = ( - self.options.macos_notary_profile - or self.get_pyproject("tool.flet.macos.signing.notary_profile") - or os.getenv("FLET_MACOS_NOTARY_PROFILE") + profile = self.macos_signing_setting( + cli_value=self.options.macos_notary_profile, + distribution="developer-id", + key="notary_profile", + env_var="FLET_MACOS_NOTARY_PROFILE", ) if profile: return NotaryCredentials(keychain_profile=profile) diff --git a/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py index 5dea706416..3abb6b7279 100644 --- a/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py +++ b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py @@ -294,3 +294,133 @@ def test_dispatch_cli_flips_lane_over_pyproject(tmp_path, monkeypatch): ran = stub_lanes(monkeypatch, cmd) cmd.sign_macos_app() assert ran.store and not ran.notarized + + +# --------------------------------------------------------------------------- +# Per-lane subtables: [tool.flet.macos.signing.] +# --------------------------------------------------------------------------- +# The fake get_pyproject is an exact-key dict, so lane subtables appear both +# as their dotted leaf paths (what macos_signing_setting queries) and, for +# the subtable-name validation, as the parent "tool.flet.macos.signing" +# dict. + + +def test_signing_setting_precedence(tmp_path, monkeypatch): + """CLI > lane subtable > flat key > env var — lane beats flat.""" + monkeypatch.setenv("FLET_MACOS_SIGNING_IDENTITY", "from-env") + cmd = make_command( + tmp_path, + pyproject={ + "tool.flet.macos.signing.developer-id.identity": "from-lane", + "tool.flet.macos.signing.identity": "from-flat", + }, + ) + + setting = lambda cli, lane: cmd.macos_signing_setting( # noqa: E731 + cli, lane, "identity", "FLET_MACOS_SIGNING_IDENTITY" + ) + assert setting("from-cli", "developer-id") == "from-cli" + assert setting(None, "developer-id") == "from-lane" + assert setting(None, "app-store") == "from-flat" # no app-store subtable + + cmd_flatless = make_command( + tmp_path, + pyproject={"tool.flet.macos.signing.developer-id.identity": "from-lane"}, + ) + assert ( + cmd_flatless.macos_signing_setting( + None, "app-store", "identity", "FLET_MACOS_SIGNING_IDENTITY" + ) + == "from-env" + ) + # the plain lane never consults a subtable + assert ( + cmd_flatless.macos_signing_setting( + None, "none", "identity", "FLET_MACOS_SIGNING_IDENTITY" + ) + == "from-env" + ) + + +def test_lane_subtables_pin_identities_per_lane(tmp_path, monkeypatch): + """One pyproject can pin a different certificate per lane.""" + pyproject = { + "tool.flet.macos.signing.distribution": "developer-id", + "tool.flet.macos.signing.developer-id.identity": "Developer ID pinned", + "tool.flet.macos.signing.app-store.identity": "Apple Distribution pinned", + } + seen = [] + + def record_identity(monkeypatch): + # applied after stub_lanes, which installs its own resolve_identity + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda identity, policy="codesigning", types=None: ( + seen.append(identity), + DEV_ID, + )[1], + ) + + cmd = make_command(tmp_path, pyproject=pyproject) + (cmd.out_dir / "Test.app").mkdir() + stub_lanes(monkeypatch, cmd) + record_identity(monkeypatch) + cmd.sign_macos_app() + assert seen[-1] == "Developer ID pinned" + + cmd = make_command( + tmp_path, options={"macos_distribution": "app-store"}, pyproject=pyproject + ) + (cmd.out_dir / "Test.app").mkdir(exist_ok=True) + stub_lanes(monkeypatch, cmd) + record_identity(monkeypatch) + cmd.sign_macos_app() + assert seen[-1] == "Apple Distribution pinned" + + +def test_unknown_lane_subtable_rejected(tmp_path): + """A misnamed subtable fails loudly instead of being silently ignored.""" + cmd = make_command( + tmp_path, + pyproject={ + "tool.flet.macos.signing": { + "distribution": "developer-id", + "app_store": {"identity": "never read"}, + }, + "tool.flet.macos.signing.distribution": "developer-id", + }, + ) + with pytest.raises(Exit, match=r"Unknown lane subtable .*signing\.app_store"): + cmd.resolve_macos_distribution() + + +def test_fully_lane_organized_pyproject(tmp_path, monkeypatch): + """Lane-only keys may live in their lane's subtable instead of flat. + + notary_profile is only read by the developer-id lane and + provisioning_profile only by the app-store lane, so a config with no + flat keys at all must resolve identically. + """ + monkeypatch.setattr( + build_module, + "resolve_identity", + lambda identity, policy="codesigning", types=None: APPLE_DIST, + ) + profile = tmp_path / "test.provisionprofile" + profile.write_bytes(b"profile") + pyproject = { + "tool.flet.macos.signing.distribution": "developer-id", + "tool.flet.macos.signing.developer-id.notary_profile": "flet-notary", + "tool.flet.macos.signing.app-store.provisioning_profile": str(profile), + } + + # developer-id lane finds its notary profile in the subtable + cmd = make_command(tmp_path, pyproject=pyproject) + assert cmd._macos_notary_credentials().keychain_profile == "flet-notary" + + # app-store lane finds its provisioning profile in the subtable + cmd = make_command( + tmp_path, options={"macos_distribution": "app-store"}, pyproject=pyproject + ) + assert cmd._macos_store_profile_path() == profile.resolve() diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 53125d6c71..af0e0c2753 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -244,8 +244,8 @@ Its value is determined in the following order of precedence: ``` :::note - `com.apple.security.cs.allow-unsigned-executable-memory` is required for - `ctypes`/`cffi` callbacks to work on Intel Macs when the app is signed with + [`com.apple.security.cs.allow-unsigned-executable-memory`](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory) + is required for `ctypes`/`cffi` callbacks to work on Intel Macs when the app is signed with the [hardened runtime](#code-signing) (Apple's `libffi` allocates writable-and-executable closure memory on `x86_64`). Apple Silicon is unaffected. Set it to `false` if your app targets only `arm64` and you @@ -375,8 +375,8 @@ that built it, but when other users *download* it, macOS Gatekeeper steps in. Since macOS 15 (Sequoia), there is no Control-click bypass anymore — users must approve every blocked item in **System Settings → Privacy & Security → Open Anyway** with an administrator password, and a Python app can trigger this -per bundled library. For public distribution, sign your app with a -**Developer ID Application** certificate and [notarize](#notarization) it. +per bundled library. For public distribution (excluding the Mac App Store), +sign your app with a **Developer ID Application** certificate and [notarize](#notarization) it. ### Prerequisites @@ -420,10 +420,12 @@ explicit ad-hoc signature. The signing identity is determined in the following order of precedence: 1. [`--macos-signing-identity`](../cli/flet-build.md#--macos-signing-identity) -2. `[tool.flet.macos.signing].identity` -3. [`FLET_MACOS_SIGNING_IDENTITY`](../reference/environment-variables.md#flet_macos_signing_identity) +2. `[tool.flet.macos.signing.].identity` + ([per-lane](#per-lane-settings)) +3. `[tool.flet.macos.signing].identity` +4. [`FLET_MACOS_SIGNING_IDENTITY`](../reference/environment-variables.md#flet_macos_signing_identity) environment variable -4. Default: none — a plain build keeps its ad-hoc signature and no signing +5. Default: none — a plain build keeps its ad-hoc signature and no signing step runs, while [notarize](#notarization) and [App Store](#mac-app-store) builds [auto-discover](#identity-auto-discovery) the certificate. @@ -493,12 +495,60 @@ The distribution lane is determined in the following order of precedence: 1. [`--macos-distribution`](../cli/flet-build.md#--macos-distribution) 2. `[tool.flet.macos.signing].distribution` 3. Default: `none` +#### Per-lane settings -#### Shipping on both channels +Every `[tool.flet.macos.signing]` key except `distribution` may be set +in a per-lane subtable, which overrides the flat key when that lane builds. +This matters for the one setting whose value genuinely differs per lane — +the identity, whenever [auto-discovery](#identity-auto-discovery) cannot +pick for you (say, certificates from several teams in one keychain): + + + +The command line has no per-lane syntax: an invocation selects exactly +one lane, so its options are inherently scoped to it — per-lane values +are simply per-run values: + +```bash +flet build macos --macos-distribution developer-id \ + --macos-signing-identity "Developer ID Application: Jane Doe (TEAM123456)" \ + --macos-notary-profile flet-notary + +flet build macos --macos-distribution app-store \ + --macos-signing-identity "Apple Distribution: Jane Doe (TEAM123456)" \ + --macos-installer-identity "3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" \ + --macos-provisioning-profile certs/MyApp_MacAppStore.provisionprofile \ + --output build/macos-store +``` + + +```toml +[tool.flet.macos.signing] +distribution = "developer-id" # decides which lane subtable below gets chosen + +[tool.flet.macos.signing.developer-id] +identity = "Developer ID Application: Jane Doe (TEAM123456)" +notary_profile = "flet-notary" + +[tool.flet.macos.signing.app-store] +identity = "Apple Distribution: Jane Doe (TEAM123456)" +installer_identity = "3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" +provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" +``` + + + +Settings resolve as: CLI option → lane subtable → flat key → environment +variable. For a key only one lane reads (like [`notary_profile`](#notarization) or +[`provisioning_profile`](#provisioning-profile)), the subtable and the flat form are equivalent — +group by lane for readability, or keep them flat for less nesting. A +misnamed/inexisting subtable (e.g. `[tool.flet.macos.signing.app-wrong-store]`) fails the build. + +#### Switching lanes One `pyproject.toml` can hold both lanes' settings — the notary profile, provisioning profile, and installer identity are each read only by their -own lane, and store [Info.plist](#infoplist) keys are harmless in +own lane, and App Store [Info.plist](#infoplist) keys are harmless in Developer ID builds. Leave the identities to [auto-discovery](#identity-auto-discovery) (a pinned identity fits only one lane), set your default lane in `pyproject.toml`, and flip it per build: @@ -508,12 +558,12 @@ flet build macos --macos-distribution app-store ``` Both lanes write to the same output directory by default and each build -replaces it — pass `--output` per lane to keep a notarized `.app` and a -store `.pkg` side by side. +replaces it — pass an [output directory](index.md#output-directory) per lane to keep a +notarized `.app` and a store `.pkg` side by side. ## Notarization -A Developer-ID-signed app must also be **notarized** by Apple for Gatekeeper to +A **Developer-ID**-signed app must also be **notarized** by Apple for Gatekeeper to open it without warnings. Notarization uploads the app to Apple's notary service (a malware scan, typically a few minutes), after which the resulting "ticket" is **stapled** to the app so it validates even offline. @@ -534,7 +584,7 @@ suits you: [account.apple.com](https://account.apple.com) → **Sign-In and Security** → **App-Specific Passwords** → **+**, generate a [password](https://support.apple.com/102654) dedicated to notarization - (your regular Apple ID password won't work with `notarytool`). + (your regular Apple ID password is not meant here and won't work with `notarytool`). Flet can receive either kind through two channels: @@ -574,15 +624,17 @@ Flet can receive either kind through two channels: Credentials are determined in the following order of precedence: 1. [`--macos-notary-profile`](../cli/flet-build.md#--macos-notary-profile) -2. `[tool.flet.macos.signing].notary_profile` -3. [`FLET_MACOS_NOTARY_PROFILE`](../reference/environment-variables.md#flet_macos_notary_profile) +2. `[tool.flet.macos.signing.developer-id].notary_profile` + ([per-lane](#per-lane-settings)) +3. `[tool.flet.macos.signing].notary_profile` +4. [`FLET_MACOS_NOTARY_PROFILE`](../reference/environment-variables.md#flet_macos_notary_profile) environment variable -4. The [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key), +5. The [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key), [`APPLE_API_KEY_ID`](../reference/environment-variables.md#apple_api_key_id) and [`APPLE_API_ISSUER`](../reference/environment-variables.md#apple_api_issuer) environment variables (all three must be set) -5. Default: none — notarizing builds fail without credentials. +6. Default: none — notarizing builds fail without credentials. A configured profile deliberately outranks the `APPLE_API_*` variables, which other tooling (Fastlane, CI images) may have exported for a different team. @@ -660,13 +712,13 @@ Export your certificate and private key as a `.p12` file, then store it ### Troubleshooting -| Symptom | Cause and fix | -|---------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Symptom | Cause and fix | +|---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `"MyApp" is damaged and can't be opened` on users' Macs | The bundle was modified after signing — most commonly the app writes next to its own files at runtime. Write user data to `os.getcwd()` (Flet points it at a writable location) instead of paths derived from `__file__`. Also triggered by building with [`--no-compile-app`](../cli/flet-build.md#--compile-app)/[`--no-compile-packages`](../cli/flet-build.md#--compile-packages), which lets Python create `__pycache__` inside the bundle at runtime. | -| `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use [`apple-actions/import-codesign-certs`](https://github.com/apple-actions/import-codesign-certs), which handles it. | -| Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | -| `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | -| Notarization takes very long | The first-ever submission for a new account can take up to an hour or more; subsequent submissions typically finish within minutes. | +| `errSecInternalComponent` when signing in CI | The keychain is locked — unlock it in the job, or use [`apple-actions/import-codesign-certs`](https://github.com/apple-actions/import-codesign-certs), which handles it. | +| Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | +| `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | +| Notarization takes very long | The first-ever submission for a new account can take up to an hour or more; subsequent submissions typically finish within minutes. | ## Mac App Store @@ -750,10 +802,12 @@ upload as `ITMS-90889`. The provisioning profile is determined in the following order of precedence: 1. [`--macos-provisioning-profile`](../cli/flet-build.md#--macos-provisioning-profile) -2. `[tool.flet.macos.signing].provisioning_profile` -3. [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) +2. `[tool.flet.macos.signing.app-store].provisioning_profile` + ([per-lane](#per-lane-settings)) +3. `[tool.flet.macos.signing].provisioning_profile` +4. [`FLET_MACOS_PROVISIONING_PROFILE`](../reference/environment-variables.md#flet_macos_provisioning_profile) environment variable -4. Default: none — App Store builds fail without one. +5. Default: none — App Store builds fail without one. ### Installer identity @@ -766,10 +820,12 @@ a unique substring, matched only among installer certificates. The installer identity is determined in the following order of precedence: 1. [`--macos-installer-identity`](../cli/flet-build.md#--macos-installer-identity) -2. `[tool.flet.macos.signing].installer_identity` -3. [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) +2. `[tool.flet.macos.signing.app-store].installer_identity` + ([per-lane](#per-lane-settings)) +3. `[tool.flet.macos.signing].installer_identity` +4. [`FLET_MACOS_INSTALLER_IDENTITY`](../reference/environment-variables.md#flet_macos_installer_identity) environment variable -4. Default: none — the certificate is +5. Default: none — the certificate is [auto-discovered](#identity-auto-discovery). ### Building for the App Store From 208c181bc7e9713c7e9581e70753ee2a501a0dab Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 12:50:48 +0200 Subject: [PATCH 19/28] Normalize web option case on the resolved value, not per source route_url_strategy and web_renderer previously lowercased only the CLI option (type=str.lower) and the environment variable, letting a pyproject value like renderer = "CanvasKit" through unlowered. Apply .lower() once to whichever source wins instead; the or-"" guard on the env var is no longer needed since the default keeps the chain non-None. Also reflow an over-long comment in android_sdk.py (E501). --- .../flet-cli/src/flet_cli/commands/build_base.py | 9 ++++----- .../packages/flet-cli/src/flet_cli/utils/android_sdk.py | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index a87d282291..bffd6e56ed 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -1338,10 +1338,9 @@ def _xml_attr_value(v): "route_url_strategy": ( self.options.route_url_strategy or self.get_pyproject("tool.flet.web.route_url_strategy") - # lowered for parity with the CLI option's type=str.lower - or (os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "").lower() + or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "path" - ), + ).lower(), # "canvaskit" (dart2js), not "auto": with "auto" Chromium browsers # select the dart2wasm/skwasm build, where every JS <-> Dart byte # buffer crossing pays a WasmGC boundary conversion instead of a @@ -1350,9 +1349,9 @@ def _xml_attr_value(v): "web_renderer": ( self.options.web_renderer or self.get_pyproject("tool.flet.web.renderer") - or (os.getenv("FLET_WEB_RENDERER") or "").lower() + or os.getenv("FLET_WEB_RENDERER") or "canvaskit" - ), + ).lower(), "pwa_background_color": ( self.options.pwa_background_color or self.get_pyproject("tool.flet.web.pwa_background_color") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py index da88c695a3..bd122bad8b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py @@ -229,7 +229,8 @@ def cmdline_tools_url(self): }, "Windows": { "AMD64": "win", - "ARM64": "win" # If it is an ARM-based Windows device, return the download link for x64 devices. + # ARM-based Windows devices get the x64 download link + "ARM64": "win", }, }[platform.system()][platform.machine()] except KeyError: From 3f90368728338b48d6fb0430acc51b8e83286032 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 13:04:00 +0200 Subject: [PATCH 20/28] macOS publish docs: custom DMG recipe (dmgbuild) in Distributing Add a third Distributing tab covering styled DMGs (background image, app-left/Applications-right layout) via dmgbuild: verified settings file matched to flet's build output, the >=1.6.7 version floor (older versions produce blank backgrounds on macOS 26.2+, dmgbuild#273), the @2x Retina background convention, and the same sign/notarize/staple closing sequence as the plain-DMG tab. dmgbuild writes the Finder layout without a GUI session, so the recipe works identically in CI. --- website/docs/publish/macos.md | 184 ++++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 30 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index af0e0c2753..ecd468ab49 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -400,7 +400,7 @@ flet build macos --macos-signing-identity "Developer ID Application: Jane Doe (T ```toml -[tool.flet.macos.signing] +[tool.flet.macos.signing] # or [tool.flet.macos.signing.] identity = "Developer ID Application: Jane Doe (TEAM123456)" ``` @@ -488,6 +488,12 @@ single setting: - `app-store` — sandboxed store signing plus a signed installer `.pkg` for the [Mac App Store](#mac-app-store). +:::tip +By default, all lanes write to the same output directory, so each build +overwrites the existing files. To keep separate output for each lane, +pass a custom [output directory](index.md#output-directory) for each build. +::: + #### Resolution order The distribution lane is determined in the following order of precedence: @@ -495,6 +501,7 @@ The distribution lane is determined in the following order of precedence: 1. [`--macos-distribution`](../cli/flet-build.md#--macos-distribution) 2. `[tool.flet.macos.signing].distribution` 3. Default: `none` + #### Per-lane settings Every `[tool.flet.macos.signing]` key except `distribution` may be set @@ -517,14 +524,16 @@ flet build macos --macos-distribution developer-id \ flet build macos --macos-distribution app-store \ --macos-signing-identity "Apple Distribution: Jane Doe (TEAM123456)" \ --macos-installer-identity "3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" \ - --macos-provisioning-profile certs/MyApp_MacAppStore.provisionprofile \ - --output build/macos-store + --macos-provisioning-profile path/to/File.provisionprofile \ + # --output build/macos-store ``` ```toml [tool.flet.macos.signing] -distribution = "developer-id" # decides which lane subtable below gets chosen +# Decides which distribution lane subtable below gets chosen for the current build. +# To change lane without editing pyproject.toml, consider using the `--macos-distribution` CLI option instead. +distribution = "developer-id" [tool.flet.macos.signing.developer-id] identity = "Developer ID Application: Jane Doe (TEAM123456)" @@ -533,16 +542,16 @@ notary_profile = "flet-notary" [tool.flet.macos.signing.app-store] identity = "Apple Distribution: Jane Doe (TEAM123456)" installer_identity = "3rd Party Mac Developer Installer: Jane Doe (TEAM123456)" -provisioning_profile = "certs/MyApp_MacAppStore.provisionprofile" +provisioning_profile = "path/to/File.provisionprofile" ``` Settings resolve as: CLI option → lane subtable → flat key → environment -variable. For a key only one lane reads (like [`notary_profile`](#notarization) or -[`provisioning_profile`](#provisioning-profile)), the subtable and the flat form are equivalent — -group by lane for readability, or keep them flat for less nesting. A -misnamed/inexisting subtable (e.g. `[tool.flet.macos.signing.app-wrong-store]`) fails the build. +variable. For a key only one lane reads (like [`notary_profile`](#notarization) +on `developer-id` lane or [`provisioning_profile`](#provisioning-profile) on `app-store` lane), +the subtable and the flat form are equivalent — group by lane for readability, +or keep them flat for less nesting. A misnamed/inexisting subtable fails the build. #### Switching lanes @@ -557,10 +566,6 @@ lane), set your default lane in `pyproject.toml`, and flip it per build: flet build macos --macos-distribution app-store ``` -Both lanes write to the same output directory by default and each build -replaces it — pass an [output directory](index.md#output-directory) per lane to keep a -notarized `.app` and a store `.pkg` side by side. - ## Notarization A **Developer-ID**-signed app must also be **notarized** by Apple for Gatekeeper to @@ -570,8 +575,12 @@ service (a malware scan, typically a few minutes), after which the resulting ### Credentials -Apple's notary service accepts two kinds of credentials — get whichever -suits you: +Setting up credentials takes two steps: create one with Apple, then make +it available to Flet. + +#### Creating a credential + +Apple's notary service accepts two kinds — get whichever suits you: - **App Store Connect API key** (recommended; also reusable for [store uploads](#uploading)) — in App Store Connect, open @@ -586,15 +595,15 @@ suits you: [password](https://support.apple.com/102654) dedicated to notarization (your regular Apple ID password is not meant here and won't work with `notarytool`). -Flet can receive either kind through two channels: +#### Providing it to Flet - **Keychain profile** (best for local development) — a one-time setup that - saves the credential into the macOS keychain under a name of your choice. - With an API key: + saves either kind of credential into the macOS keychain under a name of + your choice. With an API key: ```bash xcrun notarytool store-credentials flet-notary \ - --key ~/keys/AuthKey_ABC123DEFG.p8 --key-id ABC123DEFG \ + --key /path/to/AuthKey_ABC123DEFG.p8 --key-id ABC123DEFG \ --issuer 12345678-90ab-cdef-1234-567890abcdef ``` @@ -636,7 +645,7 @@ Credentials are determined in the following order of precedence: environment variables (all three must be set) 6. Default: none — notarizing builds fail without credentials. -A configured profile deliberately outranks the `APPLE_API_*` variables, which +A configured profile intentionally has precedence over the `APPLE_API_*` variables, which other tooling (Fastlane, CI images) may have exported for a different team. ### Notarizing the app @@ -675,10 +684,12 @@ log, which lists the exact offending files. ### Distributing -Ship the signed, notarized, and stapled `.app` in a **DMG** (recommended) or a -zip archive created with `ditto -c -k --keepParent` (preserves the bundle -structure and staple). A simple DMG that also gets its own staple: +`flet build` leaves you with a signed, notarized, and stapled `.app` — +ship it as a single downloadable file, either a **DMG** (recommended) or a +zip archive. + + ```bash hdiutil create -volname "MyApp" -srcfolder build/macos/MyApp.app -ov -format UDZO MyApp.dmg codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg @@ -686,12 +697,112 @@ xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait xcrun stapler staple MyApp.dmg ``` +- `hdiutil create` packs the app into a compressed read-only image (`UDZO`). +- `codesign` signs the image itself, with the same **Developer ID + Application** identity the app was signed with. +- `notarytool submit` notarizes the image — same + [credentials](#credentials) as the build; with an API key instead of a + keychain profile, pass `--key`/`--key-id`/`--issuer`. +- `stapler staple` attaches the ticket to the DMG, so the whole download — + not just the app inside — validates offline. + +The result is the conventional macOS download: users open the image and +drag the app into **Applications**. + + +For the polished look — background image, app on the left, **Applications** +on the right — use [`dmgbuild`](https://dmgbuild.readthedocs.io/), a small +pip-installable tool that writes the Finder layout directly (no Finder or +GUI session involved, so it works the same in CI): + +```bash +pip install "dmgbuild>=1.6.7" +``` + +:::caution +Keep dmgbuild at **1.6.7 or later**: images built with older versions +[show a blank background](https://github.com/dmgbuild/dmgbuild/issues/273) +on macOS 26.2+. +::: + +Create `dmg_settings.py` next to your project: + +```python +files = ["build/macos/MyApp.app"] +symlinks = {"Applications": "/Applications"} + +# 600x400 image; place an optional dmg/background@2x.png sibling +# next to it for Retina — both are combined automatically. +background = "dmg/background.png" + +window_rect = ((200, 200), (600, 400)) +icon_size = 110 +icon_locations = { + "MyApp.app": (150, 210), + "Applications": (450, 210), +} +format = "UDZO" +``` + +The window is sized in points equal to the 1x image's pixel size, so draw +any "drag the app to Applications" guidance directly into the background +image. Then build, and sign/notarize/staple the image exactly as in the +**DMG** tab: + +```bash +dmgbuild -s dmg_settings.py "MyApp" MyApp.dmg +codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg +xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait +xcrun stapler staple MyApp.dmg +``` + + +```bash +ditto -c -k --keepParent build/macos/MyApp.app MyApp.zip +``` + +Use `ditto` (or Finder's **Compress**) rather than plain `zip`, which can +mangle the symlinks inside the bundled frameworks and break the app's +signature. A zip involves no extra signing — it can't be signed or +stapled — so after extraction Gatekeeper relies on the staple already on +the `.app` inside. + + + ### Signing and notarizing in CI #### GitHub Actions -Export your certificate and private key as a `.p12` file, then store it -(base64-encoded) and its password as repository secrets: +A CI runner starts with an empty keychain, so the one-time setup is about +getting your certificate and notary credentials into +[repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets): + +1. Export the **Developer ID Application** certificate together with its + private key: in **Keychain Access**, under **login → My Certificates**, + right-click the certificate → **Export…** and save it in the `.p12` + format, protected by an export password + ([Apple's guide](https://support.apple.com/guide/keychain-access/import-and-export-keychain-items-kyca35961/mac)). +2. Store the secrets — in the repository's **Settings → Secrets and + variables → Actions**, or with the + [`gh` CLI](https://cli.github.com/manual/gh_secret_set). Secrets hold + text, so the binary `.p12` is stored base64-encoded + (`base64 -i path/to/certificate.p12 | pbcopy` fills the clipboard), + while the `.p8` key is already text (PEM) and goes in as-is: + + ```bash + gh secret set MACOS_CERTIFICATE_P12 --body "$(base64 -i path/to/certificate.p12)" + gh secret set MACOS_CERTIFICATE_PASSWORD # prompts; the export password from step 1 above + gh secret set MACOS_SIGNING_IDENTITY --body "Developer ID Application: Jane Doe (TEAM123456)" + gh secret set APPLE_API_KEY_P8 < path/to/AuthKey_ABC123DEFG.p8 + gh secret set APPLE_API_KEY_ID --body "ABC123DEFG" + gh secret set APPLE_API_ISSUER --body "12345678-90ab-cdef-1234-567890abcdef" + ``` + + The last three values come with the + [App Store Connect API key](#creating-a-credential). + +The workflow then imports the certificate into the runner's keychain and +exposes the credentials to `flet build`: ```yaml - uses: apple-actions/import-codesign-certs@v3 @@ -701,15 +812,27 @@ Export your certificate and private key as a `.p12` file, then store it - name: Build, sign and notarize env: - FLET_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} - APPLE_API_KEY: ${{ github.workspace }}/AuthKey.p8 + APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + FLET_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} run: | - echo "${{ secrets.APPLE_API_KEY_P8 }}" > AuthKey.p8 + printf '%s' "${{ secrets.APPLE_API_KEY_P8 }}" > "$APPLE_API_KEY" flet build macos --macos-distribution developer-id ``` +The API key is materialized to a file because +[`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key) is a +*path* — `notarytool` reads the key from disk and cannot take its content +inline. Writing it under `runner.temp` (instead of the workspace) keeps the +private key out of any artifact upload of the checkout. + +:::tip +Since the imported certificate is the only identity in the runner's +keychain, the `MACOS_SIGNING_IDENTITY` secret and env line can also be +dropped in favor of [auto-discovery](#identity-auto-discovery). +::: + ### Troubleshooting | Symptom | Cause and fix | @@ -843,8 +966,8 @@ flet build macos --macos-distribution app-store \ ```toml [tool.flet.macos.info] # required by App Store validation -LSApplicationCategoryType = "public.app-category.productivity" -# answers the export-compliance question per build (standard encryption only) +LSApplicationCategoryType = "public.app-category.xxx-yyy-zzz" +# optional ITSAppUsesNonExemptEncryption = false [tool.flet.macos.signing] @@ -866,6 +989,7 @@ equivalent. - Setting [`LSApplicationCategoryType`](https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype) is required — App Store validation rejects the package without it. + See supported/possible values [here](https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype#possibleValues). - Setting [`ITSAppUsesNonExemptEncryption`](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption) is optional but answers the export-compliance question once and for all. If it's not set, App Store Connect walks you through an export compliance From 6662b2d1cd23be6f9d544433bf4cc48466e09c3e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 13:30:44 +0200 Subject: [PATCH 21/28] Document Flet build action in publishing docs --- website/docs/publish/index.md | 108 +++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 8 deletions(-) diff --git a/website/docs/publish/index.md b/website/docs/publish/index.md index d228492c65..59d780c283 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -562,7 +562,7 @@ Increment this for each new release to differentiate it from previous versions. Its value is determined in the following order of precedence: -1. `--build-version` +1. [`--build-version`](../cli/flet-build.md#--build-version) 2. `[project].version` 3. `[tool.poetry].version` 4. Otherwise, the build version from the generated `pubspec.yaml` @@ -1503,6 +1503,96 @@ the build and release process of your Flet apps. You can use [GitHub Actions](https://docs.github.com/en/actions) to build your Flet app automatically on every push, pull request, or manual run. +The recommended option is the [official Flet build action](https://github.com/flet-dev/flet-build-action), +which wraps `flet build`, sets up the required tools, installs Linux build dependencies when needed, and +creates a platform-aware archive for upload. If you need full control over every +step, you can run `flet build` **manually** in the workflow instead. + + + + +```yaml +name: Build Flet App # (1)! + +on: # (2)! + push: # (3)! + pull_request: # (4)! + workflow_dispatch: # (5)! + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: # (6)! + fail-fast: false + matrix: + include: + - target: apk + runner: ubuntu-latest + + - target: aab + runner: ubuntu-latest + + - target: web + runner: ubuntu-latest + + - target: linux + runner: ubuntu-latest + + - target: windows + runner: windows-latest + + - target: macos + runner: macos-latest + + - target: ipa + runner: macos-latest + + - target: ios-simulator + runner: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 # (7)! + + - name: Build app + id: build + uses: flet-dev/flet-build-action@v1 # (8)! + with: + target: ${{ matrix.target }} # (9)! + runner-python-version: "3.14" # (10)! + bundled-python-version: "3.14" # (11)! + build-number: ${{ github.run_number }} # (12)! + + - name: Upload build archive + uses: actions/upload-artifact@v7 # (13)! + with: + path: ${{ steps.build.outputs.archive-path }} # (14)! + archive: false # (15)! + if-no-files-found: error # (16)! + overwrite: false +``` + +1. Workflow display name shown in the **Actions** tab. +2. Trigger block for automatic and manual workflow runs. +3. Runs this workflow on every push (unless you restrict branches). +4. Runs this workflow when pull requests are opened/updated. +5. Enables manual runs from GitHub UI (**Actions** → **Run workflow**). +6. Matrix strategy: each `include` item becomes a parallel build job. +7. Checks out your repository so this workflow can access project files. View its docs [here](https://github.com/actions/checkout). +8. Builds the selected target using the official Flet build action. View its docs [here](https://github.com/flet-dev/flet-build-action). +9. Passes the current matrix target to the action. +10. Python version used by `uv` on the GitHub Actions runner. +11. Python version bundled into the built Flet app. See [Choosing a Python version](#choosing-a-python-version). +12. Uses the GitHub run number as the app build number. See [Build Number](#build-number). +13. Uploads the archive created by the Flet build action. View its docs [here](https://github.com/actions/upload-artifact). +14. Uploads the action's platform-aware archive output. +15. Disables `upload-artifact`'s own archive wrapper because the Flet build action already created an archive. +16. If no archive was found to upload, the workflow fails, indicating something went wrong during the build. + + + + ```yaml name: Build Flet App # (1)! @@ -1512,7 +1602,7 @@ on: # (2)! workflow_dispatch: # (5)! env: # (6)! - UV_PYTHON: 3.12 # (7)! + UV_PYTHON: 3.14 # (7)! PYTHONUTF8: 1 # (8)! # https://flet.dev/docs/reference/environment-variables @@ -1580,7 +1670,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 # (11)! + uses: actions/checkout@v6 # (11)! - name: Setup uv uses: astral-sh/setup-uv@v6 # (12)! @@ -1600,7 +1690,7 @@ jobs: uv run ${{ matrix.build_cmd }} --yes --verbose - name: Upload Artifact - uses: actions/upload-artifact@v5.0.0 # (16)! + uses: actions/upload-artifact@v7 # (16)! with: name: ${{ matrix.name }}-build-artifact path: ${{ matrix.artifact_path }} # (17)! @@ -1627,11 +1717,13 @@ jobs: 17. Artifact path expected from each build target. 18. If no files were found to upload, the workflow fails, indicating something went wrong during the build. -The workflow file above builds for all major targets and uploads each build output as an artifact. -You can further customize the workflow for your specific needs, for example, -restricting the build targets or adding additional steps. + + -See it in action [here](https://github.com/ndonkoHenri/flet-github-action-workflows). +Both workflow variants build for all major targets and upload each build output +as an artifact. You can further customize the workflow for your specific needs, +for example, restricting the build targets or adding signing, notarization, +store upload, or deployment steps. ## Troubleshooting From 756c2352e8e42ce057f58ea356f9a1b98800b50e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 13:31:49 +0200 Subject: [PATCH 22/28] macOS publish docs: credentials/CI walkthroughs, troubleshooting expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Credentials split into two sequential subsections (Creating a credential / Providing it to Flet); note that only the keychain profile takes both credential kinds. - CI section now walks the one-time setup: .p12 export from Keychain Access, why the .p12 is base64-encoded while the PEM .p8 goes in as-is, and gh secret set commands for all six secrets. Workflow writes the API key through $APPLE_API_KEY into runner.temp (printf, no path duplication, out of artifact-upload reach) with a note on why the key must be a file at all. - Distributing: DMG (plain or dmgbuild custom look, sharing the sign/notarize/staple steps) vs zip as tabs, each step explained; zip's can't-be-stapled asymmetry called out. - Troubleshooting: two new rows (codesign hang on keychain prompt, missing Apple intermediate CAs) and a Mac App Store table (ITMS-90889, 91109 quarantine, App Sandbox file access, TestFlight app relocation) — all hit during real-world verification. --- website/docs/publish/macos.md | 76 +++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index ecd468ab49..26474dc511 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -689,27 +689,20 @@ ship it as a single downloadable file, either a **DMG** (recommended) or a zip archive. - + +First, create the image — plain, or with a custom Finder look: + + + ```bash hdiutil create -volname "MyApp" -srcfolder build/macos/MyApp.app -ov -format UDZO MyApp.dmg -codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg -xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait -xcrun stapler staple MyApp.dmg ``` -- `hdiutil create` packs the app into a compressed read-only image (`UDZO`). -- `codesign` signs the image itself, with the same **Developer ID - Application** identity the app was signed with. -- `notarytool submit` notarizes the image — same - [credentials](#credentials) as the build; with an API key instead of a - keychain profile, pass `--key`/`--key-id`/`--issuer`. -- `stapler staple` attaches the ticket to the DMG, so the whole download — - not just the app inside — validates offline. - -The result is the conventional macOS download: users open the image and -drag the app into **Applications**. +`hdiutil create` packs the app into a compressed read-only image (`UDZO`) +containing just the app. For an **Applications** drop-shortcut, a +background image, and icon placement, see the **Custom look** tab. - + For the polished look — background image, app on the left, **Applications** on the right — use [`dmgbuild`](https://dmgbuild.readthedocs.io/), a small pip-installable tool that writes the Finder layout directly (no Finder or @@ -725,7 +718,7 @@ Keep dmgbuild at **1.6.7 or later**: images built with older versions on macOS 26.2+. ::: -Create `dmg_settings.py` next to your project: +Create [`dmg_settings.py`](https://dmgbuild.readthedocs.io/en/latest/settings.html) next to your project, and customize accordingly: ```python files = ["build/macos/MyApp.app"] @@ -746,15 +739,45 @@ format = "UDZO" The window is sized in points equal to the 1x image's pixel size, so draw any "drag the app to Applications" guidance directly into the background -image. Then build, and sign/notarize/staple the image exactly as in the -**DMG** tab: +image. + +:::info +The example above is just the classic subset — the +[settings reference](https://dmgbuild.readthedocs.io/en/latest/settings.html) +covers much more: solid-color backgrounds, a custom volume icon (or a badge +composited onto the standard disk icon), extra files with hidden-file and +hidden-extension control, icon/text sizes and list-view layouts, a +multi-language license agreement shown before mounting (attach it before +signing the image), alternative image formats (`UDBZ`, `ULFO`) and the +APFS filesystem, and more. +::: + +Then build the image: ```bash dmgbuild -s dmg_settings.py "MyApp" MyApp.dmg +``` + + + +Then sign, notarize, and staple the image: + +```bash codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait xcrun stapler staple MyApp.dmg ``` + +- `codesign` signs the image itself, with the same **Developer ID + Application** identity the app was signed with. +- `notarytool submit` notarizes the image — same + [credentials](#credentials) as the build; with an API key instead of a + keychain profile, pass `--key`/`--key-id`/`--issuer`. +- `stapler staple` attaches the ticket to the DMG, so the whole download — + not just the app inside — validates offline. + +The result is the conventional macOS download: users open the image and +drag the app into **Applications**. ```bash @@ -824,8 +847,8 @@ exposes the credentials to `flet build`: The API key is materialized to a file because [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key) is a *path* — `notarytool` reads the key from disk and cannot take its content -inline. Writing it under `runner.temp` (instead of the workspace) keeps the -private key out of any artifact upload of the checkout. +inline. Writing it under [`runner.temp`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#runner-context) +(instead of the workspace) keeps the private key out of any artifact upload of the checkout. :::tip Since the imported certificate is the only identity in the runner's @@ -842,6 +865,8 @@ dropped in favor of [auto-discovery](#identity-auto-discovery). | Notarization status `Invalid` | Read the printed notary log: typical causes are an unsigned binary that was added to the bundle after signing, or a certificate that is not a Developer ID Application certificate. | | `library load disallowed by system policy` | A native library is signed with a different Team ID than the app (or not at all). Rebuild so all binaries are re-signed together, or — if your app must load externally acquired native code at runtime — add the `com.apple.security.cs.disable-library-validation` [entitlement](#entitlements). | | Notarization takes very long | The first-ever submission for a new account can take up to an hour or more; subsequent submissions typically finish within minutes. | +| Build hangs at the signing step (`codesign` at 0% CPU) | macOS is waiting on a keychain prompt — possibly hidden behind other windows — for permission to use the private key, common after importing a key from the terminal. Click **Always Allow** on the prompt, or pre-authorize `codesign` with `security set-key-partition-list -S apple-tool:,apple: -s -k login.keychain-db`. | +| `Warning: unable to build chain to self-signed root` | Apple's intermediate certificate authorities are missing from the keychain, so the signature can't chain up to Apple's root. Sign in to Xcode (**Settings → Accounts**), which installs them, or download them from [Apple PKI](https://www.apple.com/certificateauthority/). | ## Mac App Store @@ -1032,3 +1057,12 @@ the build appears in the **TestFlight** tab of your app record — internal testers can install it without beta review. Note that `--validate-app` does not catch everything processing checks, so a clean upload is only confirmed once processing completes. + +### Troubleshooting + +| Symptom | Cause and fix | +|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ITMS-90889: Invalid Provisioning Profile` after upload | The embedded profile doesn't match the upload. `flet build` pre-checks that the profile covers the app's bundle ID, so this usually means the profile doesn't include the **Apple Distribution** certificate that signed the app — [regenerate the profile](#creating-the-provisioning-profile) selecting that certificate and rebuild. | +| `91109: Invalid package contents` … `com.apple.quarantine` | A file in the package carries the quarantine attribute macOS puts on downloads. `flet build` strips it from everything it packages, so this points to files added after the build — rebuild, or clean with `xattr -cr` before re-packaging. | +| The store build can't read or write files the direct build could | The `app-store` lane enables the mandatory **App Sandbox**: file access is confined to the app's container (`~/Library/Containers/`). Relative paths and `os.getcwd()` already land there; for anything outside, let the user pick the path with `FilePicker` — user-selected locations are granted to a sandboxed app. | +| After a TestFlight install, `flet build macos` fails with `Permission denied` under `build/` | macOS *app relocation*: the installer updates an existing copy with the same bundle ID wherever it finds one — including your local build products — leaving root-owned files (`_MASReceipt`) behind. `sudo rm -rf` the affected `build/macos` directory, and delete dev copies of the app before installing the store build. | From 6948d2db029010b337fa51538e1a5f1e18c3e8ee Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 13:40:32 +0200 Subject: [PATCH 23/28] Publish docs: troubleshooting tables for windows/linux/ios/android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Symptom | Cause-and-fix table format from the macOS page to the other platform pages. Rows are symptom indexes: where a page already documents a failure in depth (android's Extract packages, linux's Wayland section), the row links there instead of duplicating it. - windows: tabulate the Developer Mode item; add the missing C++-workload toolchain error. - linux: linker error (lld), missing -dev packages, libmpv/GStreamer as *runtime* dependencies on users' machines, Wayland positioning. - ios (had none): codesign hang on hidden keychain prompt, no valid signing certificates, profiles disappearing while Xcode runs. - android: sitepackages.zip crash -> Extract packages, keytool not on PATH, manifest merger provider clash. Also link the macOS CI section to the complete build workflow in the CI/CD guide — its snippet shows only the signing-specific steps. --- website/docs/publish/android.md | 8 ++++++++ website/docs/publish/ios.md | 8 ++++++++ website/docs/publish/linux.md | 9 +++++++++ website/docs/publish/macos.md | 6 ++++++ website/docs/publish/windows.md | 18 ++++-------------- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/website/docs/publish/android.md b/website/docs/publish/android.md index 1ef9fe3c1e..ba6b211723 100644 --- a/website/docs/publish/android.md +++ b/website/docs/publish/android.md @@ -907,3 +907,11 @@ help installing and using adb on different platforms. ```bash adb devices ``` + +## Troubleshooting + +| Symptom | Cause and fix | +|---------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Build succeeds but the app crashes on launch; `adb logcat` shows `FileNotFoundError`/`OSError` with a `sitepackages.zip/…` path | The package reads bundled data through `__file__`-relative paths — add it to [Extract packages](#extract-packages). Capture the traceback with the [ADB tips](#adb-tips). | +| `keytool: command not found` when creating the upload keystore | `keytool` ships with the Java JDK (installed with the [Android SDK](#android-sdk) prerequisite) — call it by its full path or add the JDK's `bin` directory to `PATH`. | +| Android manifest merger fails after adding a provider | The `authorities` value clashes with the built-in `${applicationId}.provider` — pick a different one (see [Providers](#providers)). | diff --git a/website/docs/publish/ios.md b/website/docs/publish/ios.md index 2d321e6075..4b8b8deec7 100644 --- a/website/docs/publish/ios.md +++ b/website/docs/publish/ios.md @@ -701,3 +701,11 @@ you'll need to manually trust the developer: - Navigate to **Apps → Your App → TestFlight or App Store Version**. - Your newly uploaded build will initially appear under **Processing** (processing typically takes a few minutes to an hour). - Once processing completes, your build will become available for submission. You can now **submit the app for review**. + +## Troubleshooting + +| Symptom | Cause and fix | +|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Build hangs at `Running Xcode build...` (`codesign` at 0% CPU) | macOS is waiting on a keychain prompt — possibly hidden behind other windows — for permission to use the signing key, common after importing a key from the terminal. Click **Always Allow** on the prompt, or pre-authorize `codesign` with `security set-key-partition-list -S apple-tool:,apple: -s -k login.keychain-db`. | +| `No valid code signing certificates were found` | No signing certificate and matching profile are installed for the selected team — walk through [Signing Certificate](#signing-certificate) and [Provisioning Profile](#provisioning-profile). | +| A manually copied provisioning profile keeps disappearing | A running Xcode process removes profiles copied into `~/Library/MobileDevice/Provisioning Profiles` — quit Xcode before [installing the profile](#provisioning-profile). | diff --git a/website/docs/publish/linux.md b/website/docs/publish/linux.md index 94e7168556..cef8f99b52 100644 --- a/website/docs/publish/linux.md +++ b/website/docs/publish/linux.md @@ -95,3 +95,12 @@ You can check the current session type with: ```bash echo $XDG_SESSION_TYPE # "wayland" or "x11" ``` + +## Troubleshooting + +| Symptom | Cause and fix | +|---------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Build fails with a linker error | The `lld` linker is missing — it is part of the [prerequisites](#prerequisites): `sudo apt install lld` (or your distribution's equivalent) and rebuild. | +| CMake can't find `gtk+-3.0` or other packages | One or more `-dev` [prerequisites](#prerequisites) are missing — install the full list (package names differ on non-Debian distributions). | +| The built app won't start on users' machines: `error while loading shared libraries: libmpv…` (or GStreamer errors) | The [`Audio`](../services/audio/index.md#usage) service and [`Video`](../controls/video/index.md#linux) control link against system libraries — `mpv`/`libmpv` and GStreamer must also be installed on the machine *running* the app, not only the build machine. | +| Window positioning or centering has no effect | The app is running in a Wayland session — see [Window positioning on Wayland](#window-positioning-on-wayland). | diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 26474dc511..d8c18d3a9f 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -796,6 +796,12 @@ the `.app` inside. #### GitHub Actions +:::note +The steps below cover only signing and notarization — graft them onto a +complete build workflow such as the one in the +[CI/CD guide](index.md#github-actions). +::: + A CI runner starts with an empty keychain, so the one-time setup is about getting your certificate and notary credentials into [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets): diff --git a/website/docs/publish/windows.md b/website/docs/publish/windows.md index 57d021bf52..f335f63228 100644 --- a/website/docs/publish/windows.md +++ b/website/docs/publish/windows.md @@ -31,17 +31,7 @@ Builds a Windows application. ## Troubleshooting -### Developer mode - -If you get the below error: - -``` -Building with plugins requires symlink support. - -Please enable Developer Mode in your system settings. Run - start ms-settings:developers -to open settings. -``` - -Then, you need to enable Developer Mode as it indicates. -Follow this [guide](https://stackoverflow.com/a/70994092/1435891) on how to do that. +| Symptom | Cause and fix | +|---------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Building with plugins requires symlink support` | Windows **Developer Mode** is off — run `start ms-settings:developers`, enable it (see [this guide](https://stackoverflow.com/a/70994092/1435891)), and rebuild. | +| `Unable to find suitable Visual Studio toolchain` | The **Desktop development with C++** workload is missing — install it with the Visual Studio Installer (see [Prerequisites](#visual-studio)). | From bf18e878fe14cde58a0a768af5f4fc2b3ac41309 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 13:59:35 +0200 Subject: [PATCH 24/28] macOS publish docs: code annotations for CI and DMG blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the expandable # (N)! annotations (remark-code-annotations, as on the CI/CD guide) where explanations belong to specific lines: - CI workflow: import-codesign-certs (with docs link), APPLE_API_KEY path/runner.temp rationale, optional identity secret (auto-discovery), printf materialization — absorbing the two after-block explainer paragraphs and the tip admonition. - gh secret set: base64-for-binary .p12 vs as-is PEM .p8, interactive prompt note, which values come with the API key. - DMG sign/notarize/staple: the three per-command bullets. Left plain: altool (backslash-continued lines can't carry trailing markers), dmg_settings.py (copied to disk, comments should survive), and short config examples. --- website/docs/publish/macos.md | 69 +++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index d8c18d3a9f..7d9e607cb5 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -763,18 +763,18 @@ dmgbuild -s dmg_settings.py "MyApp" MyApp.dmg Then sign, notarize, and staple the image: ```bash -codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg -xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait -xcrun stapler staple MyApp.dmg +codesign -f --timestamp -s "Developer ID Application: Jane Doe (TEAM123456)" MyApp.dmg # (1)! +xcrun notarytool submit MyApp.dmg --keychain-profile flet-notary --wait # (2)! +xcrun stapler staple MyApp.dmg # (3)! ``` -- `codesign` signs the image itself, with the same **Developer ID - Application** identity the app was signed with. -- `notarytool submit` notarizes the image — same - [credentials](#credentials) as the build; with an API key instead of a - keychain profile, pass `--key`/`--key-id`/`--issuer`. -- `stapler staple` attaches the ticket to the DMG, so the whole download — - not just the app inside — validates offline. +1. Signs the image itself, with the same **Developer ID Application** + identity the app was signed with. +2. Notarizes the image — same [credentials](#credentials) as the build; + with an API key instead of a keychain profile, pass + `--key`/`--key-id`/`--issuer`. +3. Attaches the ticket to the DMG, so the whole download — not just the + app inside — validates offline. The result is the conventional macOS download: users open the image and drag the app into **Applications**. @@ -819,48 +819,55 @@ getting your certificate and notary credentials into while the `.p8` key is already text (PEM) and goes in as-is: ```bash - gh secret set MACOS_CERTIFICATE_P12 --body "$(base64 -i path/to/certificate.p12)" - gh secret set MACOS_CERTIFICATE_PASSWORD # prompts; the export password from step 1 above + gh secret set MACOS_CERTIFICATE_P12 --body "$(base64 -i path/to/certificate.p12)" # (1)! + gh secret set MACOS_CERTIFICATE_PASSWORD # (2)! gh secret set MACOS_SIGNING_IDENTITY --body "Developer ID Application: Jane Doe (TEAM123456)" - gh secret set APPLE_API_KEY_P8 < path/to/AuthKey_ABC123DEFG.p8 - gh secret set APPLE_API_KEY_ID --body "ABC123DEFG" + gh secret set APPLE_API_KEY_P8 < path/to/AuthKey_ABC123DEFG.p8 # (3)! + gh secret set APPLE_API_KEY_ID --body "ABC123DEFG" # (4)! gh secret set APPLE_API_ISSUER --body "12345678-90ab-cdef-1234-567890abcdef" ``` - The last three values come with the - [App Store Connect API key](#creating-a-credential). + 1. Secrets hold text, so the binary `.p12` is stored base64-encoded. + For the web UI, `base64 -i path/to/certificate.p12 | pbcopy` fills + the clipboard. + 2. Prompts for the value — the export password from step 1 above. + 3. Already text (PEM) — goes in as-is, no base64 needed. + 4. This and the next value come with the + [App Store Connect API key](#creating-a-credential). The workflow then imports the certificate into the runner's keychain and exposes the credentials to `flet build`: ```yaml -- uses: apple-actions/import-codesign-certs@v3 +- uses: apple-actions/import-codesign-certs@v3 # (1)! with: p12-file-base64: ${{ secrets.MACOS_CERTIFICATE_P12 }} p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - name: Build, sign and notarize env: - APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 + APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 # (2)! APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - FLET_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + FLET_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} # (3)! run: | - printf '%s' "${{ secrets.APPLE_API_KEY_P8 }}" > "$APPLE_API_KEY" + printf '%s' "${{ secrets.APPLE_API_KEY_P8 }}" > "$APPLE_API_KEY" # (4)! flet build macos --macos-distribution developer-id ``` -The API key is materialized to a file because -[`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key) is a -*path* — `notarytool` reads the key from disk and cannot take its content -inline. Writing it under [`runner.temp`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#runner-context) -(instead of the workspace) keeps the private key out of any artifact upload of the checkout. - -:::tip -Since the imported certificate is the only identity in the runner's -keychain, the `MACOS_SIGNING_IDENTITY` secret and env line can also be -dropped in favor of [auto-discovery](#identity-auto-discovery). -::: +1. Imports the certificate and its private key into a fresh, unlocked + keychain on the runner. View its docs + [here](https://github.com/apple-actions/import-codesign-certs). +2. [`APPLE_API_KEY`](../reference/environment-variables.md#apple_api_key) + is a *path*, not content — `notarytool` reads the key from disk. + [`runner.temp`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#runner-context) + (instead of the workspace) keeps the private key out of any artifact + upload of the checkout. +3. Optional: since the imported certificate is the only identity in the + runner's keychain, this secret and env line can also be dropped in + favor of [auto-discovery](#identity-auto-discovery). +4. Materializes the PEM key from the secret to the path `notarytool` + will read. ### Troubleshooting From 34eede745be1a4244cdca75d3f02004a0f537cfb Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 15:59:44 +0200 Subject: [PATCH 25/28] Revive PyInstaller packaging docs as publish/using-pyinstaller The flet pack command is supported (the planned deprecation was reversed) but its only doc was the archived PyInstaller page, hidden from the sidebar and opening with a danger banner steering readers to flet build. Replace it with a live page under Publishing (sidebar: 'Using PyInstaller', below Web): - Neutral 'How it relates to flet build' comparison table with when-to-pick-which guidance instead of the danger banner. - Content refreshed against today's pack.py: platform-native icon formats (.ico/.icns/.png) replace the old PNG+Pillow advice, the Windows-only ';' add-data separator is gone, and --onedir, --yes, --company-name, --debug-console, --uac-admin, --codesign-identity and --pyinstaller-build-args are covered. Documents that icon and metadata are patched into the embedded Flet viewer too, and the build/dist deletion prompts. - AppVeyor CI section (stale claims, PAT-in-yaml flow, missing images) replaced by a GitHub Actions three-OS matrix. - Troubleshooting table (hidden imports, --debug-console, macOS Gatekeeper) in the same format as the platform pages. - Every option mention links to its anchor in the flet pack CLI reference, per the house style. - Alternative-note admonitions on the macOS/Windows/Linux pages and a pointer in the publish index; archived page removed (nothing linked to it). --- .../flet-cli/src/flet_cli/commands/pack.py | 2 + .../packaging-desktop-app-with-pyinstaller.md | 152 ------------- website/docs/publish/index.md | 5 + website/docs/publish/linux.md | 5 + website/docs/publish/macos.md | 5 + website/docs/publish/using-pyinstaller.md | 208 ++++++++++++++++++ website/docs/publish/windows.md | 5 + website/sidebars.yml | 1 + 8 files changed, 231 insertions(+), 152 deletions(-) delete mode 100644 website/docs/archive/packaging-desktop-app-with-pyinstaller.md create mode 100644 website/docs/publish/using-pyinstaller.md diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/pack.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/pack.py index 28d055e403..556027f86a 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/pack.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/pack.py @@ -15,6 +15,8 @@ class Command(BaseCommand): """ Package a Flet application into a standalone desktop executable or app bundle using PyInstaller. + + Detailed usage guide: https://flet.dev/docs/publish/using-pyinstaller """ def add_arguments(self, parser: argparse.ArgumentParser) -> None: diff --git a/website/docs/archive/packaging-desktop-app-with-pyinstaller.md b/website/docs/archive/packaging-desktop-app-with-pyinstaller.md deleted file mode 100644 index 7ce8e91b72..0000000000 --- a/website/docs/archive/packaging-desktop-app-with-pyinstaller.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: "packaging-desktop-app-with-pyinstaller" ---- - -:::danger[A better way to package is here] - -You can now use `flet build` command to package your Flet app into executable or install bundle for -macOS, Windows and Linux. - -`flet build` does not longer rely on PyInstaller like `flet pack` does, but uses Flutter SDK to produce a fast, offline, fully customizable (your own icons, about dialog and metadata) executable for Windows, Linux and macOS with Python runtime embedded into executable and running in-process. - -[Follow this guide for desktop packaging](../publish/index.md) -::: - -Flet Python app and all its dependencies can be packaged into an executable and user can run it on their computer without installing a Python interpreter or any modules. - -Flet wraps [PyInstaller](https://pyinstaller.org/en/stable/index.html) API to package Flet Python app and all its dependencies into a single package for Windows, macOS and Linux. To create Windows package, PyInstaller must be run on Windows; to build Linux app, it must be run on Linux; and to build macOS app - on macOS. - -Start from installing PyInstaller: - -``` -pip install pyinstaller -``` - -Navigate to the directory where your `.py` file is located and build your app with the following command: - -``` -flet pack your_program.py -``` - -Your bundled Flet app should now be available in `dist` folder. Try running the program to see if it works. - -On macOS: - -``` -open dist/your_program.app -``` - -on Windows: - -``` -dist\your_program.exe -``` - -on Linux: - -``` -dist/your_program -``` - -Now you can just zip the contents of `dist` folder and distribute to your users! They don't need Python or Flet installed to run your packaged program - what a great alternative to Electron! - -By default, an executable/bundle has the same name as a Python script. You can change it with `--name` argument: - -``` -flet pack your_program.py --name bundle_name -``` - -## Customizing package icon - -Default bundle app icon is diskette which might be confusing for younger developers missed those ancient times when [floppy disks](https://en.wikipedia.org/wiki/Floppy_disk) were used to store computer data. - -You can replace the icon with your own by adding `--icon` argument: - -``` -flet pack your_program.py --icon -``` - -PyInstaller will convert provided PNG to a platform specific format (`.ico` for Windows and `.icns` for macOS), but you need to install [Pillow](https://pillow.readthedocs.io/en/stable/) module for that: - -``` -pip install pillow -``` - -## Packaging assets - -Your Flet app can include [assets](../cookbook/assets.md). Provided app assets are in `assets` folder next to `your_program.py` they can be added to an application package with `--add-data` argument, on macOS/Linux: - -``` -flet pack your_program.py --add-data "assets:assets" -``` - -On Windows `assets;assets` must be delimited with `;`: - -``` -flet pack your_program.py --add-data "assets;assets" -``` - -## Customizing macOS bundle - -macOS bundle details can be customized with the following `flet pack` macOS-specific arguments: - -* `--product-name` - display name of macOS bundle, shown in Dock, Activity Monitor, About dialog. -* `--product-version` - bundle version shown in "About" dialog. -* `--copyright` - copyright notice shown in "About" dialog. -* `--bundle-id` unique bundle ID. - -
Flet app bundle about
- -## Customizing Windows executable metadata - -Windows executable "Details" properties dialog can be customized with the following `flet pack` arguments: - -* `--product-name` - "Product name" field. -* `--product-version` - "Product version" field. -* `--file-version` - "File version" field. -* `--file-description` - "File description" field, also program display name in Task Manager. -* `--copyright` - "Copyright" field. - -## Using CI for multi-platform packaging - -To create an app package with PyInstaller for specific OS it must be run on that OS. - -If you don't have an access to Mac or PC you can bundle your app for all three platforms with [AppVeyor](https://www.appveyor.com) - Continuous Integration service for Windows, Linux and macOS. In short, Continuous Integration (CI) is an automated process of building, testing and deploying (Continuous Delivery - CD) application on every push to a repository. - -AppVeyor is free for open source projects hosted on GitHub, GitLab and Bitbucket. To use AppVeyor, push your app to a repository within one of those source-control providers. - -:::note -AppVeyor is the company behind Flet. -::: - -To get started with AppVeyor [sign up for a free account](https://ci.appveyor.com/signup). - -Click "New project" button, authorize AppVeyor to access your GitHub, GitLab or Bitbucket account, choose a repository with your program and create a new project. - -Now, to configure packaging of your app for Windows, Linux and macOS, add file with [the following contents](https://github.com/flet-dev/python-ci-example/blob/main/appveyor.yml) into the root of your repository `appveyor.yml`. `appveyor.yml` is a build configuration file, or CI workflow, describing build, test, packaging and deploy commands that must be run on every commit. - -:::note -You can just fork [flet-dev/python-ci-example](https://github.com/flet-dev/python-ci-example) repository and customize it to your needs. -::: - -When you push any changes to GitHub repository, AppVeyor will automatically start a new build: - -> Missing image omitted: AppVeyor CI Flet Python project - -What that [CI workflow](https://ci.appveyor.com/project/flet-dev/python-ci-example) does on every push to the repository: - -* Clones the repository to a clean virtual machine. -* Installs app dependencies using `pip`. -* Runs `flet pack` to package Python app into a bundle for **Windows**, **macOS** and **Ubuntu**. -* Zip/Tar app bundles and uploads them to ["Artifacts"](https://ci.appveyor.com/project/flet-dev/python-ci-example/build/job/g2j2lhstv04eyxcm/artifacts). -* Uploads app bundles to [**GitHub releases**](https://github.com/flet-dev/python-ci-example/releases) when a new tag is pushed. Just push a new tag to make a release! - -:::note[GITHUB_TOKEN] -`GITHUB_TOKEN` in `appveyor.yml` is a GitHub Personal Access Token (PAT) used by AppVeyor to publish created packages to repository "Releases". You need to generate your own token and replace it in `appveyor.yml`. Login to your GitHub account and navigate to [Personal access token](https://github.com/settings/tokens) page. Click "Generate new token" and select "public_repo" or "repo" scope for public or private repository respectively. Copy generated token to a clipboard and return to AppVeyor Portal. Navigate to [Encrypt configuration data](https://ci.appveyor.com/tools/encrypt) page and paste token to "Value to encrypt" field, click "Encrypt" button. Put encrypted value under `GITHUB_TOKEN` in your `appveyor.yml`. -::: - -Configure AppVeyor for your Python project, push a new tag to a repository and "automagically" get desktop bundle for all three platforms in GitHub releases! 🎉 - -> Missing image omitted: AppVeyor CI Flet GitHub releases - -In addition to [GitHub Releases](https://www.appveyor.com/docs/deployment/github/), you can also configure releasing of artifacts to [Amazon S3 bucket](https://www.appveyor.com/docs/deployment/amazon-s3/) or [Azure Blob storage](https://www.appveyor.com/docs/deployment/azure-blob/). diff --git a/website/docs/publish/index.md b/website/docs/publish/index.md index 59d780c283..4d171841e1 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -11,6 +11,11 @@ import Tabs from '@theme/Tabs'; Flet CLI provides the [`flet build`](../cli/flet-build.md) command to package a Flet app into a standalone executable or installable package for distribution. +:::info[Alternative: flet pack] +For desktop targets, a PyInstaller-based route is also supported — +see [`flet pack`](using-pyinstaller.md). +::: + ## Prerequisites ### Platform matrix diff --git a/website/docs/publish/linux.md b/website/docs/publish/linux.md index cef8f99b52..bc25954998 100644 --- a/website/docs/publish/linux.md +++ b/website/docs/publish/linux.md @@ -9,6 +9,11 @@ This guide provides detailed Linux-specific information. Complementary and more general information is available [here](index.md). ::: +:::info[Alternative: flet pack] +For a quicker, PyInstaller-based way to package desktop apps — without the +build-toolchain prerequisites below — see [`flet pack`](using-pyinstaller.md). +::: + ## Prerequisites Flet uses [Flutter](https://flutter.dev) to build Linux apps. Compiling the app diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 7d9e607cb5..b91f01dc45 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -12,6 +12,11 @@ This guide provides detailed macOS-specific information. Complementary and more general information is available [here](index.md). ::: +:::info[Alternative: flet pack] +For a quicker, PyInstaller-based way to package desktop apps — no Flutter +toolchain required — see [`flet pack`](using-pyinstaller.md). +::: + ## Prerequisites ### Rosetta 2 diff --git a/website/docs/publish/using-pyinstaller.md b/website/docs/publish/using-pyinstaller.md new file mode 100644 index 0000000000..0acd39af4c --- /dev/null +++ b/website/docs/publish/using-pyinstaller.md @@ -0,0 +1,208 @@ +--- +title: "Packaging with flet pack" +--- + +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; + +Instructions for packaging a Flet app into a standalone desktop executable +with [`flet pack`](../cli/flet-pack.md) — a lightweight, +[PyInstaller](https://pyinstaller.org/en/stable/)-based alternative to +[`flet build`](../cli/flet-build.md). Users can run the packaged app without +installing a Python interpreter or any modules. + +## How it relates to `flet build` + +Both commands are supported — they occupy different points on the +speed-vs-control curve: + +| | `flet pack` | `flet build` | +|------------------|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| Targets | Desktop only: Windows, macOS, Linux | Desktop, mobile (Android/iOS), and [web](web/index.md) | +| Toolchain | [PyInstaller](https://pyinstaller.org/en/stable/) | [Flutter SDK](index.md#flutter-sdk) (auto-installed) | +| How the app runs | Your Python code alongside the prebuilt Flet desktop client | Flutter-compiled app with Python embedded, running in-process | +| Build time | Fast — no native compilation | Slower — a full Flutter build | +| Customization | Icon and executable/bundle metadata | Everything: icons, splash, [build template](index.md#build-template), [signing and store packaging](macos.md#code-signing) | + +Reach for `flet pack` when you want a desktop artifact quickly; use +[`flet build`](index.md) when you target mobile or web, or need deeper +customization. + +Like PyInstaller itself, `flet pack` is not a cross-compiler: run it on each +OS you target — [CI](#packaging-in-ci) makes this painless. + +## Prerequisites + +[PyInstaller](https://pyinstaller.org/en/stable/) powers the packaging and +must be installed first: + +```bash +pip install pyinstaller +``` + +## Packaging + +From the directory containing your program, run: + +```bash +flet pack your_program.py +``` + +The packaged app lands in the `dist` folder +([`--distpath`](../cli/flet-pack.md#--distpath) changes that): a single-file executable +on Windows and Linux, or a `.app` bundle on macOS. Pass +[`--onedir`](../cli/flet-pack.md#--onedir) for a one-folder bundle instead of a +single file (macOS always produces a `.app` bundle). Try running it: + + + +```bash +open dist/your_program.app +``` + + +```bash +dist\your_program.exe +``` + + +```bash +dist/your_program +``` + + + +By default the executable or bundle is named after the Python script; change +it with [`--name`](../cli/flet-pack.md#--name): + +```bash +flet pack your_program.py --name bundle_name +``` + +If non-empty `build` or `dist` folders remain from a previous run, +`flet pack` asks before deleting them — pass [`--yes`](../cli/flet-pack.md#--yes) to skip all prompts +(useful in [CI](#packaging-in-ci)). + +To distribute, zip the contents of the `dist` folder and hand it to your +users — they don't need Python or Flet installed to run it. + +## Custom icon + +Set the icon with [`--icon`](../cli/flet-pack.md#--icon): + +```bash +flet pack your_program.py --icon your-icon.ico +``` + +Provide the icon in the target platform's native format: `.ico` on Windows, +`.icns` on macOS, and `.png` on Linux. It is applied both to the outer +executable and to the embedded Flet viewer, so the app window, Dock/taskbar +entries, and the executable itself all match. + +## Including assets + +If your app uses [assets](../cookbook/assets.md), include them with +[`--add-data`](../cli/flet-pack.md#--add-data), in the form `source:destination`: + +```bash +flet pack your_program.py --add-data "assets:assets" +``` + +The option can be repeated to include multiple files or folders. + +## Executable and bundle metadata + +Details shown by the OS about your app can be customized. + +On macOS — the "About" dialog and Dock/Activity Monitor entries of the +bundle: + +- [`--product-name`](../cli/flet-pack.md#--product-name) — display name of the bundle. +- [`--product-version`](../cli/flet-pack.md#--product-version) — version shown in the "About" dialog. +- [`--copyright`](../cli/flet-pack.md#--copyright) — copyright notice shown in the "About" dialog. +- [`--bundle-id`](../cli/flet-pack.md#--bundle-id) — unique bundle identifier. + +
Flet app bundle about
+ +On Windows — the executable's "Details" properties dialog: + +- [`--product-name`](../cli/flet-pack.md#--product-name) — "Product name" field. +- [`--product-version`](../cli/flet-pack.md#--product-version) — "Product version" field. +- [`--file-version`](../cli/flet-pack.md#--file-version) — "File version" field, in `n.n.n.n` format. +- [`--file-description`](../cli/flet-pack.md#--file-description) — "File description" field, also the program's + display name in Task Manager. +- [`--company-name`](../cli/flet-pack.md#--company-name) — "Company name" field. +- [`--copyright`](../cli/flet-pack.md#--copyright) — "Copyright" field. + +Like the icon, the metadata is embedded into both the outer executable and +the Flet viewer inside it. + +## More options + +- [`--hidden-import`](../cli/flet-pack.md#--hidden-import) — add modules that are imported dynamically and + therefore missed by PyInstaller's static analysis. +- [`--add-binary`](../cli/flet-pack.md#--add-binary) — bundle additional binary files. +- [`--debug-console`](../cli/flet-pack.md#--debug-console) `1` — keep a console window with Python output open, + for troubleshooting the packaged app. +- [`--uac-admin`](../cli/flet-pack.md#--uac-admin) — request elevated permissions on start (Windows). +- [`--codesign-identity`](../cli/flet-pack.md#--codesign-identity) — sign the app bundle (macOS). +- [`--pyinstaller-build-args`](../cli/flet-pack.md#--pyinstaller-build-args) — pass any other argument straight through to + the underlying `pyinstaller` command. + +The full option list is in the [`flet pack` reference](../cli/flet-pack.md). + +## Packaging in CI + +Since each OS must package its own artifact, a CI matrix produces all three +in one go: + +```yaml +name: Pack Flet App + +on: + push: + workflow_dispatch: + +jobs: + pack: + name: Pack on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: pip install flet pyinstaller # (1)! + + - name: Pack app + run: flet pack your_program.py --yes # (2)! + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: your_program-${{ matrix.os }} + path: dist +``` + +1. Install your app's own dependencies here as well — for example + `pip install -r requirements.txt`. +2. [`--yes`](../cli/flet-pack.md#--yes) skips the interactive prompts about deleting previous `build` + and `dist` folders. + +## Troubleshooting + +| Symptom | Cause and fix | +|-------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ModuleNotFoundError` inside the packaged app only | The module is imported dynamically, so PyInstaller's static analysis missed it — repackage with [`--hidden-import`](../cli/flet-pack.md#--hidden-import) ``. | +| The packaged app exits or misbehaves with no visible error | Repackage with [`--debug-console`](../cli/flet-pack.md#--debug-console) `1` to get a console window showing Python output and tracebacks. | +| macOS: `"App" is damaged and can't be opened` on other Macs | Downloaded apps must be signed and notarized for Gatekeeper to run them. Sign the bundle with [`--codesign-identity`](../cli/flet-pack.md#--codesign-identity), then notarize and staple it — the [Notarization](macos.md#notarization) section explains the concepts and commands, which apply to any signed app. `flet build macos` automates this entire chain. | diff --git a/website/docs/publish/windows.md b/website/docs/publish/windows.md index f335f63228..c6a0b63e4c 100644 --- a/website/docs/publish/windows.md +++ b/website/docs/publish/windows.md @@ -9,6 +9,11 @@ This guide provides detailed Windows-specific information. Complementary and more general information is available [here](index.md). ::: +:::info[Alternative: flet pack] +For a quicker, PyInstaller-based way to package desktop apps — no Visual +Studio or Flutter toolchain required — see [`flet pack`](using-pyinstaller.md). +::: + ## Prerequisites ### Visual Studio diff --git a/website/sidebars.yml b/website/sidebars.yml index 206c97b79d..c72961a81c 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -63,6 +63,7 @@ docs: Hosting: - Cloudflare: publish/web/static-website/hosting/cloudflare.md - GitHub Pages: publish/web/static-website/hosting/github-pages.md + Using PyInstaller: publish/using-pyinstaller.md Extending Flet: - extend/user-extensions.md - extend/built-in-extensions.md From 5e1b2452409c07153b6422af241fb73816359b17 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 17:51:16 +0200 Subject: [PATCH 26/28] update --- CHANGELOG.md | 8 ++- .../flet-cli/src/flet_cli/commands/build.py | 4 +- .../src/flet_cli/commands/build_base.py | 10 +-- website/docs/publish/ios.md | 27 +++---- website/docs/publish/macos.md | 5 ++ .../docs/updates/breaking-changes/index.md | 6 ++ .../ios-per-method-signing-precedence.md | 72 +++++++++++++++++++ website/docs/updates/release-notes.md | 4 ++ website/sidebars.yml | 2 + 9 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 website/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 955f84bfb3..694c4cc323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ ### New features -* **macOS code signing and notarization in `flet build macos`.** Configure a signing identity — `--macos-signing-identity`, `[tool.flet.macos.signing].identity`, or `FLET_MACOS_SIGNING_IDENTITY` — and the built app is Developer ID-signed for distribution: every bundled Mach-O (the embedded Python runtime, all native modules from your dependencies, helper executables) is discovered by content and signed "inside out" with the hardened runtime and a secure timestamp, entitlements are applied to the app bundle, helper executables, and nested helper bundles, and the result is verified with `codesign --verify --deep --strict` plus a per-binary coverage check. Select the distribution lane with `--macos-distribution developer-id` (or `[tool.flet.macos.signing].distribution`) to submit the app to the Apple notary service — authenticating via a `notarytool` keychain profile or `APPLE_API_KEY`/`APPLE_API_KEY_ID`/`APPLE_API_ISSUER` App Store Connect API key environment variables — and staple the ticket, producing an app that opens cleanly on macOS 15+ where downloaded unsigned apps are effectively blocked. Apple's per-file notarization log is printed on rejection, a typo'd identity fails fast with the list of valid keychain identities (an expired or revoked certificate is called out by name and status instead of appearing missing), and without a configured identity a plain build keeps its ad-hoc signature — while the `developer-id` and `app-store` distribution lanes, which require an identity anyway, scope resolution to the certificate type Apple accepts and **auto-discover** it when the keychain holds exactly one (so a bare team ID also stays unambiguous per lane). The whole signing configuration is additionally **validated before the build starts** — a typo'd or expired identity, missing notary credentials, or a missing store prerequisite fails in seconds instead of after the multi-minute Flutter build. The default macOS entitlements now include `com.apple.security.cs.allow-unsigned-executable-memory`, required for `ctypes`/`cffi` callbacks on Intel Macs under the hardened runtime. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing) and [Notarization](https://flet.dev/docs/publish/macos#notarization) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. - -* **Mac App Store / TestFlight builds in `flet build macos`.** With `--macos-distribution app-store` (or `[tool.flet.macos.signing].distribution`), the build produces a store-ready artifact verified end-to-end against App Store Connect and TestFlight: the app is signed with your *Apple Distribution* certificate — sandboxed, without the hardened runtime, with the store-mandated `com.apple.application-identifier`/`com.apple.developer.team-identifier` entitlements derived from your certificate and bundle id, all `com.apple.security.cs.*` hardened-runtime exceptions stripped, and helper executables carrying the sandbox `inherit` pair — your Mac App Store provisioning profile (`--macos-provisioning-profile` / `[tool.flet.macos.signing].provisioning_profile` / `FLET_MACOS_PROVISIONING_PROFILE`) is embedded, cross-checked against the bundle id before signing, and scrubbed of the `com.apple.quarantine` attribute browser downloads carry (App Store Connect processing rejects quarantined package contents with error 91109 — a check `altool --validate-app` does not perform), and the result is packaged into a `.pkg` signed with your installer certificate (`--macos-installer-identity` / `[tool.flet.macos.signing].installer_identity` / `FLET_MACOS_INSTALLER_IDENTITY`) and verified with `pkgutil`. Misconfiguration fails in seconds, before the build starts: a missing installer certificate, profile, or `LSApplicationCategoryType` (App Store validation rejects without it — set it via `[tool.flet.macos.info]`) and a profile/bundle-id mismatch (the slow-to-surface ITMS-90889) are all reported with the exact setting to fix. Because the lane is a single selector rather than per-lane toggles, conflicting lanes are inexpressible, and one `pyproject.toml` can hold both a `developer-id` and an `app-store` configuration — their lane-specific settings don't collide — with the CLI flipping between them per build. See the updated [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#4543](https://github.com/flet-dev/flet/issues/4543)) by @ndonkoHenri. +* **macOS code signing, notarization, and Mac App Store builds in `flet build macos`.** Select a distribution lane with `--macos-distribution` (or `[tool.flet.macos.signing].distribution`): `developer-id` signs every bundled binary with your Developer ID certificate — hardened runtime, entitlements, secure timestamp — then notarizes and staples the app for direct distribution, while `app-store` produces a sandboxed app with your provisioning profile embedded, packaged into an installer-signed `.pkg` ready for App Store Connect and TestFlight. Signing identities are auto-discovered from the keychain when not explicitly configured (via CLI options, `pyproject.toml` — including per-lane `[tool.flet.macos.signing.]` subtables — or environment variables), and the whole configuration is validated before the build starts, so a typo'd identity, expired certificate, or missing store prerequisite fails in seconds instead of after the full build. See the new [Code signing](https://flet.dev/docs/publish/macos#code-signing), [Notarization](https://flet.dev/docs/publish/macos#notarization), and [Mac App Store](https://flet.dev/docs/publish/macos#mac-app-store) docs ([#2347](https://github.com/flet-dev/flet/issues/2347), [#4543](https://github.com/flet-dev/flet/issues/4543), [#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. ### Improvements @@ -15,6 +13,10 @@ * Fix `flet publish`'s documented `[tool.flet.web].route_url_strategy` and `FLET_WEB_ROUTE_URL_STRATEGY` fallbacks being unreachable: the `--route-url-strategy` option's argparse default of `"path"` made the CLI value always win. The default now applies at the end of the resolution chain, as in `flet build`, by @ndonkoHenri. * Fix the generated `macos/Runner/*.entitlements` files being rejected by `codesign` with `AMFIUnserializeXML: syntax error` when used directly for re-signing: the template emitted boolean values as self-closing tags with a space (``), which Xcode and `plutil` accept but codesign's stricter AMFI plist parser does not. The templates now emit ``, and `flet build`'s own signing step additionally normalizes any entitlements file through `plistlib` before use, so plist formatting can never break signing ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. +### Breaking changes + +* iOS per-method signing settings in `[tool.flet.ios.export_methods.]` (`provisioning_profile`, `signing_certificate`, `export_options`, `team_id`) now override the flat `[tool.flet.ios]` keys instead of being overridden by them. Previously a per-method value silently lost to the generic one, making the subtables useless whenever a flat key was also set; the flat key is now the shared fallback across methods — the same rule as the macOS `[tool.flet.macos.signing.]` subtables. See the [iOS per-method signing precedence](/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence) guide ([#6702](https://github.com/flet-dev/flet/pull/6702)) by @ndonkoHenri. + ## 0.86.1 ### Improvements diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 9fe1c7fa0b..9548715c49 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -294,9 +294,7 @@ def macos_signing_setting( Precedence: CLI option > `[tool.flet.macos.signing.]` subtable > flat `[tool.flet.macos.signing]` key > environment - variable. The lane subtable deliberately beats the flat key — a - per-lane value that lost to a generic one would be pointless - (iOS's `export_methods` gets this backwards). + variable. Args: cli_value: The already-parsed CLI option value, or None. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index bffd6e56ed..0656af4d57 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -1293,26 +1293,26 @@ def _xml_attr_value(v): ios_provisioning_profile = ( self.options.ios_provisioning_profile - or self.get_pyproject("tool.flet.ios.provisioning_profile") or ios_export_method_opts.get("provisioning_profile") + or self.get_pyproject("tool.flet.ios.provisioning_profile") ) ios_signing_certificate = ( self.options.ios_signing_certificate - or self.get_pyproject("tool.flet.ios.signing_certificate") or ios_export_method_opts.get("signing_certificate") + or self.get_pyproject("tool.flet.ios.signing_certificate") ) ios_export_options = ( - self.get_pyproject("tool.flet.ios.export_options") - or ios_export_method_opts.get("export_options") + ios_export_method_opts.get("export_options") + or self.get_pyproject("tool.flet.ios.export_options") or {} ) ios_team_id = ( self.options.ios_team_id - or self.get_pyproject("tool.flet.ios.team_id") or ios_export_method_opts.get("team_id") + or self.get_pyproject("tool.flet.ios.team_id") ) if ( diff --git a/website/docs/publish/ios.md b/website/docs/publish/ios.md index 4b8b8deec7..b212b4f72d 100644 --- a/website/docs/publish/ios.md +++ b/website/docs/publish/ios.md @@ -120,8 +120,8 @@ The developer team ID to include in export options. Its value is determined in the following order of precedence: 1. [`--ios-team-id`](../cli/flet-build.md#--ios-team-id) -2. `[tool.flet.ios].team_id` -3. `[tool.flet.ios.export_methods."EXPORT_METHOD"].team_id` +2. `[tool.flet.ios.export_methods.EXPORT_METHOD].team_id` +3. `[tool.flet.ios].team_id` ##### Example @@ -184,8 +184,8 @@ Before creating a development or distribution certificate, you need a **CSR (Cer Its value is determined in the following order of precedence: 1. [`--ios-signing-certificate`](../cli/flet-build.md#--ios-signing-certificate) -2. `[tool.flet.ios].signing_certificate` -3. `[tool.flet.ios.export_methods."EXPORT_METHOD"].signing_certificate` +2. `[tool.flet.ios.export_methods.EXPORT_METHOD].signing_certificate` +3. `[tool.flet.ios].signing_certificate` #### Example @@ -293,8 +293,8 @@ for profile in ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision; Its value is determined in the following order of precedence: 1. [`--ios-provisioning-profile`](../cli/flet-build.md#--ios-provisioning-profile) -2. `[tool.flet.ios].provisioning_profile` -3. `[tool.flet.ios.export_methods."EXPORT_METHOD"].provisioning_profile` +2. `[tool.flet.ios.export_methods.EXPORT_METHOD].provisioning_profile` +3. `[tool.flet.ios].provisioning_profile` The profile must match your [Bundle ID](index.md#bundle-id). @@ -332,8 +332,8 @@ and find the section titled **"Available keys for -exportOptionsPlist"**. Its value is determined in the following order of precedence: -1. `[tool.flet.ios].export_options` (if set, per-method export options are ignored) -2. `[tool.flet.ios.export_methods."EXPORT_METHOD"].export_options` (see [export methods](#export-methods)) +1. `[tool.flet.ios.export_methods.EXPORT_METHOD].export_options` (see [export methods](#export-methods)) +2. `[tool.flet.ios].export_options` 3. `{}` (no extra keys) ##### Supported value forms @@ -434,8 +434,9 @@ export_method = "debugging" Signing settings can be configured individually per [export method](#export-method). -Per-method values are used only when the corresponding top-level -`[tool.flet.ios]` setting is not set. The method key must match the `export_method` value exactly. +A per-method value overrides the corresponding top-level `[tool.flet.ios]` +setting when its method is built — the top-level key is the shared +fallback. The method key must match the `export_method` value exactly. Supported keys (same as the top-level settings): @@ -449,17 +450,17 @@ Supported keys (same as the top-level settings): ```toml -[tool.flet.ios.export_methods."debugging"] +[tool.flet.ios.export_methods.debugging] provisioning_profile = "debugging com.mycompany.example-app" signing_certificate = "Apple Development" -[tool.flet.ios.export_methods."release-testing"] +[tool.flet.ios.export_methods.release-testing] provisioning_profile = "release-testing com.mycompany.example-app" team_id = "ABCDEFE234" signing_certificate = "Apple Distribution" export_options = { uploadSymbols = false } -[tool.flet.ios.export_methods."app-store-connect"] +[tool.flet.ios.export_methods.app-store-connect] provisioning_profile = "app-store-connect com.mycompany.example-app" team_id = "ABCDEFE234" signing_certificate = "Apple Distribution" diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index b91f01dc45..362a0fc027 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -816,6 +816,11 @@ getting your certificate and notary credentials into right-click the certificate → **Export…** and save it in the `.p12` format, protected by an export password ([Apple's guide](https://support.apple.com/guide/keychain-access/import-and-export-keychain-items-kyca35961/mac)). + The `.cer` file downloadable from the + [developer portal](https://developer.apple.com/account/resources/certificates/list) + is not a substitute: it holds only the public certificate, while the private + key exists solely in the keychain of the Mac that created the + certificate request — hence the export from Keychain Access there. 2. Store the secrets — in the repository's **Settings → Secrets and variables → Actions**, or with the [`gh` CLI](https://cli.github.com/manual/gh_secret_set). Secrets hold diff --git a/website/docs/updates/breaking-changes/index.md b/website/docs/updates/breaking-changes/index.md index 007e668502..549ceaaf20 100644 --- a/website/docs/updates/breaking-changes/index.md +++ b/website/docs/updates/breaking-changes/index.md @@ -22,6 +22,12 @@ This page lists the guides created for each release. The following guides are available. They're sorted by release, with the most recent release first. Each guide explains the change, the reason for it, and how to migrate your code. +### Released in Flet 1.0.0 + +#### Breaking changes + +- [iOS: per-method signing settings now override top-level ones](/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence) + ### Released in Flet 0.86.0 #### Breaking changes diff --git a/website/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence.md b/website/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence.md new file mode 100644 index 0000000000..6864009635 --- /dev/null +++ b/website/docs/updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence.md @@ -0,0 +1,72 @@ +--- +title: "iOS: per-method signing settings now override top-level ones" +--- + +# iOS: per-method signing settings now override top-level ones + +:::note +This guide is accurate as of Flet 1.0.0. Later releases might add new APIs or +additional migration paths. + +The [breaking changes and deprecations index](../index.md) lists the guides created for each release. +::: + +## Summary + +Flet 1.0.0 flips the precedence between the top-level `[tool.flet.ios]` signing +keys and their per-method counterparts in +[`[tool.flet.ios.export_methods.]`](../../../publish/ios.md#export-methods) +subtables. This affects four settings: `provisioning_profile`, +`signing_certificate`, `export_options`, and `team_id`. + +- **Before**: the top-level key won — a per-method value was used only when no + top-level key was set, making the subtables useless whenever both were + configured. +- **Now**: the per-method value wins for the export method being built; the + top-level key is the shared fallback across methods. CLI options still beat + both. + +This matches the rule used by the macOS +[per-lane signing subtables](../../../publish/macos.md#per-lane-settings). + +## Symptoms + +No error is raised — the change is silent. An `ipa` build where **both** forms +of the same key are set (for example, a top-level `provisioning_profile` *and* +one inside an `export_methods` subtable) is now signed with the per-method +value where it previously used the top-level one, which can surface later as a +profile or certificate mismatch when installing or uploading the build. + +Projects that use only one of the two forms are unaffected. + +## Migration guide + +If a per-method value was shadowed by a top-level key and you relied on the +top-level one winning, remove the entry you don't want from the +`export_methods` subtable — a per-method entry now means "use this when +building this method": + +```toml +[tool.flet.ios] +# shared fallback for all export methods +signing_certificate = "Apple Development" + +[tool.flet.ios.export_methods.app-store-connect] +# overrides the fallback for app-store-connect builds only +signing_certificate = "Apple Distribution" +``` + +### No action needed for + +- Projects that set signing keys in only one place — top-level *or* + per-method. +- Builds driven entirely by CLI options, which keep the highest precedence. + +## Timeline + +- Changed in: `1.0.0` + +## References + +- Docs: [iOS export methods](../../../publish/ios.md#export-methods) +- Release notes: [Flet 1.0.0](../../release-notes.md) diff --git a/website/docs/updates/release-notes.md b/website/docs/updates/release-notes.md index a39a075677..04e613ec14 100644 --- a/website/docs/updates/release-notes.md +++ b/website/docs/updates/release-notes.md @@ -8,6 +8,10 @@ This page links release announcements, changelogs, and migration notes for Flet ## Stable releases +### 1.0.x + +- 1.0.0: Announcement, [Changelog](https://github.com/flet-dev/flet/blob/main/CHANGELOG.md#100), [Breaking changes and deprecations](breaking-changes/index.md#released-in-flet-100) + ### 0.86.x - 0.86.0: [Announcement](/blog/flet-v-0-86-release-announcement), [Changelog](https://github.com/flet-dev/flet/blob/main/CHANGELOG.md#0860), [Breaking changes and deprecations](breaking-changes/index.md#released-in-flet-0860) diff --git a/website/sidebars.yml b/website/sidebars.yml index c72961a81c..d048932f76 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -72,6 +72,8 @@ docs: Release notes: updates/release-notes.md Breaking changes and deprecations: _index: updates/breaking-changes/index.md + v1.0.0: + "iOS: per-method signing settings now override top-level ones": updates/breaking-changes/v1-0-0/ios-per-method-signing-precedence.md v0.86.0: App files ship unpacked in a read-only bundle; storage dirs reworked: updates/breaking-changes/v0-86-0/app-files-unpacked-read-only-bundle.md "Android: site-packages ship zipped; some packages need extract_packages": updates/breaking-changes/v0-86-0/android-extract-packages.md From 6637e16e53804a39af7bbe892dae63f36d368d4c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 24 Jul 2026 18:56:45 +0200 Subject: [PATCH 27/28] update docs [skip ci] --- website/docs/publish/linux.md | 2 +- website/docs/publish/macos.md | 2 +- website/docs/publish/windows.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website/docs/publish/linux.md b/website/docs/publish/linux.md index bc25954998..d11c44d279 100644 --- a/website/docs/publish/linux.md +++ b/website/docs/publish/linux.md @@ -10,7 +10,7 @@ Complementary and more general information is available [here](index.md). ::: :::info[Alternative: flet pack] -For a quicker, PyInstaller-based way to package desktop apps — without the +For a PyInstaller-based way to package desktop apps — without the build-toolchain prerequisites below — see [`flet pack`](using-pyinstaller.md). ::: diff --git a/website/docs/publish/macos.md b/website/docs/publish/macos.md index 362a0fc027..2f8a8c3099 100644 --- a/website/docs/publish/macos.md +++ b/website/docs/publish/macos.md @@ -13,7 +13,7 @@ Complementary and more general information is available [here](index.md). ::: :::info[Alternative: flet pack] -For a quicker, PyInstaller-based way to package desktop apps — no Flutter +For a PyInstaller-based way to package desktop apps — no Flutter toolchain required — see [`flet pack`](using-pyinstaller.md). ::: diff --git a/website/docs/publish/windows.md b/website/docs/publish/windows.md index c6a0b63e4c..024242f188 100644 --- a/website/docs/publish/windows.md +++ b/website/docs/publish/windows.md @@ -10,7 +10,7 @@ Complementary and more general information is available [here](index.md). ::: :::info[Alternative: flet pack] -For a quicker, PyInstaller-based way to package desktop apps — no Visual +For a PyInstaller-based way to package desktop apps — no Visual Studio or Flutter toolchain required — see [`flet pack`](using-pyinstaller.md). ::: From 34993644d26a0b66c48f6a075ca43c2ae00b9e33 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 28 Jul 2026 10:58:27 +0200 Subject: [PATCH 28/28] Address Copilot review findings on signing preflight and credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject [tool.flet.macos.signing.none]: it is in MACOS_DISTRIBUTIONS, so the misnamed-subtable validation accepted it, yet no lookup ever reads a none subtable — exactly the silent ignore the validation exists to prevent. Tailored error points at the flat table instead. - Preflight now rejects an explicit ad-hoc identity ('-') for the developer-id/app-store lanes: resolve_identity('-') short-circuits before type scoping, so the misconfiguration previously surfaced only after the full Flutter build, at notarization/upload. - NotaryCredentials.as_args() raises MacOSSigningError instead of asserting the API-key triple: python -O strips asserts and would emit a notarytool call with silently missing arguments. - sign_app() fails fast when the xattr -cr sweep errors instead of proceeding with quarantine attributes still present. - flet publish now lowercases the web renderer/route strategy on the resolved value, matching flet build: a pyproject value like "CanvasKit" no longer reaches the WebRenderer enum unlowered. Tests 130 -> 132: none-subtable rejection, ad-hoc preflight rejection, incomplete-credentials raise. --- .../flet-cli/src/flet_cli/commands/build.py | 24 +++++++++++++++++-- .../flet-cli/src/flet_cli/commands/publish.py | 21 +++++++++------- .../flet-cli/src/flet_cli/utils/macos_sign.py | 16 +++++++++++-- .../tests/test_build_macos_signing.py | 24 +++++++++++++++++++ .../flet-cli/tests/test_macos_sign.py | 5 ++++ 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 9548715c49..1009adedb8 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -272,13 +272,24 @@ def resolve_macos_distribution(self) -> str: f"`[tool.flet.macos.signing].distribution`: " f"{', '.join(self.MACOS_DISTRIBUTIONS)}.", ) + lane_names = [d for d in self.MACOS_DISTRIBUTIONS if d != "none"] for key, value in (self.get_pyproject("tool.flet.macos.signing") or {}).items(): - if isinstance(value, dict) and key not in self.MACOS_DISTRIBUTIONS: + if not isinstance(value, dict): + continue + if key == "none": + self.cleanup( + 1, + "`[tool.flet.macos.signing.none]` is not a lane subtable " + "— the `none` lane reads no signing settings, so it " + "would be silently ignored. Put shared values directly " + "on `[tool.flet.macos.signing]`.", + ) + elif key not in lane_names: self.cleanup( 1, f"Unknown lane subtable `[tool.flet.macos.signing.{key}]` " f"— it would be silently ignored. Valid lane names: " - f"{', '.join(d for d in self.MACOS_DISTRIBUTIONS if d != 'none')}.", + f"{', '.join(lane_names)}.", ) return distribution @@ -382,6 +393,15 @@ def preflight_macos_signing(self) -> None: if not identity and distribution == "none": return + if distribution != "none" and (identity or "").strip() == "-": + self.cleanup( + 1, + f"The ad-hoc identity ('-') cannot be used with the " + f"'{distribution}' distribution — ad-hoc signatures cannot " + f"be notarized or uploaded. Configure a real signing " + f"identity, or build with --macos-distribution none.", + ) + try: if distribution == "app-store": resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py index 4ab4ae8ca4..03c8235ca8 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py @@ -370,17 +370,20 @@ def filter_tar(tarinfo: tarfile.TarInfo): # typed-data boundary costs are a large per-frame tax for # byte-streaming Pyodide apps. web_renderer=WebRenderer( - options.web_renderer - or get_pyproject("tool.flet.web.renderer") - # lowered for parity with the CLI option's type=str.lower - or (os.getenv("FLET_WEB_RENDERER") or "").lower() - or "canvaskit" + ( + options.web_renderer + or get_pyproject("tool.flet.web.renderer") + or os.getenv("FLET_WEB_RENDERER") + or "canvaskit" + ).lower() ), route_url_strategy=RouteUrlStrategy( - options.route_url_strategy - or get_pyproject("tool.flet.web.route_url_strategy") - or (os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") or "").lower() - or "path" + ( + options.route_url_strategy + or get_pyproject("tool.flet.web.route_url_strategy") + or os.getenv("FLET_WEB_ROUTE_URL_STRATEGY") + or "path" + ).lower() ), no_cdn=no_cdn, ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py index b8a1a38ce0..49f5af2859 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/macos_sign.py @@ -130,7 +130,14 @@ def as_args(self) -> list[str]: """ if self.keychain_profile: return ["--keychain-profile", self.keychain_profile] - assert self.api_key and self.api_key_id and self.api_issuer + if not (self.api_key and self.api_key_id and self.api_issuer): + # Not an assert: `python -O` would strip it and produce a + # notarytool call with silently missing arguments. + raise MacOSSigningError( + "Incomplete App Store Connect API key credentials: " + "APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER " + "must all be set." + ) return [ "--key", self.api_key, @@ -836,7 +843,12 @@ def sign_app( # Quarantine and Finder-info extended attributes make codesign fail with # "resource fork, Finder information, or similar detritus not allowed", # and App Store Connect rejects quarantined files outright. - _run(["xattr", "-cr", str(app_path)]) + result = _run(["xattr", "-cr", str(app_path)]) + if result.returncode != 0: + raise MacOSSigningError( + f"Failed to clear extended attributes from {app_path}: " + f"{result.stderr.strip() or result.stdout.strip()}" + ) mach_o_files = find_mach_o_files(app_path) main_executable = _main_executable(app_path) diff --git a/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py index 3abb6b7279..aaff44a5c8 100644 --- a/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py +++ b/sdk/python/packages/flet-cli/tests/test_build_macos_signing.py @@ -395,6 +395,30 @@ def test_unknown_lane_subtable_rejected(tmp_path): cmd.resolve_macos_distribution() +def test_none_subtable_rejected(tmp_path): + """`[tool.flet.macos.signing.none]` is never read — fail instead of ignore.""" + cmd = make_command( + tmp_path, + pyproject={ + "tool.flet.macos.signing": {"none": {"identity": "never read"}}, + }, + ) + with pytest.raises(Exit, match=r"signing\.none.* is not a lane subtable"): + cmd.resolve_macos_distribution() + + +def test_preflight_rejects_adhoc_identity_for_lanes(tmp_path, monkeypatch): + """An explicit '-' identity fails preflight — it can't be notarized/uploaded.""" + forbid_keychain(monkeypatch) + cmd = make_command( + tmp_path, + options={"macos_signing_identity": "-", "macos_notary_profile": "flet"}, + pyproject={"tool.flet.macos.signing.distribution": "developer-id"}, + ) + with pytest.raises(Exit, match="ad-hoc"): + cmd.preflight_macos_signing() + + def test_fully_lane_organized_pyproject(tmp_path, monkeypatch): """Lane-only keys may live in their lane's subtable instead of flat. diff --git a/sdk/python/packages/flet-cli/tests/test_macos_sign.py b/sdk/python/packages/flet-cli/tests/test_macos_sign.py index 3233da15fc..ec02304fbc 100644 --- a/sdk/python/packages/flet-cli/tests/test_macos_sign.py +++ b/sdk/python/packages/flet-cli/tests/test_macos_sign.py @@ -391,6 +391,11 @@ def test_notary_credentials_args(): api_key="key.p8", api_key_id="KID", api_issuer="ISS" ).as_args() == ["--key", "key.p8", "--key-id", "KID", "--issuer", "ISS"] + # An incomplete API-key triple must raise, not assert — `python -O` + # strips asserts and would emit a notarytool call with missing args. + with pytest.raises(MacOSSigningError, match="APPLE_API_KEY_ID"): + NotaryCredentials(api_key="key.p8").as_args() + # Real (non-ad-hoc) identities for tests that never touch the keychain. DEV_ID = SigningIdentity(