From c3535a4f3e2f1f76082fd8a71dec7195f0b89be8 Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Wed, 7 Oct 2026 11:06:57 -0300 Subject: [PATCH 1/7] feat: prepare signed notarized public update releases --- .github/workflows/publish-update.yml | 59 ++++ Scripts/.gitignore | 1 + Scripts/release-updates.py | 436 +++++++++++++++++++++++++++ Scripts/test_release_policy.py | 68 +++++ Scripts/test_update_release.py | 32 ++ Scripts/update-config.json | 10 + Scripts/update_release.py | 114 +++++++ WindowCleaner/Info.plist | 51 ++-- docs/signed-updates.md | 11 + 9 files changed, 754 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/publish-update.yml create mode 100644 Scripts/.gitignore create mode 100644 Scripts/release-updates.py create mode 100644 Scripts/test_release_policy.py create mode 100644 Scripts/test_update_release.py create mode 100644 Scripts/update-config.json create mode 100644 Scripts/update_release.py create mode 100644 docs/signed-updates.md diff --git a/.github/workflows/publish-update.yml b/.github/workflows/publish-update.yml new file mode 100644 index 0000000..0411f48 --- /dev/null +++ b/.github/workflows/publish-update.yml @@ -0,0 +1,59 @@ +name: Prepare signed update +on: + workflow_dispatch: + inputs: + version: + description: Stable version for this approved release (for example 1.0.0) + required: true + type: string + publish: + description: Publish only after signed notarized bytes and complete metadata verify + default: false + type: boolean +permissions: + contents: read +concurrency: + group: WindowCleaner-signed-updates + cancel-in-progress: false +jobs: + prepare: + if: github.repository == 'fernandobelotto/WindowCleaner' && github.ref == 'refs/heads/main' + runs-on: macos-26 + environment: release + timeout-minutes: 90 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + SIGNING_IDENTITY: ${{ vars.SIGNING_IDENTITY }} + DEVELOPER_ID_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} + DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + NOTARY_PRIVATE_KEY: ${{ secrets.NOTARY_PRIVATE_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_BUILD: ${{ github.run_number }} + RELEASE_PUBLISH: ${{ inputs.publish }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Use OpenSSL 3 for Ed25519 verification + shell: bash + run: | + brew list --versions openssl@3 >/dev/null || brew install openssl@3 + echo "$(brew --prefix openssl@3)/bin" >> "$GITHUB_PATH" + - name: Verify release policy and signature fixtures + run: python3 -m unittest discover -s Scripts -p 'test_*release*.py' -v + - name: Build, sign, notarize and prepare immutable update + shell: bash + run: | + args=(--version "$RELEASE_VERSION" --build "$RELEASE_BUILD") + if [[ "$RELEASE_PUBLISH" == true ]]; then args+=(--publish); fi + python3 Scripts/release-updates.py "${args[@]}" + - uses: actions/upload-artifact@v4 + with: + name: signed-update-${{ inputs.version }} + path: dist/ + if-no-files-found: error diff --git a/Scripts/.gitignore b/Scripts/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/Scripts/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Scripts/release-updates.py b/Scripts/release-updates.py new file mode 100644 index 0000000..63aaa37 --- /dev/null +++ b/Scripts/release-updates.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Prepare universal notarized updates; publish only on an explicitly approved main run.""" + +import argparse, base64, hashlib, json, os, plistlib, re, secrets, subprocess, sys, tempfile +from pathlib import Path +from update_release import sign_archive, verify_update, publish_appcast + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = json.loads((ROOT / "Scripts/update-config.json").read_text()) +REPOSITORY = CONFIG["repository"] + + +class ReleaseError(Exception): + pass + + +def run(*args): + result = subprocess.run( + list(map(str, args)), cwd=ROOT, capture_output=True, text=True + ) + if result.returncode: + raise ReleaseError( + Path(str(args[0])).name + " failed; private output was suppressed" + ) + return result.stdout.strip() + + +def candidate(version, build, checkout, main): + if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version): + raise ReleaseError("A stable semantic version is required") + if not re.fullmatch(r"[1-9][0-9]*", str(build)): + raise ReleaseError("A positive build number is required") + if not re.fullmatch(r"[a-f0-9]{40}", checkout) or checkout != main: + raise ReleaseError("Only the exact current main commit can prepare a release") + + +def verify_artifacts(manifest, info, archive): + app = plistlib.loads(Path(info).read_bytes()) + if ( + manifest["version"] != app["CFBundleShortVersionString"] + or str(manifest["build"]) != str(app["CFBundleVersion"]) + or app["CFBundleIdentifier"] != CONFIG["bundle"] + ): + raise ReleaseError( + "Release version/build/app identity disagrees with built app" + ) + if ( + manifest.get("repository") != REPOSITORY + or manifest.get("notarized") is not True + ): + raise ReleaseError("Only a notarized artifact for this repository can publish") + expected = CONFIG["asset"] + "-" + manifest["version"] + "-universal.zip" + if ( + archive.name != expected + or manifest["sha256"] != hashlib.sha256(archive.read_bytes()).hexdigest() + ): + raise ReleaseError("Archive filename or checksum mismatch") + verify_update(ROOT / "dist/release.json", archive, info) + + +def publish(manifest, archive, info): + version = manifest["version"] + tag = "v" + version + candidate( + version, + manifest["build"], + manifest["commit"], + run("gh", "api", f"repos/{REPOSITORY}/commits/main", "--jq", ".sha"), + ) + verify_artifacts(manifest, info, archive) + existing = json.loads(run("gh", "api", f"repos/{REPOSITORY}/releases?per_page=100")) + if any(r["tag_name"] == tag for r in existing): + raise ReleaseError( + "Release already exists; never overwrite a published or draft artifact" + ) + refs = json.loads( + run("gh", "api", f"repos/{REPOSITORY}/git/matching-refs/tags/{tag}") + ) + if any(ref["ref"] == "refs/tags/" + tag for ref in refs): + raise ReleaseError("Release tag already exists; never retarget source history") + current = tuple(map(int, version.split("."))) + for release in existing: + if release.get("draft") or release.get("prerelease"): + continue + prior = release.get("tag_name", "")[1:] + if ( + re.fullmatch(r"\d+\.\d+\.\d+", prior) + and tuple(map(int, prior.split("."))) >= current + ): + raise ReleaseError("Stable release version must increase") + marker = [a for a in release["assets"] if a["name"] == "release.json"] + if marker: + data = json.loads( + run( + "gh", + "api", + f'repos/{REPOSITORY}/releases/assets/{marker[0]["id"]}', + "-H", + "Accept: application/octet-stream", + ) + ) + if int(data.get("build", 0)) >= int(manifest["build"]): + raise ReleaseError("Updater build number must increase") + checksums = ROOT / "dist/SHA256SUMS" + run( + "gh", + "release", + "create", + tag, + "--repo", + REPOSITORY, + "--target", + manifest["commit"], + "--draft", + "--title", + CONFIG["asset"] + " " + version, + "--notes", + "Signed and notarized macOS update.", + archive, + checksums, + ) + publish_appcast( + run, + REPOSITORY, + tag, + ROOT / "dist/release.json", + archive, + info, + "Signed and notarized macOS update.", + "15.6", + ) + # Complete release marker is uploaded last, while the release is still a draft. + run( + "gh", "release", "upload", tag, ROOT / "dist/release.json", "--repo", REPOSITORY + ) + with tempfile.TemporaryDirectory(prefix="verify-published-update-") as d: + run("gh", "release", "download", tag, "--repo", REPOSITORY, "--dir", d) + for path in [ + archive, + checksums, + ROOT / "dist/appcast.xml", + ROOT / "dist/release.json", + ]: + if (Path(d) / path.name).read_bytes() != path.read_bytes(): + raise ReleaseError( + "Uploaded release bytes did not match verified artifacts" + ) + candidate( + version, + manifest["build"], + manifest["commit"], + run("gh", "api", f"repos/{REPOSITORY}/commits/main", "--jq", ".sha"), + ) + run("gh", "release", "edit", tag, "--repo", REPOSITORY, "--draft=false") + + +def prepare(version, build, publishing): + if ( + os.environ.get("GITHUB_REF") != "refs/heads/main" + or os.environ.get("GITHUB_REPOSITORY") != REPOSITORY + ): + raise ReleaseError("Run the approved main-branch release workflow") + sha = run("git", "rev-parse", "HEAD") + main = run("gh", "api", f"repos/{REPOSITORY}/commits/main", "--jq", ".sha") + candidate(version, build, sha, main) + if run("git", "status", "--porcelain", "--untracked-files=no"): + raise ReleaseError("Release checkout has tracked changes") + identity = os.environ.get("SIGNING_IDENTITY", "") + if ( + not identity.startswith("Developer ID Application: ") + or "(J37G76B69X)" not in identity + ): + raise ReleaseError("Expected Developer ID identity is required") + required = [ + "DEVELOPER_ID_P12_BASE64", + "DEVELOPER_ID_P12_PASSWORD", + "NOTARY_PRIVATE_KEY", + "NOTARY_KEY_ID", + "NOTARY_ISSUER", + "SPARKLE_PRIVATE_KEY", + ] + if not all(os.environ.get(k) for k in required): + raise ReleaseError("Required release credentials are missing") + output = ROOT / "dist" + output.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="sign-update-") as d: + temporary = Path(d) + keychain = temporary / "release.keychain-db" + p12 = temporary / "identity.p12" + notary = temporary / "AuthKey.p8" + p12.write_bytes( + base64.b64decode(os.environ["DEVELOPER_ID_P12_BASE64"], validate=True) + ) + notary.write_text(os.environ["NOTARY_PRIVATE_KEY"]) + p12.chmod(0o600) + notary.chmod(0o600) + password = secrets.token_urlsafe(32) + try: + run("security", "create-keychain", "-p", password, keychain) + run("security", "unlock-keychain", "-p", password, keychain) + run( + "security", + "import", + p12, + "-k", + keychain, + "-P", + os.environ["DEVELOPER_ID_P12_PASSWORD"], + "-T", + "/usr/bin/codesign", + ) + run( + "security", + "set-key-partition-list", + "-S", + "apple-tool:,apple:,codesign:", + "-s", + "-k", + password, + keychain, + ) + derived = ROOT / ".release-build" + print("Building universal direct application…", flush=True) + run( + "xcodebuild", + "-project", + CONFIG["project"] + ".xcodeproj", + "-scheme", + CONFIG["scheme"], + "-configuration", + "Release", + "-destination", + "generic/platform=macOS", + "-derivedDataPath", + derived, + "ARCHS=arm64 x86_64", + "ONLY_ACTIVE_ARCH=NO", + "CODE_SIGNING_ALLOWED=NO", + "ENABLE_HARDENED_RUNTIME=YES", + "-skipPackagePluginValidation", + "MARKETING_VERSION=" + version, + "CURRENT_PROJECT_VERSION=" + str(build), + "build", + ) + app = derived / "Build/Products/Release" / (CONFIG["product"] + ".app") + info = app / "Contents/Info.plist" + built = plistlib.loads(info.read_bytes()) + if ( + built["CFBundleIdentifier"] != CONFIG["bundle"] + or built["CFBundleShortVersionString"] != version + or str(built["CFBundleVersion"]) != str(build) + ): + raise ReleaseError("Built app identity/version mismatch") + for arch in ["arm64", "x86_64"]: + run( + "lipo", + app / "Contents/MacOS" / built["CFBundleExecutable"], + "-verify_arch", + arch, + ) + entitlements = plistlib.loads((ROOT / CONFIG["entitlements"]).read_bytes()) + + def expanded(value): + if isinstance(value, str): + return value.replace( + "$(PRODUCT_BUNDLE_IDENTIFIER)", CONFIG["bundle"] + ) + if isinstance(value, list): + return [expanded(v) for v in value] + if isinstance(value, dict): + return {k: expanded(v) for k, v in value.items()} + return value + + entitlement_file = temporary / "entitlements.plist" + entitlement_file.write_bytes(plistlib.dumps(expanded(entitlements))) + framework = app / "Contents/Frameworks/Sparkle.framework" + for path in [ + framework / "Versions/B/XPCServices/Downloader.xpc", + framework / "Versions/B/XPCServices/InstallerLauncher.xpc", + framework / "Versions/B/Autoupdate", + framework / "Versions/B/Updater.app", + framework, + ]: + if not path.exists(): + raise ReleaseError("Sparkle nested component missing") + run( + "codesign", + "--force", + "--sign", + identity, + "--keychain", + keychain, + "--preserve-metadata=entitlements", + "--options", + "runtime", + "--timestamp", + path, + ) + helpers = app / "Contents/Library/LaunchServices" + if CONFIG.get("helper"): + helper = helpers / CONFIG["helper"] + if not helper.is_file(): + raise ReleaseError("Privileged helper missing from release bundle") + for arch in ["arm64", "x86_64"]: + run("lipo", helper, "-verify_arch", arch) + run( + "codesign", + "--force", + "--sign", + identity, + "--keychain", + keychain, + "--options", + "runtime", + "--timestamp", + helper, + ) + run( + "codesign", + "--verify", + "--strict", + "-R", + built["SMPrivilegedExecutables"][CONFIG["helper"]], + helper, + ) + run( + "codesign", + "--force", + "--sign", + identity, + "--keychain", + keychain, + "--entitlements", + entitlement_file, + "--options", + "runtime", + "--timestamp", + app, + ) + run("codesign", "--verify", "--deep", "--strict", app) + if CONFIG.get("helper"): + authorized = plistlib.loads( + (ROOT / "MacFanToolkitHelper/info.plist").read_bytes() + )["SMAuthorizedClients"] + if len(authorized) != 1: + raise ReleaseError( + "Privileged-helper client requirement must be unambiguous" + ) + run("codesign", "--verify", "--strict", "-R", authorized[0], app) + notarization = temporary / "notarization.zip" + run( + "ditto", + "-c", + "-k", + "--sequesterRsrc", + "--keepParent", + app, + notarization, + ) + print("Notarizing and stapling exact application…", flush=True) + result = json.loads( + run( + "xcrun", + "notarytool", + "submit", + notarization, + "--key", + notary, + "--key-id", + os.environ["NOTARY_KEY_ID"], + "--issuer", + os.environ["NOTARY_ISSUER"], + "--wait", + "--output-format", + "json", + ) + ) + if result.get("status") != "Accepted": + raise ReleaseError("Apple did not accept notarization") + run("xcrun", "stapler", "staple", app) + run("xcrun", "stapler", "validate", app) + run("spctl", "--assess", "--type", "execute", app) + archive = output / (CONFIG["asset"] + "-" + version + "-universal.zip") + run("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", app, archive) + binaries = list(derived.glob("SourcePackages/artifacts/**/bin/sign_update")) + if len(binaries) != 1: + raise ReleaseError( + "Exactly one resolved Sparkle signing tool is required" + ) + manifest = { + "repository": REPOSITORY, + "version": version, + "build": str(build), + "commit": sha, + "notarized": True, + "notarizationId": result.get("id"), + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + } + manifest["sparkle"] = sign_archive(archive, built, binaries[0]) + (output / "release.json").write_text(json.dumps(manifest, indent=2) + "\n") + (output / "SHA256SUMS").write_text( + manifest["sha256"] + " " + archive.name + "\n" + ) + verify_artifacts(manifest, info, archive) + if publishing: + publish(manifest, archive, info) + print( + "Verified signed update prepared" + + (" and published" if publishing else "; publication disabled") + + "." + ) + finally: + try: + run("security", "delete-keychain", keychain) + except ReleaseError: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--build", required=True) + parser.add_argument("--publish", action="store_true") + args = parser.parse_args() + try: + prepare(args.version, args.build, args.publish) + except Exception as error: + print( + ( + str(error) + if isinstance(error, ReleaseError) + else "Release preparation failed; secret-bearing tool output was suppressed" + ), + file=sys.stderr, + ) + sys.exit(1) diff --git a/Scripts/test_release_policy.py b/Scripts/test_release_policy.py new file mode 100644 index 0000000..b04b521 --- /dev/null +++ b/Scripts/test_release_policy.py @@ -0,0 +1,68 @@ +import importlib.util, unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "release", Path(__file__).with_name("release-updates.py") +) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class PolicyTests(unittest.TestCase): + def test_only_exact_main_commit_may_release(self): + release.candidate("1.2.3", "2", "a" * 40, "a" * 40) + with self.assertRaises(release.ReleaseError): + release.candidate("1.2.3", "2", "a" * 40, "b" * 40) + + def test_drafts_prereleases_and_bad_builds_cannot_be_stamped(self): + for version in ["v1.2.3", "1.2.3-beta.1", "../1.2.3", "1.2", "01.2.3"]: + with self.assertRaises(release.ReleaseError): + release.candidate(version, "2", "a" * 40, "a" * 40) + for build in ["0", "-1", "2.3", "2;echo bad"]: + with self.assertRaises(release.ReleaseError): + release.candidate("1.2.3", build, "a" * 40, "a" * 40) + + def test_metadata_only_wrong_app_and_tampered_bytes_never_publish(self): + import tempfile, plistlib + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / (release.CONFIG["asset"] + "-1.2.3-universal.zip") + archive.write_bytes(b"final bytes") + info = root / "Info.plist" + manifest = { + "version": "1.2.3", + "build": "2", + "repository": release.REPOSITORY, + "notarized": True, + "sha256": "0" * 64, + } + info.write_bytes( + plistlib.dumps( + { + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "2", + "CFBundleIdentifier": "wrong.app", + } + ) + ) + with self.assertRaises(release.ReleaseError): + release.verify_artifacts(manifest, info, archive) + info.write_bytes( + plistlib.dumps( + { + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "2", + "CFBundleIdentifier": release.CONFIG["bundle"], + } + ) + ) + with self.assertRaises(release.ReleaseError): + release.verify_artifacts(manifest, info, archive) + manifest["notarized"] = False + with self.assertRaises(release.ReleaseError): + release.verify_artifacts(manifest, info, archive) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/test_update_release.py b/Scripts/test_update_release.py new file mode 100644 index 0000000..49f6d0e --- /dev/null +++ b/Scripts/test_update_release.py @@ -0,0 +1,32 @@ +import base64 +from pathlib import Path +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET +from update_release import appcast, verify_archive + +class UpdateReleaseTests(unittest.TestCase): + def test_real_signature_accepts_final_bytes_and_rejects_mutation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root/'archive.zip'; archive.write_bytes(b'final stapled archive bytes') + subprocess.run(['openssl','genpkey','-algorithm','ED25519','-out',str(root/'key')],check=True,capture_output=True) + subprocess.run(['openssl','pkeyutl','-sign','-inkey',str(root/'key'),'-rawin','-in',str(archive),'-out',str(root/'sig')],check=True,capture_output=True) + key = subprocess.check_output(['openssl','pkey','-in',str(root/'key'),'-pubout','-outform','DER'])[-32:] + sig = base64.b64encode((root/'sig').read_bytes()).decode() + public = base64.b64encode(key).decode() + verify_archive(archive,sig,public) + archive.write_bytes(b'changed bytes after signing') + with self.assertRaisesRegex(ValueError,'does not verify'):verify_archive(archive,sig,public) + + def test_feed_requires_complete_immutable_archive(self): + release={'version':'1.2.3','build':'123','sparkle':{'enclosure':'Fixture-1.2.3.zip','length':100,'edSignature':base64.b64encode(bytes(64)).decode()}} + asset={'id':19,'name':'Fixture-1.2.3.zip','size':100} + xml=appcast(release,'test/Fixture',asset,'Fixed the app','15.0') + enclosure=ET.fromstring(xml).find('./channel/item/enclosure') + self.assertEqual(enclosure.attrib['url'],'https://api.github.com/repos/test/Fixture/releases/assets/19?filename=Fixture-1.2.3.zip') + for change in [{'id':0},{'size':101},{'name':'Other.zip'}]: + with self.assertRaises(ValueError):appcast(release,'test/Fixture',asset|change,'Notes') + +if __name__=='__main__':unittest.main() diff --git a/Scripts/update-config.json b/Scripts/update-config.json new file mode 100644 index 0000000..42b82d8 --- /dev/null +++ b/Scripts/update-config.json @@ -0,0 +1,10 @@ +{ + "repository": "fernandobelotto/WindowCleaner", + "project": "WindowCleaner", + "product": "WindowCleaner", + "scheme": "WindowCleaner", + "asset": "WindowCleaner", + "entitlements": "WindowCleaner/WindowCleaner.entitlements", + "info": "WindowCleaner/Info.plist", + "bundle": "com.fernandobelotto.WindowCleaner" +} diff --git a/Scripts/update_release.py b/Scripts/update_release.py new file mode 100644 index 0000000..7bb7f6e --- /dev/null +++ b/Scripts/update_release.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Sign exact release bytes and build a private, immutable per-release appcast.""" +import base64 +import json +import os +from pathlib import Path +import plistlib +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +NS = 'http://www.andymatuschak.org/xml-namespaces/sparkle' +ET.register_namespace('sparkle', NS) + + +def sign_archive(archive, info, binary): + """Sparkle's format is an exported seed, never printed or stored in the repository.""" + private_key = os.environ.get('SPARKLE_PRIVATE_KEY') + if not private_key: + raise ValueError('SPARKLE_PRIVATE_KEY is required for a signed release') + result = subprocess.run([str(binary), '--ed-key-file', '-', str(archive)], input=private_key, + capture_output=True, text=True, check=True) + match = re.fullmatch(r'\s*sparkle:edSignature="([A-Za-z0-9+/=]+)" length="([0-9]+)"\s*', result.stdout) + if not match or int(match[2]) != Path(archive).stat().st_size: + raise ValueError('Sparkle signing output does not match archive length') + signature = match[1] + verify_archive(archive, signature, info['SUPublicEDKey']) + return {'enclosure': Path(archive).name, 'length': int(match[2]), 'edSignature': signature} + + +def verify_archive(archive, signature, public_key): + key = base64.b64decode(public_key, validate=True) + sig = base64.b64decode(signature, validate=True) + if len(key) != 32 or len(sig) != 64: + raise ValueError('Invalid updater public key or archive signature') + with tempfile.TemporaryDirectory(prefix='verify-update-') as temporary: + root = Path(temporary) + (root / 'public.der').write_bytes(bytes.fromhex('302a300506032b6570032100') + key) + (root / 'signature').write_bytes(sig) + result = subprocess.run(['openssl', 'pkeyutl', '-verify', '-pubin', '-keyform', 'DER', + '-inkey', str(root / 'public.der'), '-rawin', '-in', str(archive), + '-sigfile', str(root / 'signature')], capture_output=True) + if result.returncode: + raise ValueError('Update archive signature does not verify against the application public key') + + +def appcast(release, repository, asset, notes, minimum='15.0'): + signature = release.get('sparkle', {}) + if (not isinstance(asset.get('id'), int) or asset['id'] < 1 + or asset.get('name') != signature.get('enclosure') + or asset.get('size') != signature.get('length') + or not isinstance(asset.get('size'), int) or asset['size'] <= 0 + or len(base64.b64decode(signature.get('edSignature', ''), validate=True)) != 64 + or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', release['version']) + or not re.fullmatch(r'[1-9][0-9]*(\.[0-9]+){0,2}', str(release['build']))): + raise ValueError('Update release signature or immutable asset metadata is invalid') + rss = ET.Element('rss', {'version': '2.0'}) + channel = ET.SubElement(rss, 'channel') + ET.SubElement(channel, 'title').text = repository.rsplit('/', 1)[-1] + ' Updates' + item = ET.SubElement(channel, 'item') + ET.SubElement(item, 'title').text = release['version'] + ET.SubElement(item, '{' + NS + '}version').text = str(release['build']) + ET.SubElement(item, '{' + NS + '}shortVersionString').text = release['version'] + ET.SubElement(item, '{' + NS + '}minimumSystemVersion').text = minimum + # Inline text avoids authenticated release note loads or HTML execution. + ET.SubElement(item, 'description', {'{' + NS + '}format': 'plain-text'}).text = notes + ET.SubElement(item, 'enclosure', {'url': f'https://api.github.com/repos/{repository}/releases/assets/{asset["id"]}?filename={asset["name"]}', + 'length': str(asset['size']), 'type': 'application/octet-stream', + '{' + NS + '}edSignature': signature['edSignature']}) + ET.indent(rss) + return ET.tostring(rss, encoding='utf-8', xml_declaration=True) + b'\n' + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--archive', required=True) + parser.add_argument('--info', required=True) + parser.add_argument('--manifest', required=True) + parser.add_argument('--sign-update', required=True) + args = parser.parse_args() + manifest = Path(args.manifest) + release = json.loads(manifest.read_text()) + release['sparkle'] = sign_archive(Path(args.archive), plistlib.loads(Path(args.info).read_bytes()), Path(args.sign_update)) + manifest.write_text(json.dumps(release, indent=2) + '\n') + + +def verify_update(manifest, archive, info): + evidence = json.loads(Path(manifest).read_text()) + signature = evidence.get('sparkle', {}) + if signature.get('enclosure') != Path(archive).name or signature.get('length') != Path(archive).stat().st_size: + raise ValueError('Signed update provenance does not match the release archive') + verify_archive(archive, signature.get('edSignature', ''), plistlib.loads(Path(info).read_bytes())['SUPublicEDKey']) + return evidence + + +def publish_appcast(run, repository, tag, manifest, archive, info, notes, minimum): + evidence = verify_update(manifest, archive, info) + if 'build' not in evidence: + evidence['build'] = evidence['build_number'] + draft = json.loads(run('gh', 'api', f'repos/{repository}/releases/tags/{tag}')) + if not draft.get('draft') or draft.get('prerelease'): + raise ValueError('Update metadata may only be added to a stable unpublished draft') + assets = [asset for asset in draft['assets'] if asset['name'] == Path(archive).name] + if len(assets) != 1: + raise ValueError('The immutable update archive asset is missing or duplicated') + feed = Path(manifest).parent / 'appcast.xml' + feed.write_bytes(appcast(evidence, repository, assets[0], notes, minimum)) + run('gh', 'release', 'upload', tag, str(feed), '--repo', repository) + with tempfile.TemporaryDirectory(prefix='verify-update-feed-') as temporary: + run('gh', 'release', 'download', tag, '--repo', repository, '--pattern', 'appcast.xml', '--dir', temporary) + if (Path(temporary) / 'appcast.xml').read_bytes() != feed.read_bytes(): + raise ValueError('Uploaded update metadata differs from the verified release') diff --git a/WindowCleaner/Info.plist b/WindowCleaner/Info.plist index 1b261cd..5a1b7dd 100644 --- a/WindowCleaner/Info.plist +++ b/WindowCleaner/Info.plist @@ -2,53 +2,48 @@ - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - - CFBundleName - $(PRODUCT_NAME) - CFBundleDisplayName Window Cleaner - CFBundleExecutable $(EXECUTABLE_NAME) - + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) - - CFBundleShortVersionString $(MARKETING_VERSION) - CFBundleVersion $(CURRENT_PROJECT_VERSION) - - CFBundleInfoDictionaryVersion - 6.0 - - + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) NSAccessibilityUsageDescription Window Cleaner needs accessibility access to count windows and manage running applications. - + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAppleEventsUsageDescription Window Cleaner needs automation access to quit applications on your behalf. - - NSApplicationSupportsSecureRestorableState - - NSPrincipalClass - NSApplication - NSHighResolutionCapable - + NSPrincipalClass + NSApplication NSSupportsAutomaticGraphicsSwitching - - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + + SUPublicEDKey + sU8dlHdwb/gIfqm06/QQcSb8QRbBCsVDdW/jqwAqVN0= + SUVerifyUpdateBeforeExtraction + diff --git a/docs/signed-updates.md b/docs/signed-updates.md new file mode 100644 index 0000000..1e48da7 --- /dev/null +++ b/docs/signed-updates.md @@ -0,0 +1,11 @@ +# Signed direct macOS updates + +The **Prepare signed update** workflow only runs on this repository's current main commit in the main-only `release` environment. Enter a stable semantic version; the workflow run number owns the monotonically increasing update build. Publication defaults off. Review the prepared artifact before dispatching with publish enabled. + +Universal arm64/Intel application bytes are built from the exact commit with the selected release version/build, signed inside-out using Developer ID, notarized, stapled, verified by Gatekeeper and zipped. The final ZIP is signed with the app's unique Sparkle key and verified against SUPublicEDKey. ZIP and checksums upload into a draft, appcast is generated using the immutable archive asset ID, and release.json is uploaded last. Every uploaded artifact is downloaded and byte-compared before the release leaves draft status. Versions/builds cannot regress and published releases are never overwritten. A failed draft leaves the previous stable update usable. + +Hosted signing and notarization credentials and SPARKLE_PRIVATE_KEY are configured in the repository's main-only release environment, with only the public updater key in the direct Info.plist. Store targets use separate Info files and do not receive self-update metadata. Existing bundle identities and entitlements are preserved. WindowCleaner remains public; update access is anonymous. + +No workflow or release is triggered by implementation. A live two-version upgrade rehearsal, Apple notarization, and privileged-helper installation (where applicable) require an approved release run. Input version is release-owned and stamped into the prepared artifact; it does not silently edit the source project version. + +The workflow creates a new GitHub draft release and version tag from the exact approved main commit. It rejects a tag or release that already exists, signs the final stapled bytes and publishes only after the complete marker and all assets verify. Vendor Sparkle XPC entitlements are preserved during re-signing. OpenSSL 3 is explicitly selected for Ed25519 fixtures and final verification; trusted main builds permit the committed SwiftLint plugin. From 04e42e50d0eb99994fe0746efb9b8139399e0f54 Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Wed, 7 Oct 2026 11:09:11 -0300 Subject: [PATCH 2/7] feat: add safe native update controls and Store targets --- .github/workflows/updater-checks.yml | 28 ++ Scripts/test-update-transport.sh | 7 + Tests/Updater/TransportFixtures.swift | 87 +++++ WindowCleaner.xcodeproj/project.pbxproj | 327 +++++++++++++++++- .../xcschemes/WindowCleanerAppStore.xcscheme | 87 +++++ .../xcschemes/WindowCleanerUpdates.xcscheme | 111 ++++++ WindowCleaner/Info-AppStore.plist | 54 +++ .../Updates/PrivateUpdateAccess.swift | 72 ++++ .../Updates/PrivateUpdateTransport.swift | 302 ++++++++++++++++ WindowCleaner/Updates/UpdateController.swift | 202 +++++++++++ .../Views/Settings/SettingsView.swift | 3 + WindowCleaner/WindowCleanerApp.swift | 8 + .../WindowCleanerAppStore.entitlements | 14 + WindowCleanerTests/UpdateTests.swift | 80 +++++ docs/native-updates.md | 21 ++ 15 files changed, 1397 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/updater-checks.yml create mode 100755 Scripts/test-update-transport.sh create mode 100644 Tests/Updater/TransportFixtures.swift create mode 100644 WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerAppStore.xcscheme create mode 100644 WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerUpdates.xcscheme create mode 100644 WindowCleaner/Info-AppStore.plist create mode 100644 WindowCleaner/Updates/PrivateUpdateAccess.swift create mode 100644 WindowCleaner/Updates/PrivateUpdateTransport.swift create mode 100644 WindowCleaner/Updates/UpdateController.swift create mode 100644 WindowCleaner/WindowCleanerAppStore.entitlements create mode 100644 WindowCleanerTests/UpdateTests.swift create mode 100644 docs/native-updates.md diff --git a/.github/workflows/updater-checks.yml b/.github/workflows/updater-checks.yml new file mode 100644 index 0000000..478a37d --- /dev/null +++ b/.github/workflows/updater-checks.yml @@ -0,0 +1,28 @@ +name: Updater checks +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + updater: + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Validate metadata, loopback transport and credential redirects + run: Scripts/test-update-transport.sh + - name: Test updater preferences and safe installation + run: xcodebuild -project WindowCleaner.xcodeproj -scheme WindowCleanerUpdates -destination 'platform=macOS' -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO test + - name: Build Store variant without self-updater + run: xcodebuild -project WindowCleaner.xcodeproj -scheme WindowCleanerAppStore -configuration Release -derivedDataPath .build/updater-store -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO build + - name: Verify Store has no Sparkle linkage or framework + run: | + python3 - <<'PY' + import pathlib, plistlib, subprocess + bundle = pathlib.Path('.build/updater-store/Build/Products/Release/WindowCleaner.app') + assert not (bundle / 'Contents/Frameworks/Sparkle.framework').exists() + assert 'Sparkle' not in subprocess.check_output(['otool', '-L', str(bundle / 'Contents/MacOS/WindowCleaner')], text=True) + info = plistlib.loads((bundle / 'Contents/Info.plist').read_bytes()) + assert not any(key.startswith('SU') for key in info) + PY diff --git a/Scripts/test-update-transport.sh b/Scripts/test-update-transport.sh new file mode 100755 index 0000000..9aebb90 --- /dev/null +++ b/Scripts/test-update-transport.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +output="$(mktemp -d)" +trap 'rm -rf "$output"' EXIT +xcrun swiftc -swift-version 6 WindowCleaner/Updates/PrivateUpdateTransport.swift WindowCleaner/Updates/PrivateUpdateAccess.swift Tests/Updater/TransportFixtures.swift -o "$output/transport-tests" +"$output/transport-tests" diff --git a/Tests/Updater/TransportFixtures.swift b/Tests/Updater/TransportFixtures.swift new file mode 100644 index 0000000..f9742b9 --- /dev/null +++ b/Tests/Updater/TransportFixtures.swift @@ -0,0 +1,87 @@ +import Foundation +import Synchronization + +@main +struct UpdateTransportFixtures { + @MainActor static func main() async throws { + let signature = Data(repeating: 0, count: 64).base64EncodedString() + let archive = PrivateUpdateRelease.Asset(id: 17, name: "Fixture-1.2.3-universal.zip", size: 123) + let feed = Data(""" + + """.utf8) + _ = try UpdateFeedValidator.validate(feed, asset: archive, repository: "test/Fixture") + for bad in [String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "assets/17", with: "assets/18"), + String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "test/Fixture", with: "test/Other"), + String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "length=\"123\"", with: "length=\"124\""), + String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "api.github.com", with: "evil.example"), + String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "", with: "https://evil.example"), + "", ""] { + var failed = false + do { _ = try UpdateFeedValidator.validate(Data(bad.utf8), asset: archive, repository: "test/Fixture") } + catch { failed = true } + precondition(failed, "Untrusted feed must fail") + } + let assets = [archive, .init(id: 18, name: "appcast.xml", size: 123), .init(id: 19, name: "release.json", size: 123)] + let release = PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: false, assets: assets) + precondition(release.completeArchive(app: "Fixture") != nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: true, prerelease: false, assets: assets).completeArchive(app: "Fixture") == nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: true, assets: assets).completeArchive(app: "Fixture") == nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: false, assets: [archive]).completeArchive(app: "Fixture") == nil) + precondition(!PrivateUpdateAccess.validToken("token with spaces")) + precondition(PrivateUpdateAccess.validToken("github_pat_example123")) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [FixtureUpdateProtocol.self] + let releases = """ + [{"id":3,"tag_name":"v1.2.3","draft":false,"prerelease":false,"assets":[{"id":17,"name":"Fixture-1.2.3-universal.zip","size":123,"state":"uploaded"},{"id":18,"name":"appcast.xml","size":500,"state":"uploaded"},{"id":19,"name":"release.json","size":123,"state":"uploaded"}]}] + """ + FixtureUpdateProtocol.responses.withLock { value in + value["/repos/test/Fixture/releases"] = Data(releases.utf8) + value["/repos/test/Fixture/releases/assets/18"] = feed + } + let transport = PrivateUpdateTransport(repository: "test/Fixture", app: "Fixture", configuration: config) + try await transport.prepare(token: nil) { nil } + precondition(FixtureUpdateProtocol.paths.withLock { !$0.contains("/repos/test/Fixture/releases/assets/17") }, "Checking must not download archives") + let (servedFeed, response) = try await URLSession.shared.data(from: transport.feedURL!) + precondition((response as? HTTPURLResponse)?.statusCode == 200 && servedFeed == feed, "Loopback must preserve feed bytes") + var redirected = URLRequest(url: URL(string: "https://release-assets.githubusercontent.com/fixture")!) + redirected.setValue("Bearer github_pat_fixture", forHTTPHeaderField: "Authorization") + redirected.setValue("session=fixture", forHTTPHeaderField: "Cookie") + let redirectSession = URLSession(configuration: .ephemeral) + let redirectTask = redirectSession.dataTask(with: URL(string: "https://api.github.com")!) + let redirectResponse = HTTPURLResponse(url: URL(string: "https://api.github.com")!, statusCode: 302, httpVersion: nil, headerFields: nil)! + UpdateRedirectPolicy().urlSession(redirectSession, task: redirectTask, willPerformHTTPRedirection: redirectResponse, newRequest: redirected) { clean in + precondition(clean?.value(forHTTPHeaderField: "Authorization") == nil) + precondition(clean?.value(forHTTPHeaderField: "Cookie") == nil) + } + redirected.url = URL(string: "https://evil.example/fixture")! + UpdateRedirectPolicy().urlSession(redirectSession, task: redirectTask, willPerformHTTPRedirection: redirectResponse, newRequest: redirected) { clean in + precondition(clean == nil) + } + FixtureUpdateProtocol.statuses.withLock { $0["/repos/test/Fixture/releases"] = 401 } + let expired = PrivateUpdateTransport(repository: "test/Fixture", app: "Fixture", configuration: config) + var denied = false + do { try await expired.prepare(token: nil) { nil } } + catch PrivateUpdateTransport.Failure.access { denied = true } + precondition(denied, "Expired access must produce actionable failure") + print("Update metadata, credential failure, cache-free loopback and redirect fixtures passed") + } +} + +final class FixtureUpdateProtocol: URLProtocol, @unchecked Sendable { + static let responses = Mutex<[String: Data]>([:]) + static let paths = Mutex<[String]>([]) + static let statuses = Mutex<[String: Int]>([:]) + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "api.github.com" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + precondition(request.value(forHTTPHeaderField: "Authorization") == nil, "Public updates must not need a token") + let path = request.url!.path + Self.paths.withLock { $0.append(path) } + let body = Self.responses.withLock { $0[path] } + let response = HTTPURLResponse(url: request.url!, statusCode: Self.statuses.withLock { $0[path] } ?? (body == nil ? 404 : 200), httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body ?? Data()) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/WindowCleaner.xcodeproj/project.pbxproj b/WindowCleaner.xcodeproj/project.pbxproj index 19a83d1..328ec18 100644 --- a/WindowCleaner.xcodeproj/project.pbxproj +++ b/WindowCleaner.xcodeproj/project.pbxproj @@ -6,6 +6,16 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 264D3A07EDE6D54CBB18FED6 /* PrivateUpdateTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB738BBEB41821407787B5BF /* PrivateUpdateTransport.swift */; }; + 2A22608750F63AD8853D69A5 /* UpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5FB43DE4B58E2ECC0363A7 /* UpdateTests.swift */; }; + 2CA02DE45B8F82EBAC0CD732 /* PrivateUpdateAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7535ABD1380100920A5B10F2 /* PrivateUpdateAccess.swift */; }; + 3A3EB789F4E12CB87CCB2A28 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 9512BD4F5BA249BF4F56EDBB /* Sparkle */; }; + A00C8C5249AE925BB8735793 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 9512BD4F5BA249BF4F56EDBB /* Sparkle */; }; + D973BE7F5E0ADE2495961A04 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48142974370E27279982B9C5 /* Cocoa.framework */; }; + E98A6343580577450759DF19 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA5FBCB65BA7B2A26F6F83A /* UpdateController.swift */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 84B242B52EDB440900177559 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -24,34 +34,82 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 48142974370E27279982B9C5 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; + 5D5FB43DE4B58E2ECC0363A7 /* UpdateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UpdateTests.swift; path = WindowCleanerTests/UpdateTests.swift; sourceTree = ""; }; + 6AA5FBCB65BA7B2A26F6F83A /* UpdateController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UpdateController.swift; path = WindowCleaner/Updates/UpdateController.swift; sourceTree = ""; }; + 7535ABD1380100920A5B10F2 /* PrivateUpdateAccess.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrivateUpdateAccess.swift; path = WindowCleaner/Updates/PrivateUpdateAccess.swift; sourceTree = ""; }; 84B242A52EDB440800177559 /* WindowCleaner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WindowCleaner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 84B242B42EDB440900177559 /* WindowCleanerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WindowCleanerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 84B242BE2EDB440900177559 /* WindowCleanerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WindowCleanerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EB738BBEB41821407787B5BF /* PrivateUpdateTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrivateUpdateTransport.swift; path = WindowCleaner/Updates/PrivateUpdateTransport.swift; sourceTree = ""; }; + ECE316D77F92EC4C1A85C953 /* WindowCleanerUpdateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WindowCleanerUpdateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FC867191A76D6D253F7D41D8 /* WindowCleanerAppStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WindowCleanerAppStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 89C73A5F093A6BAC63B44499 /* Exceptions for "WindowCleaner" folder in "WindowCleanerAppStore" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + "Info-AppStore.plist", + WindowCleaner.entitlements, + WindowCleanerAppStore.entitlements, + ); + target = D4C71A935362E0DD93E24372 /* WindowCleanerAppStore */; + }; + B7DE754C843A42B863D54C1B /* Exceptions for "WindowCleaner" folder in "WindowCleaner" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + "Info-AppStore.plist", + WindowCleaner.entitlements, + WindowCleanerAppStore.entitlements, + ); + target = 84B242A42EDB440800177559 /* WindowCleaner */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 84B242A72EDB440800177559 /* WindowCleaner */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B7DE754C843A42B863D54C1B /* Exceptions for "WindowCleaner" folder in "WindowCleaner" target */, + 89C73A5F093A6BAC63B44499 /* Exceptions for "WindowCleaner" folder in "WindowCleanerAppStore" target */, + ); path = WindowCleaner; sourceTree = ""; }; 84B242B72EDB440900177559 /* WindowCleanerTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = WindowCleanerTests; sourceTree = ""; }; 84B242C12EDB440900177559 /* WindowCleanerUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = WindowCleanerUITests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 1A86517224B844D2F96A249C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D973BE7F5E0ADE2495961A04 /* Cocoa.framework in Frameworks */, + A00C8C5249AE925BB8735793 /* Sparkle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 84B242A22EDB440800177559 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3A3EB789F4E12CB87CCB2A28 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -69,9 +127,32 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D48502D7A90675F118A0C1E5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2EF724BFB44BC08A30016355 /* OS X */ = { + isa = PBXGroup; + children = ( + 48142974370E27279982B9C5 /* Cocoa.framework */, + ); + name = "OS X"; + sourceTree = ""; + }; + 30D8BB2356A991701D36968A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2EF724BFB44BC08A30016355 /* OS X */, + ); + name = Frameworks; + sourceTree = ""; + }; 84B2429C2EDB440800177559 = { isa = PBXGroup; children = ( @@ -79,6 +160,11 @@ 84B242B72EDB440900177559 /* WindowCleanerTests */, 84B242C12EDB440900177559 /* WindowCleanerUITests */, 84B242A62EDB440800177559 /* Products */, + 30D8BB2356A991701D36968A /* Frameworks */, + 5D5FB43DE4B58E2ECC0363A7 /* UpdateTests.swift */, + 6AA5FBCB65BA7B2A26F6F83A /* UpdateController.swift */, + 7535ABD1380100920A5B10F2 /* PrivateUpdateAccess.swift */, + EB738BBEB41821407787B5BF /* PrivateUpdateTransport.swift */, ); sourceTree = ""; }; @@ -88,6 +174,8 @@ 84B242A52EDB440800177559 /* WindowCleaner.app */, 84B242B42EDB440900177559 /* WindowCleanerTests.xctest */, 84B242BE2EDB440900177559 /* WindowCleanerUITests.xctest */, + FC867191A76D6D253F7D41D8 /* WindowCleanerAppStore.app */, + ECE316D77F92EC4C1A85C953 /* WindowCleanerUpdateTests.xctest */, ); name = Products; sourceTree = ""; @@ -113,6 +201,7 @@ ); name = WindowCleaner; packageProductDependencies = ( + 9512BD4F5BA249BF4F56EDBB /* Sparkle */, ); productName = WindowCleaner; productReference = 84B242A52EDB440800177559 /* WindowCleaner.app */; @@ -135,8 +224,6 @@ 84B242B72EDB440900177559 /* WindowCleanerTests */, ); name = WindowCleanerTests; - packageProductDependencies = ( - ); productName = WindowCleanerTests; productReference = 84B242B42EDB440900177559 /* WindowCleanerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -158,12 +245,50 @@ 84B242C12EDB440900177559 /* WindowCleanerUITests */, ); name = WindowCleanerUITests; - packageProductDependencies = ( - ); productName = WindowCleanerUITests; productReference = 84B242BE2EDB440900177559 /* WindowCleanerUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 9987FE75A09EEDF8683FD657 /* WindowCleanerUpdateTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 568BE32C5015B44366FA9AEF /* Build configuration list for PBXNativeTarget "WindowCleanerUpdateTests" */; + buildPhases = ( + E8F6053DEA11352AD4F6D279 /* Sources */, + 1A86517224B844D2F96A249C /* Frameworks */, + 2112D8D3783BD9E0F9B142A6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WindowCleanerUpdateTests; + packageProductDependencies = ( + 9512BD4F5BA249BF4F56EDBB /* Sparkle */, + ); + productName = WindowCleanerUpdateTests; + productReference = ECE316D77F92EC4C1A85C953 /* WindowCleanerUpdateTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + D4C71A935362E0DD93E24372 /* WindowCleanerAppStore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3F995E0DE5228D7E3F460FE8 /* Build configuration list for PBXNativeTarget "WindowCleanerAppStore" */; + buildPhases = ( + 00E0BBE459DE539D336302BC /* Sources */, + D48502D7A90675F118A0C1E5 /* Frameworks */, + 4B744B18AFA24DEA67DC1728 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 84B242A72EDB440800177559 /* WindowCleaner */, + ); + name = WindowCleanerAppStore; + productName = WindowCleanerAppStore; + productReference = FC867191A76D6D253F7D41D8 /* WindowCleanerAppStore.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -198,6 +323,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 84B242D12EDB49C000177559 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, + 4045DB1F821A96BCD7A063BA /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = 84B242A62EDB440800177559 /* Products */; @@ -207,11 +333,27 @@ 84B242A42EDB440800177559 /* WindowCleaner */, 84B242B32EDB440900177559 /* WindowCleanerTests */, 84B242BD2EDB440900177559 /* WindowCleanerUITests */, + D4C71A935362E0DD93E24372 /* WindowCleanerAppStore */, + 9987FE75A09EEDF8683FD657 /* WindowCleanerUpdateTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 2112D8D3783BD9E0F9B142A6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4B744B18AFA24DEA67DC1728 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 84B242A32EDB440800177559 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -236,6 +378,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 00E0BBE459DE539D336302BC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 84B242A12EDB440800177559 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -257,6 +406,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E8F6053DEA11352AD4F6D279 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A22608750F63AD8853D69A5 /* UpdateTests.swift in Sources */, + E98A6343580577450759DF19 /* UpdateController.swift in Sources */, + 2CA02DE45B8F82EBAC0CD732 /* PrivateUpdateAccess.swift in Sources */, + 264D3A07EDE6D54CBB18FED6 /* PrivateUpdateTransport.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -272,11 +432,37 @@ }; 84B242D32EDB49F700177559 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 84B242D22EDB49F700177559 /* SwiftLintBuildToolPlugin */; + productRef = 84B242D22EDB49F700177559 /* plugin:SwiftLintBuildToolPlugin */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 03C30533551946943D827B13 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J37G76B69X; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fernandobelotto.WindowCleaner.updatetests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) UPDATER_TEST_HARNESS"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; 84B242C62EDB440900177559 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -402,6 +588,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WindowCleaner/WindowCleaner.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -411,6 +598,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = WindowCleaner/Info.plist; + INFOPLIST_KEY_SUAutomaticallyUpdate = NO; + INFOPLIST_KEY_SUEnableAutomaticChecks = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -433,6 +622,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WindowCleaner/WindowCleaner.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -442,6 +632,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = WindowCleaner/Info.plist; + INFOPLIST_KEY_SUAutomaticallyUpdate = NO; + INFOPLIST_KEY_SUEnableAutomaticChecks = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -539,9 +731,119 @@ }; name = Release; }; + A9C4139BD01D5C4396D44940 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WindowCleaner/WindowCleanerAppStore.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J37G76B69X; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "WindowCleaner/Info-AppStore.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fernandobelotto.WindowCleaner; + PRODUCT_NAME = WindowCleaner; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BCDB97009070F3D6E3DF6E0E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J37G76B69X; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fernandobelotto.WindowCleaner.updatetests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) UPDATER_TEST_HARNESS"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + E9836C07336CD4BA405A46C3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = WindowCleaner/WindowCleanerAppStore.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J37G76B69X; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "WindowCleaner/Info-AppStore.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.fernandobelotto.WindowCleaner; + PRODUCT_NAME = WindowCleaner; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3F995E0DE5228D7E3F460FE8 /* Build configuration list for PBXNativeTarget "WindowCleanerAppStore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9836C07336CD4BA405A46C3 /* Release */, + A9C4139BD01D5C4396D44940 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 568BE32C5015B44366FA9AEF /* Build configuration list for PBXNativeTarget "WindowCleanerUpdateTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BCDB97009070F3D6E3DF6E0E /* Release */, + 03C30533551946943D827B13 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 84B242A02EDB440800177559 /* Build configuration list for PBXProject "WindowCleaner" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -581,6 +883,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 4045DB1F821A96BCD7A063BA /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.10.0; + }; + }; 84B242D12EDB49C000177559 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; @@ -592,11 +902,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 84B242D22EDB49F700177559 /* SwiftLintBuildToolPlugin */ = { + 84B242D22EDB49F700177559 /* plugin:SwiftLintBuildToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = 84B242D12EDB49C000177559 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; productName = "plugin:SwiftLintBuildToolPlugin"; }; + 9512BD4F5BA249BF4F56EDBB /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 4045DB1F821A96BCD7A063BA /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 84B2429D2EDB440800177559 /* Project object */; diff --git a/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerAppStore.xcscheme b/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerAppStore.xcscheme new file mode 100644 index 0000000..08c5e99 --- /dev/null +++ b/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerAppStore.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerUpdates.xcscheme b/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerUpdates.xcscheme new file mode 100644 index 0000000..8e99d54 --- /dev/null +++ b/WindowCleaner.xcodeproj/xcshareddata/xcschemes/WindowCleanerUpdates.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WindowCleaner/Info-AppStore.plist b/WindowCleaner/Info-AppStore.plist new file mode 100644 index 0000000..1b261cd --- /dev/null +++ b/WindowCleaner/Info-AppStore.plist @@ -0,0 +1,54 @@ + + + + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + + CFBundleName + $(PRODUCT_NAME) + + CFBundleDisplayName + Window Cleaner + + CFBundleExecutable + $(EXECUTABLE_NAME) + + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + + + CFBundleShortVersionString + $(MARKETING_VERSION) + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + CFBundleInfoDictionaryVersion + 6.0 + + + NSAccessibilityUsageDescription + Window Cleaner needs accessibility access to count windows and manage running applications. + + NSAppleEventsUsageDescription + Window Cleaner needs automation access to quit applications on your behalf. + + + NSApplicationSupportsSecureRestorableState + + + NSPrincipalClass + NSApplication + + NSHighResolutionCapable + + + NSSupportsAutomaticGraphicsSwitching + + + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + + diff --git a/WindowCleaner/Updates/PrivateUpdateAccess.swift b/WindowCleaner/Updates/PrivateUpdateAccess.swift new file mode 100644 index 0000000..2b3957c --- /dev/null +++ b/WindowCleaner/Updates/PrivateUpdateAccess.swift @@ -0,0 +1,72 @@ +#if os(macOS) && !APP_STORE + import Foundation + import LocalAuthentication + import Security + + @MainActor + protocol UpdateTokenStore { + func readToken() throws -> String? + func saveToken(_ token: String) throws + } + + enum PrivateUpdateAccess { + enum AccessError: LocalizedError { + case missingToken, invalidToken, keychain(OSStatus) + + var errorDescription: String? { + switch self { + case .missingToken: + "Configure a GitHub token in Updates → Private Update Access… to download private updates." + case .invalidToken: + "Enter a GitHub personal access token without spaces or line breaks." + case let .keychain(status): + "Could not access the update token in Keychain (\(status))." + } + } + } + + static func validToken(_ token: String) -> Bool { + !token.isEmpty && token.utf8.allSatisfy { + (65 ... 90).contains($0) || (97 ... 122).contains($0) || (48 ... 57).contains($0) || $0 == 95 + } + } + } + + @MainActor + final class KeychainUpdateTokenStore: UpdateTokenStore { + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(Bundle.main.bundleIdentifier ?? "private-app").private-updates", + kSecAttrAccount as String: "github-token", + kSecAttrSynchronizable as String: false] + } + + func readToken() throws -> String? { + var request = query + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + let context = LAContext() + context.interactionNotAllowed = true + request[kSecUseAuthenticationContext as String] = context + var result: CFTypeRef? + let status = SecItemCopyMatching(request as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw PrivateUpdateAccess.AccessError.keychain(status) } + guard let data = result as? Data, let token = String(data: data, encoding: .utf8), + PrivateUpdateAccess.validToken(token) else { throw PrivateUpdateAccess.AccessError.invalidToken } + return token + } + + func saveToken(_ token: String) throws { + guard PrivateUpdateAccess.validToken(token) else { throw PrivateUpdateAccess.AccessError.invalidToken } + let value = [kSecValueData as String: Data(token.utf8)] + var status = SecItemUpdate(query as CFDictionary, value as CFDictionary) + if status == errSecItemNotFound { + var item = query.merging(value) { _, new in new } + item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw PrivateUpdateAccess.AccessError.keychain(status) } + } + } +#endif diff --git a/WindowCleaner/Updates/PrivateUpdateTransport.swift b/WindowCleaner/Updates/PrivateUpdateTransport.swift new file mode 100644 index 0000000..4cca931 --- /dev/null +++ b/WindowCleaner/Updates/PrivateUpdateTransport.swift @@ -0,0 +1,302 @@ +#if os(macOS) && !APP_STORE + import Foundation + import Network + + /// Reject redirects outside GitHub's credential-free artifact delivery hosts. + final class UpdateRedirectPolicy: NSObject, URLSessionDownloadDelegate, Sendable { + let maximum: Int64 + init(maximum: Int64 = 512 * 1024 * 1024) { self.maximum = maximum } + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + if totalBytesWritten > maximum || totalBytesExpectedToWrite > maximum { downloadTask.cancel() } + } + + func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo _: URL) {} + func urlSession(_: URLSession, task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + guard let url = request.url, url.scheme == "https", url.user == nil, url.password == nil, + url.port == nil || url.port == 443, + ["release-assets.githubusercontent.com", "objects.githubusercontent.com"].contains(url.host) else { + completionHandler(nil) + return + } + var clean = request + clean.setValue(nil, forHTTPHeaderField: "Authorization") + clean.setValue(nil, forHTTPHeaderField: "Cookie") + completionHandler(clean) + } + } + + struct PrivateUpdateRelease: Decodable, Sendable { + struct Asset: Decodable, Sendable { + let id: Int + let name: String + let size: Int + let state: String + init(id: Int, name: String, size: Int, state: String = "uploaded") { + self.id = id + self.name = name + self.size = size + self.state = state + } + } + + let id: Int + enum CodingKeys: String, CodingKey { case id, tagName = "tag_name", draft, prerelease, assets } + let tagName: String + let draft: Bool + let prerelease: Bool + let assets: [Asset] + + func completeArchive(app: String, suffix: String = "-universal.zip") -> Asset? { + guard !draft, !prerelease, + tagName.range(of: #"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"#, + options: .regularExpression) != nil, + assets.count(where: { $0.name == "appcast.xml" && $0.size > 0 && $0.state == "uploaded" }) == 1, + assets.count(where: { $0.name == "release.json" && $0.size > 0 && $0.state == "uploaded" }) == 1 else { return nil } + let archive = app + "-" + String(tagName.dropFirst()) + suffix + let matches = assets.filter { $0.name == archive && $0.state == "uploaded" && $0.size > 0 && $0.size <= 512 * 1024 * 1024 } + return matches.count == 1 ? matches[0] : nil + } + } + + final class UpdateFeedValidator: NSObject, XMLParserDelegate { + private(set) var enclosures: [[String: String]] = [] + private(set) var invalid = false + func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, + qualifiedName _: String?, attributes attributeDict: [String: String]) { + if elementName == "enclosure" { enclosures.append(attributeDict) } + if elementName.hasSuffix("releaseNotesLink") || elementName.hasSuffix("fullReleaseNotesLink") { + invalid = true + } + } + + static func validate(_ data: Data, asset: PrivateUpdateRelease.Asset, repository: String) throws -> URL { + guard data.count <= 1024 * 1024 else { throw PrivateUpdateTransport.Failure.invalidFeed } + let parser = XMLParser(data: data) + parser.shouldResolveExternalEntities = false + let delegate = UpdateFeedValidator() + parser.delegate = delegate + guard parser.parse(), !delegate.invalid, delegate.enclosures.count == 1, + let enclosure = delegate.enclosures.first, + let url = URL(string: enclosure["url"] ?? ""), + url.scheme == "https", url.host == "api.github.com", url.user == nil, url.password == nil, + url.port == nil || url.port == 443, + url.path == "/repos/" + repository + "/releases/assets/" + String(asset.id), + url.fragment == nil, + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "filename", value: asset.name)], + Int(enclosure["length"] ?? "") == asset.size, + let signature = enclosure["sparkle:edSignature"], + Data(base64Encoded: signature)?.count == 64 else { + throw PrivateUpdateTransport.Failure.invalidFeed + } + return url + } + } + + /// Only this transport holds credentials. Sparkle accesses nonce-scoped loopback routes. + @MainActor + final class PrivateUpdateTransport { + enum Failure: LocalizedError { + case access, noCompleteRelease, invalidFeed, unexpectedResponse, bridge + var errorDescription: String? { + switch self { + case .access: "GitHub update access is unavailable or rate limited. Public updates normally need no token; optionally configure read-only GitHub access and retry." + case .noCompleteRelease: "No completed signed update is available yet." + case .invalidFeed: "The release update metadata is incomplete or invalid." + case .unexpectedResponse: "The update download did not match the completed release." + case .bridge: "Could not prepare the local update connection." + } + } + } + + let repository: String + let app: String + let archiveSuffix: String + var failureHandler: (@MainActor (any Error) -> Void)? + private let session: URLSession + private let sessionConfiguration: URLSessionConfiguration + private var listener: NWListener? + private var bridgeContinuation: CheckedContinuation? + private var nonce = UUID().uuidString + private var feed = Data() + private var archiveAsset: PrivateUpdateRelease.Asset? + private var archiveFile: URL? + private var tokenProvider: (@MainActor () throws -> String?)? + private var preparing = false + private(set) var feedURL: URL? + private(set) var archiveURL: URL? + private(set) var sourceArchiveURL: URL? + + init(repository: String, app: String, archiveSuffix: String = "-universal.zip", configuration: URLSessionConfiguration = .ephemeral) { + self.repository = repository + self.app = app + self.archiveSuffix = archiveSuffix + let config = configuration + config.httpCookieStorage = nil + config.urlCache = nil + config.timeoutIntervalForRequest = 60 + sessionConfiguration = config + session = URLSession(configuration: config, delegate: UpdateRedirectPolicy(), delegateQueue: nil) + } + + private func fetch(path: String, token: String?, binary: Bool, maximum: Int) async throws -> Data { + let url = URL(string: "https://api.github.com/repos/" + repository + "/" + path)! + var request = URLRequest(url: url) + if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } + request.setValue(binary ? "application/octet-stream" : "application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw Failure.unexpectedResponse } + if [401, 403, 404].contains(response.statusCode) { throw Failure.access } + guard response.statusCode == 200, data.count <= maximum else { throw Failure.unexpectedResponse } + return data + } + + func prepare(token: String?, tokenProvider: @escaping @MainActor () throws -> String?) async throws { + guard !preparing else { throw Failure.bridge } + preparing = true + defer { preparing = false } + self.tokenProvider = tokenProvider + let data = try await fetch(path: "releases?per_page=30", token: token, binary: false, maximum: 4 * 1024 * 1024) + let releases = try JSONDecoder().decode([PrivateUpdateRelease].self, from: data) + guard let release = releases.first(where: { $0.completeArchive(app: app, suffix: archiveSuffix) != nil }), + let asset = release.completeArchive(app: app, suffix: archiveSuffix), + let feedAsset = release.assets.first(where: { $0.name == "appcast.xml" }) else { throw Failure.noCompleteRelease } + let nextFeed = try await fetch(path: "releases/assets/" + String(feedAsset.id), token: token, binary: true, maximum: 1024 * 1024) + let source = try UpdateFeedValidator.validate(nextFeed, asset: asset, repository: repository) + listener?.cancel() + nonce = UUID().uuidString + feed = nextFeed + if let archiveFile { try? FileManager.default.removeItem(at: archiveFile) } + archiveFile = nil + archiveAsset = asset + sourceArchiveURL = source + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + let bridge = try NWListener(using: parameters) + listener = bridge + bridge.newConnectionHandler = { [weak self] connection in + Task { @MainActor in self?.serve(connection) } + } + let port = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + bridgeContinuation = continuation + bridge.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self, let pending = self.bridgeContinuation else { return } + switch state { + case .ready: + self.bridgeContinuation = nil + if let port = bridge.port { pending.resume(returning: port) } + else { pending.resume(throwing: Failure.bridge) } + case .failed, .cancelled: + self.bridgeContinuation = nil + pending.resume(throwing: Failure.bridge) + default: break + } + } + } + bridge.start(queue: .main) + } + let base = "http://127.0.0.1:" + String(port.rawValue) + "/" + nonce + "/" + feedURL = URL(string: base + "appcast.xml") + archiveURL = URL(string: base + asset.name) + } + + private func serve(_ connection: NWConnection) { + connection.start(queue: .main) + receiveHeader(connection, accumulated: Data()) + } + + private func receiveHeader(_ connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192 - accumulated.count) { [weak self] data, _, complete, error in + Task { @MainActor in + guard let self, let data, error == nil else { connection.cancel() + return + } + let header = accumulated + data + guard header.count <= 8192 else { connection.cancel() + return + } + if header.range(of: Data("\r\n\r\n".utf8)) == nil { + guard !complete, header.count < 8192 else { connection.cancel() + return + } + self.receiveHeader(connection, accumulated: header) + return + } + guard let request = String(data: header, encoding: .utf8), + let first = request.components(separatedBy: "\r\n").first else { connection.cancel() + return + } + let pieces = first.split(separator: " ") + guard pieces.count == 3, pieces[0] == "GET" else { connection.cancel() + return + } + let path = String(pieces[1]) + let body: Data + let type: String + if path == self.feedURL?.path { body = self.feed + type = "application/xml" + } else if path == self.archiveURL?.path { + do { try await self.sendArchive(connection) } + catch { + self.failureHandler?(error) + connection.send(content: Data("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), + completion: .contentProcessed { _ in connection.cancel() }) + } + return + } else { connection.cancel() + return + } + let responseHeader = Data("HTTP/1.1 200 OK\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n".utf8) + connection.send(content: responseHeader + body, completion: .contentProcessed { _ in connection.cancel() }) + } + } + } + + private func sendArchive(_ connection: NWConnection) async throws { + guard let asset = archiveAsset, let tokenProvider else { throw Failure.bridge } + if archiveFile == nil { + let token = try tokenProvider() + var request = URLRequest(url: URL(string: "https://api.github.com/repos/" + repository + "/releases/assets/" + String(asset.id))!) + if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let config = sessionConfiguration.copy() as! URLSessionConfiguration + config.httpCookieStorage = nil + config.urlCache = nil + let downloadSession = URLSession(configuration: config, delegate: UpdateRedirectPolicy(maximum: Int64(asset.size)), delegateQueue: nil) + defer { downloadSession.invalidateAndCancel() } + let (temporary, response) = try await downloadSession.download(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200, + try temporary.resourceValues(forKeys: [.fileSizeKey]).fileSize == asset.size else { throw Failure.access } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let destination = directory.appendingPathComponent(asset.name) + try FileManager.default.moveItem(at: temporary, to: destination) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + archiveFile = destination + } + guard let archiveFile else { throw Failure.bridge } + let file = try FileHandle(forReadingFrom: archiveFile) + defer { try? file.close() } + try await send(Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(asset.size)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n".utf8), to: connection) + while let chunk = try file.read(upToCount: 256 * 1024), !chunk.isEmpty { + try Task.checkCancellation() + try await send(chunk, to: connection) + } + connection.cancel() + } + + private func send(_ data: Data, to connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + }) + } + } + } +#endif diff --git a/WindowCleaner/Updates/UpdateController.swift b/WindowCleaner/Updates/UpdateController.swift new file mode 100644 index 0000000..57cda4a --- /dev/null +++ b/WindowCleaner/Updates/UpdateController.swift @@ -0,0 +1,202 @@ +#if os(macOS) && !APP_STORE + import AppKit + import Observation + import Sparkle + import SwiftUI + + @MainActor @Observable + final class UpdateController: NSObject, SPUUpdaterDelegate { + static let shared = UpdateController(repository: "fernandobelotto/WindowCleaner", app: "WindowCleaner") + private(set) var checking = false + private(set) var status: String? + private let tokenStore: any UpdateTokenStore + private let defaults: UserDefaults + private let transport: PrivateUpdateTransport + @ObservationIgnored private var controller: SPUStandardUpdaterController? + @ObservationIgnored private var timer: Timer? + private var cycleActive = false + private(set) var hasPendingInstall = false + @ObservationIgnored private var pendingInstallHandler: (() -> Void)? + var restartGuard: @MainActor () -> Bool = { false } + + var isSafeToRestart: Bool { + restartGuard() && !NSDocumentController.shared.hasEditedDocuments && !NSApp.windows.contains { $0.attachedSheet != nil } + } + + init(tokenStore: (any UpdateTokenStore)? = nil, defaults: UserDefaults = .standard, + repository: String? = nil, app: String? = nil, schedulesChecks: Bool = true) { + self.tokenStore = tokenStore ?? KeychainUpdateTokenStore() + self.defaults = defaults + let name = app ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "App") + transport = PrivateUpdateTransport(repository: repository ?? "fernandobelotto/" + name, app: name) + super.init() + transport.failureHandler = { [weak self] error in self?.status = error.localizedDescription } + defaults.register(defaults: ["PrivateUpdatesAutomaticChecks": true, "PrivateUpdatesAutomaticInstall": false]) + if schedulesChecks, ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + timer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in + Task { @MainActor in self?.scheduledCheck() } + } + Task { @MainActor [weak self] in self?.scheduledCheck() } + } + } + + var automaticallyChecksForUpdates: Bool { + get { defaults.bool(forKey: "PrivateUpdatesAutomaticChecks") } + set { defaults.set(newValue, forKey: "PrivateUpdatesAutomaticChecks") } + } + + var automaticallyInstallsUpdates: Bool { + get { defaults.bool(forKey: "PrivateUpdatesAutomaticInstall") } + set { + defaults.set(newValue, forKey: "PrivateUpdatesAutomaticInstall") + controller?.updater.automaticallyDownloadsUpdates = newValue + } + } + + var canCheckForUpdates: Bool { !checking && !hasPendingInstall && (!cycleActive || controller?.updater.canCheckForUpdates == true) } + + func scheduledCheck() { + guard automaticallyChecksForUpdates else { return } + checkForUpdates(userInitiated: false) + } + + func checkForUpdates(userInitiated: Bool = true) { + if userInitiated, cycleActive, !hasPendingInstall, controller?.updater.canCheckForUpdates == true { + controller?.checkForUpdates(nil) + return + } + guard canCheckForUpdates, !cycleActive else { return } + checking = true + status = nil + Task { @MainActor in + defer { checking = false } + do { + // This repository is already public. Anonymous GitHub downloads remain supported. + let token = try? tokenStore.readToken() + try await transport.prepare(token: token) { [tokenStore] in try? tokenStore.readToken() } + if controller == nil { + let next = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) + next.updater.automaticallyChecksForUpdates = false + next.updater.automaticallyDownloadsUpdates = automaticallyInstallsUpdates + try next.updater.start() + controller = next + } + cycleActive = true + if userInitiated { controller?.checkForUpdates(nil) } + else { controller?.updater.checkForUpdatesInBackground() } + } catch { + status = error.localizedDescription + if userInitiated { + let alert = NSAlert() + alert.messageText = "Could Not Check for Updates" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + } + } + + func configurePrivateUpdates() { + let alert = NSAlert() + alert.messageText = "Optional GitHub Access" + alert.informativeText = "Public updates work without a token. For higher API limits, optionally use a GitHub fine-grained token limited to \(transport.repository), with Contents: Read-only. It stays in this Mac’s Keychain." + alert.addButton(withTitle: "Save and Check") + alert.addButton(withTitle: "Cancel") + let field = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 420, height: 24)) + field.placeholderString = "GitHub personal access token" + alert.accessoryView = field + alert.window.initialFirstResponder = field + guard alert.runModal() == .alertFirstButtonReturn else { field.stringValue = "" + return + } + defer { field.stringValue = "" } + do { try tokenStore.saveToken(field.stringValue) + checkForUpdates() + } catch { status = error.localizedDescription } + } + + func feedURLString(for _: SPUUpdater) -> String? { transport.feedURL?.absoluteString } + func updater(_: SPUUpdater, shouldDownloadReleaseNotesForUpdate _: SUAppcastItem) -> Bool { false } + func updater(_: SPUUpdater, willDownloadUpdate _: SUAppcastItem, with request: NSMutableURLRequest) { + guard request.url == transport.sourceArchiveURL, let destination = transport.archiveURL else { + request.url = URL(string: "http://127.0.0.1:1/rejected-update") + return + } + request.url = destination + request.setValue(nil, forHTTPHeaderField: "Authorization") + request.setValue(nil, forHTTPHeaderField: "Cookie") + } + + func updater(_: SPUUpdater, didFinishUpdateCycleFor _: SPUUpdateCheck, error: (any Error)?) { + cycleActive = false + if status == nil { status = error?.localizedDescription } + } + + func updater(_: SPUUpdater, shouldPostponeRelaunchForUpdate _: SUAppcastItem, + untilInvokingBlock installHandler: @escaping () -> Void) -> Bool { + deferInstallation(installHandler) + installDownloadedUpdate() + return true + } + + func deferInstallation(_ installHandler: @escaping () -> Void) { + pendingInstallHandler = installHandler + hasPendingInstall = true + } + + func installDownloadedUpdate() { + guard hasPendingInstall else { return } + guard isSafeToRestart else { + status = "Finish recording, active operations and unsaved work before installing this update." + return + } + let alert = NSAlert() + alert.messageText = "Restart to Install Update?" + alert.informativeText = "Finish active operations and save your work before restarting. You can install later." + alert.addButton(withTitle: "Restart Now") + alert.addButton(withTitle: "Later") + guard alert.runModal() == .alertFirstButtonReturn else { return } + Task { @MainActor in + guard self.isSafeToRestart, let handler = self.pendingInstallHandler else { + self.status = "Save your work and finish active operations before restarting." + return + } + self.pendingInstallHandler = nil + self.hasPendingInstall = false + handler() + } + } + } + + struct PrivateUpdateSettings: View { + @State private var updater = UpdateController.shared + var body: some View { + Form { + Section("Updates") { + Toggle("Automatically check for updates", isOn: $updater.automaticallyChecksForUpdates) + Toggle("Automatically download and install updates", isOn: $updater.automaticallyInstallsUpdates) + Text("Installation asks before restarting so you can finish and save your work.").font(.caption) + Button("Optional GitHub Access…") { updater.configurePrivateUpdates() } + Button("Check for Updates…") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates) + + if updater.hasPendingInstall { + Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } + } + if let status = updater.status { Text(status).font(.caption).foregroundStyle(.secondary) } + } + }.padding() + } + } +#endif + +#if os(macOS) && !APP_STORE + struct UpdateCommands: Commands { + @State private var updater = UpdateController.shared + var body: some Commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates) + if updater.hasPendingInstall { Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } } + } + } + } +#endif diff --git a/WindowCleaner/Views/Settings/SettingsView.swift b/WindowCleaner/Views/Settings/SettingsView.swift index e6420fb..572da76 100644 --- a/WindowCleaner/Views/Settings/SettingsView.swift +++ b/WindowCleaner/Views/Settings/SettingsView.swift @@ -9,6 +9,9 @@ import UserNotifications struct SettingsView: View { var body: some View { TabView { + #if !APP_STORE + PrivateUpdateSettings().tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath") } + #endif GeneralSettingsTab() .tabItem { Label("General", systemImage: "gear") diff --git a/WindowCleaner/WindowCleanerApp.swift b/WindowCleaner/WindowCleanerApp.swift index 920d364..758a24e 100644 --- a/WindowCleaner/WindowCleanerApp.swift +++ b/WindowCleaner/WindowCleanerApp.swift @@ -29,12 +29,20 @@ struct WindowCleanerApp: App { // Main Window WindowGroup { MainContentView() + .task { + #if !APP_STORE + UpdateController.shared.restartGuard = { !StoreManager.shared.isLoading && !sharedModelContainer.mainContext.hasChanges } + #endif + } .frame(minWidth: 700, minHeight: 500) .environment(storeManager) } .modelContainer(sharedModelContainer) .commands { appCommands + #if !APP_STORE + UpdateCommands() + #endif } // Menu Bar Extra diff --git a/WindowCleaner/WindowCleanerAppStore.entitlements b/WindowCleaner/WindowCleanerAppStore.entitlements new file mode 100644 index 0000000..b7e16c7 --- /dev/null +++ b/WindowCleaner/WindowCleanerAppStore.entitlements @@ -0,0 +1,14 @@ + + + + + + + com.apple.security.app-sandbox + + + + com.apple.security.automation.apple-events + + + diff --git a/WindowCleanerTests/UpdateTests.swift b/WindowCleanerTests/UpdateTests.swift new file mode 100644 index 0000000..f21b340 --- /dev/null +++ b/WindowCleanerTests/UpdateTests.swift @@ -0,0 +1,80 @@ +#if !APP_STORE + import Foundation + import Testing + #if !UPDATER_TEST_HARNESS + @testable import WindowCleaner + #endif + + @MainActor + struct UpdateTests { + final class Tokens: UpdateTokenStore { + var value: String? + func readToken() throws -> String? { value } + func saveToken(_ token: String) throws { value = token } + } + + @Test func tokensRejectWhitespaceAndMissingAccess() { + #expect(PrivateUpdateAccess.validToken("github_pat_abc123")) + for token in ["", "github pat", "token\n", "token\r", "é"] { + #expect(!PrivateUpdateAccess.validToken(token)) + } + #expect(Tokens().value == nil) + } + + @Test func restartIsDeniedUntilLifecycleGuardAllowsIt() { + let updater = UpdateController(tokenStore: Tokens(), repository: "fernandobelotto/WindowCleaner", app: "WindowCleaner", schedulesChecks: false) + #expect(!updater.isSafeToRestart) + updater.restartGuard = { false } + #expect(!updater.isSafeToRestart) + } + + @Test func unsafeInstallRetainsPendingHandlerWithoutRestarting() { + let updater = UpdateController(tokenStore: Tokens(), schedulesChecks: false) + var restarted = false + updater.deferInstallation { restarted = true } + updater.restartGuard = { false } + updater.installDownloadedUpdate() + #expect(updater.hasPendingInstall) + #expect(!updater.canCheckForUpdates) + #expect(!restarted) + } + + @Test func checkAndInstallPreferencesPersistIndependently() throws { + let suite = "update-tests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let updater = UpdateController(tokenStore: Tokens(), defaults: defaults, schedulesChecks: false) + updater.automaticallyChecksForUpdates = false + updater.automaticallyInstallsUpdates = true + let restored = UpdateController(tokenStore: Tokens(), defaults: defaults, schedulesChecks: false) + #expect(!restored.automaticallyChecksForUpdates) + #expect(restored.automaticallyInstallsUpdates) + } + + @Test func incompleteDraftAndPrereleaseArtifactsAreIgnored() throws { + let json = #"[{"id":1,"tag_name":"v1.0.0","draft":false,"prerelease":false,"assets":[{"id":2,"name":"appcast.xml","size":100,"state":"uploaded"},{"id":3,"name":"release.json","size":100,"state":"uploaded"},{"id":4,"name":"WindowCleaner-1.0.0-universal.zip","size":100,"state":"uploaded"}]}]"# + let release = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(json.utf8))[0] + #expect(release.completeArchive(app: "WindowCleaner")?.id == 4) + for value in ["draft", "prerelease"] { + let modified = json.replacingOccurrences(of: "\"" + value + "\":false", with: "\"" + value + "\":true") + let parsed = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(modified.utf8))[0] + #expect(parsed.completeArchive(app: "WindowCleaner") == nil) + } + let incompleteUpload = json.replacingOccurrences(of: "uploaded", with: "starter") + let pending = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(incompleteUpload.utf8))[0] + #expect(pending.completeArchive(app: "WindowCleaner") == nil) + for asset in ["appcast.xml", "release.json", "WindowCleaner-1.0.0-universal.zip"] { + let modified = json.replacingOccurrences(of: asset, with: "missing") + let parsed = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(modified.utf8))[0] + #expect(parsed.completeArchive(app: "WindowCleaner") == nil) + } + } + + @Test func malformedFeedAndMissingSignatureAreRejected() { + let asset = PrivateUpdateRelease.Asset(id: 4, name: "WindowCleaner-1.0.0-universal.zip", size: 10) + for xml in ["bad XML", "", ""] { + #expect(throws: (any Error).self) { try UpdateFeedValidator.validate(Data(xml.utf8), asset: asset, repository: "fernandobelotto/WindowCleaner") } + } + } + } +#endif diff --git a/docs/native-updates.md b/docs/native-updates.md new file mode 100644 index 0000000..f990e98 --- /dev/null +++ b/docs/native-updates.md @@ -0,0 +1,21 @@ +# Native updates + +The direct `WindowCleaner` target uses Sparkle 2.10 with the completed signed release artifacts. Update access is separated from GitHub release publishing: clients never receive signing keys or a shared publisher token. + +This repository is already public. Anonymous release discovery and downloads are supported; an optional per-user read-only GitHub token can raise API limits. Existing repository visibility is unchanged. + +Checks require a stable release containing uploaded `appcast.xml`, `release.json` and `WindowCleaner--universal.zip`. Drafts, prereleases, missing/unuploaded artifacts and invalid metadata are ignored. Downloads preserve the original signed feed and use a nonce-scoped loopback bridge. Archive fetching is lazy; checking does not download the archive. GitHub credentials are stripped on allowlisted artifact redirects, and foreign origins are rejected. Sparkle verifies archive signatures. + +Automatic checking and automatic installation are separate persisted preferences. Automatic installation defaults off. Manual checks remain available. Installation requires an explicit restart confirmation, rechecks lifecycle safety after confirmation, and retains the downloaded installation callback when deferred; use Install Downloaded Update when ready. Active store operations, pending SwiftData edits and open native document/sheet work prevent restart. + +The `WindowCleanerAppStore` target defines `APP_STORE` and uses an independent plist and original entitlements. It has no Sparkle product dependency, linked framework, updater UI or updater plist keys. This separate target prevents a compile flag from accidentally leaving the self-updater linked. Existing Store/payment capabilities and distribution identities are preserved; Store eligibility and notarization remain distribution checks. + +## Verification + +`Scripts/test-update-transport.sh` compiles a standalone fixture executable, without launching the app or opening its user data. It verifies stable/uploaded artifact metadata, malformed/foreign feeds, cache-free loopback feed bytes, no archive download during discovery, expired access and redirect credential removal. + +`xcodebuild -project WindowCleaner.xcodeproj -scheme WindowCleanerUpdates -destination 'platform=macOS' -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO test` runs the independent unhosted updater test target. It compiles the updater implementation directly, verifies preferences/credentials/deferral safety, and bypasses unrelated legacy template test fixtures. + +`xcodebuild -project WindowCleaner.xcodeproj -scheme WindowCleanerAppStore -configuration Release -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO build` verifies the updater-free Store variant. The updater CI additionally checks `otool` linkage, framework absence and Store plist keys. + +Unsigned local builds establish compiler and linkage correctness; release signing/notarization and a live upgrade require completed signed artifacts through the separately documented release workflow. No test here publishes releases or installs/relaunches an existing application. From 34b6c4ed8017e9855ef41685f716f1e2b1d35986 Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Wed, 7 Oct 2026 11:14:29 -0300 Subject: [PATCH 3/7] fix: harden update transport failures and match source style --- Tests/Updater/TransportFixtures.swift | 79 +++++-- .../Updates/PrivateUpdateTransport.swift | 223 ++++++++++++------ WindowCleaner/Updates/UpdateController.swift | 49 ++-- WindowCleanerTests/UpdateTests.swift | 37 ++- 4 files changed, 279 insertions(+), 109 deletions(-) diff --git a/Tests/Updater/TransportFixtures.swift b/Tests/Updater/TransportFixtures.swift index f9742b9..e402375 100644 --- a/Tests/Updater/TransportFixtures.swift +++ b/Tests/Updater/TransportFixtures.swift @@ -3,30 +3,50 @@ import Synchronization @main struct UpdateTransportFixtures { - @MainActor static func main() async throws { + @MainActor + static func main() async throws { let signature = Data(repeating: 0, count: 64).base64EncodedString() let archive = PrivateUpdateRelease.Asset(id: 17, name: "Fixture-1.2.3-universal.zip", size: 123) let feed = Data(""" - + """.utf8) _ = try UpdateFeedValidator.validate(feed, asset: archive, repository: "test/Fixture") for bad in [String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "assets/17", with: "assets/18"), String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "test/Fixture", with: "test/Other"), - String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "length=\"123\"", with: "length=\"124\""), - String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "api.github.com", with: "evil.example"), - String(data: feed, encoding: .utf8)!.replacingOccurrences(of: "", with: "https://evil.example"), - "", ""] { + String(data: feed, encoding: .utf8)!.replacingOccurrences( + of: "length=\"123\"", + with: "length=\"124\"" + ), + String(data: feed, encoding: .utf8)!.replacingOccurrences( + of: "api.github.com", + with: "evil.example" + ), + String(data: feed, encoding: .utf8)!.replacingOccurrences( + of: "", + with: "https://evil.example" + ), + "", + ""] { var failed = false do { _ = try UpdateFeedValidator.validate(Data(bad.utf8), asset: archive, repository: "test/Fixture") } catch { failed = true } precondition(failed, "Untrusted feed must fail") } - let assets = [archive, .init(id: 18, name: "appcast.xml", size: 123), .init(id: 19, name: "release.json", size: 123)] + let assets = [ + archive, + .init(id: 18, name: "appcast.xml", size: 123), + .init(id: 19, name: "release.json", size: 123), + ] let release = PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: false, assets: assets) precondition(release.completeArchive(app: "Fixture") != nil) - precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: true, prerelease: false, assets: assets).completeArchive(app: "Fixture") == nil) - precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: true, assets: assets).completeArchive(app: "Fixture") == nil) - precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: false, assets: [archive]).completeArchive(app: "Fixture") == nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: true, prerelease: false, assets: assets) + .completeArchive(app: "Fixture") == nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: true, assets: assets) + .completeArchive(app: "Fixture") == nil) + precondition(PrivateUpdateRelease(id: 3, tagName: "v1.2.3", draft: false, prerelease: false, assets: [archive]) + .completeArchive(app: "Fixture") == nil) precondition(!PrivateUpdateAccess.validToken("token with spaces")) precondition(PrivateUpdateAccess.validToken("github_pat_example123")) let config = URLSessionConfiguration.ephemeral @@ -40,21 +60,42 @@ struct UpdateTransportFixtures { } let transport = PrivateUpdateTransport(repository: "test/Fixture", app: "Fixture", configuration: config) try await transport.prepare(token: nil) { nil } - precondition(FixtureUpdateProtocol.paths.withLock { !$0.contains("/repos/test/Fixture/releases/assets/17") }, "Checking must not download archives") + precondition( + FixtureUpdateProtocol.paths.withLock { !$0.contains("/repos/test/Fixture/releases/assets/17") }, + "Checking must not download archives" + ) let (servedFeed, response) = try await URLSession.shared.data(from: transport.feedURL!) - precondition((response as? HTTPURLResponse)?.statusCode == 200 && servedFeed == feed, "Loopback must preserve feed bytes") + precondition( + (response as? HTTPURLResponse)?.statusCode == 200 && servedFeed == feed, + "Loopback must preserve feed bytes" + ) var redirected = URLRequest(url: URL(string: "https://release-assets.githubusercontent.com/fixture")!) redirected.setValue("Bearer github_pat_fixture", forHTTPHeaderField: "Authorization") redirected.setValue("session=fixture", forHTTPHeaderField: "Cookie") let redirectSession = URLSession(configuration: .ephemeral) let redirectTask = redirectSession.dataTask(with: URL(string: "https://api.github.com")!) - let redirectResponse = HTTPURLResponse(url: URL(string: "https://api.github.com")!, statusCode: 302, httpVersion: nil, headerFields: nil)! - UpdateRedirectPolicy().urlSession(redirectSession, task: redirectTask, willPerformHTTPRedirection: redirectResponse, newRequest: redirected) { clean in + let redirectResponse = HTTPURLResponse( + url: URL(string: "https://api.github.com")!, + statusCode: 302, + httpVersion: nil, + headerFields: nil + )! + UpdateRedirectPolicy().urlSession( + redirectSession, + task: redirectTask, + willPerformHTTPRedirection: redirectResponse, + newRequest: redirected + ) { clean in precondition(clean?.value(forHTTPHeaderField: "Authorization") == nil) precondition(clean?.value(forHTTPHeaderField: "Cookie") == nil) } redirected.url = URL(string: "https://evil.example/fixture")! - UpdateRedirectPolicy().urlSession(redirectSession, task: redirectTask, willPerformHTTPRedirection: redirectResponse, newRequest: redirected) { clean in + UpdateRedirectPolicy().urlSession( + redirectSession, + task: redirectTask, + willPerformHTTPRedirection: redirectResponse, + newRequest: redirected + ) { clean in precondition(clean == nil) } FixtureUpdateProtocol.statuses.withLock { $0["/repos/test/Fixture/releases"] = 401 } @@ -78,10 +119,16 @@ final class FixtureUpdateProtocol: URLProtocol, @unchecked Sendable { let path = request.url!.path Self.paths.withLock { $0.append(path) } let body = Self.responses.withLock { $0[path] } - let response = HTTPURLResponse(url: request.url!, statusCode: Self.statuses.withLock { $0[path] } ?? (body == nil ? 404 : 200), httpVersion: "HTTP/1.1", headerFields: nil)! + let response = HTTPURLResponse( + url: request.url!, + statusCode: Self.statuses.withLock { $0[path] } ?? (body == nil ? 404 : 200), + httpVersion: "HTTP/1.1", + headerFields: nil + )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: body ?? Data()) client?.urlProtocolDidFinishLoading(self) } + override func stopLoading() {} } diff --git a/WindowCleaner/Updates/PrivateUpdateTransport.swift b/WindowCleaner/Updates/PrivateUpdateTransport.swift index 4cca931..fed2e97 100644 --- a/WindowCleaner/Updates/PrivateUpdateTransport.swift +++ b/WindowCleaner/Updates/PrivateUpdateTransport.swift @@ -1,3 +1,4 @@ +// Keep the bridge's asynchronous state transitions together for auditing. #if os(macOS) && !APP_STORE import Foundation import Network @@ -6,15 +7,24 @@ final class UpdateRedirectPolicy: NSObject, URLSessionDownloadDelegate, Sendable { let maximum: Int64 init(maximum: Int64 = 512 * 1024 * 1024) { self.maximum = maximum } - func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, - didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { if totalBytesWritten > maximum || totalBytesExpectedToWrite > maximum { downloadTask.cancel() } } func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo _: URL) {} - func urlSession(_: URLSession, task _: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void) { + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { guard let url = request.url, url.scheme == "https", url.user == nil, url.password == nil, url.port == nil || url.port == 443, ["release-assets.githubusercontent.com", "objects.githubusercontent.com"].contains(url.host) else { @@ -51,12 +61,16 @@ func completeArchive(app: String, suffix: String = "-universal.zip") -> Asset? { guard !draft, !prerelease, - tagName.range(of: #"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"#, - options: .regularExpression) != nil, + tagName.range( + of: #"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"#, + options: .regularExpression + ) != nil, assets.count(where: { $0.name == "appcast.xml" && $0.size > 0 && $0.state == "uploaded" }) == 1, - assets.count(where: { $0.name == "release.json" && $0.size > 0 && $0.state == "uploaded" }) == 1 else { return nil } + assets.count(where: { $0.name == "release.json" && $0.size > 0 && $0.state == "uploaded" }) == 1 + else { return nil } let archive = app + "-" + String(tagName.dropFirst()) + suffix - let matches = assets.filter { $0.name == archive && $0.state == "uploaded" && $0.size > 0 && $0.size <= 512 * 1024 * 1024 } + let matches = assets + .filter { $0.name == archive && $0.state == "uploaded" && $0.size > 0 && $0.size <= 512 * 1024 * 1024 } return matches.count == 1 ? matches[0] : nil } } @@ -64,8 +78,13 @@ final class UpdateFeedValidator: NSObject, XMLParserDelegate { private(set) var enclosures: [[String: String]] = [] private(set) var invalid = false - func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, - qualifiedName _: String?, attributes attributeDict: [String: String]) { + func parser( + _: XMLParser, + didStartElement elementName: String, + namespaceURI _: String?, + qualifiedName _: String?, + attributes attributeDict: [String: String] + ) { if elementName == "enclosure" { enclosures.append(attributeDict) } if elementName.hasSuffix("releaseNotesLink") || elementName.hasSuffix("fullReleaseNotesLink") { invalid = true @@ -85,7 +104,10 @@ url.port == nil || url.port == 443, url.path == "/repos/" + repository + "/releases/assets/" + String(asset.id), url.fragment == nil, - URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "filename", value: asset.name)], + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem( + name: "filename", + value: asset.name + )], Int(enclosure["length"] ?? "") == asset.size, let signature = enclosure["sparkle:edSignature"], Data(base64Encoded: signature)?.count == 64 else { @@ -102,7 +124,9 @@ case access, noCompleteRelease, invalidFeed, unexpectedResponse, bridge var errorDescription: String? { switch self { - case .access: "GitHub update access is unavailable or rate limited. Public updates normally need no token; optionally configure read-only GitHub access and retry." + case .access: + "GitHub update access is unavailable or rate limited. Public updates normally need no token; " + + "optionally configure read-only GitHub access and retry." case .noCompleteRelease: "No completed signed update is available yet." case .invalidFeed: "The release update metadata is incomplete or invalid." case .unexpectedResponse: "The update download did not match the completed release." @@ -129,7 +153,12 @@ private(set) var archiveURL: URL? private(set) var sourceArchiveURL: URL? - init(repository: String, app: String, archiveSuffix: String = "-universal.zip", configuration: URLSessionConfiguration = .ephemeral) { + init( + repository: String, + app: String, + archiveSuffix: String = "-universal.zip", + configuration: URLSessionConfiguration = .ephemeral + ) { self.repository = repository self.app = app self.archiveSuffix = archiveSuffix @@ -142,10 +171,14 @@ } private func fetch(path: String, token: String?, binary: Bool, maximum: Int) async throws -> Data { - let url = URL(string: "https://api.github.com/repos/" + repository + "/" + path)! + guard let url = URL(string: "https://api.github.com/repos/" + repository + "/" + path) + else { throw Failure.unexpectedResponse } var request = URLRequest(url: url) if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } - request.setValue(binary ? "application/octet-stream" : "application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue( + binary ? "application/octet-stream" : "application/vnd.github+json", + forHTTPHeaderField: "Accept" + ) request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw Failure.unexpectedResponse } @@ -154,17 +187,29 @@ return data } + // swiftlint:disable:next function_body_length func prepare(token: String?, tokenProvider: @escaping @MainActor () throws -> String?) async throws { guard !preparing else { throw Failure.bridge } preparing = true defer { preparing = false } self.tokenProvider = tokenProvider - let data = try await fetch(path: "releases?per_page=30", token: token, binary: false, maximum: 4 * 1024 * 1024) + let data = try await fetch( + path: "releases?per_page=30", + token: token, + binary: false, + maximum: 4 * 1024 * 1024 + ) let releases = try JSONDecoder().decode([PrivateUpdateRelease].self, from: data) guard let release = releases.first(where: { $0.completeArchive(app: app, suffix: archiveSuffix) != nil }), let asset = release.completeArchive(app: app, suffix: archiveSuffix), - let feedAsset = release.assets.first(where: { $0.name == "appcast.xml" }) else { throw Failure.noCompleteRelease } - let nextFeed = try await fetch(path: "releases/assets/" + String(feedAsset.id), token: token, binary: true, maximum: 1024 * 1024) + let feedAsset = release.assets.first(where: { $0.name == "appcast.xml" }) + else { throw Failure.noCompleteRelease } + let nextFeed = try await fetch( + path: "releases/assets/" + String(feedAsset.id), + token: token, + binary: true, + maximum: 1024 * 1024 + ) let source = try UpdateFeedValidator.validate(nextFeed, asset: asset, repository: repository) listener?.cancel() nonce = UUID().uuidString @@ -180,7 +225,10 @@ bridge.newConnectionHandler = { [weak self] connection in Task { @MainActor in self?.serve(connection) } } - let port = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let port = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + NWEndpoint.Port, + any Error + >) in bridgeContinuation = continuation bridge.stateUpdateHandler = { [weak self] state in Task { @MainActor in @@ -188,8 +236,7 @@ switch state { case .ready: self.bridgeContinuation = nil - if let port = bridge.port { pending.resume(returning: port) } - else { pending.resume(throwing: Failure.bridge) } + if let port = bridge.port { pending.resume(returning: port) } else { pending.resume(throwing: Failure.bridge) } case .failed, .cancelled: self.bridgeContinuation = nil pending.resume(throwing: Failure.bridge) @@ -209,71 +256,102 @@ receiveHeader(connection, accumulated: Data()) } + // swiftlint:disable:next function_body_length private func receiveHeader(_ connection: NWConnection, accumulated: Data) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 8192 - accumulated.count) { [weak self] data, _, complete, error in - Task { @MainActor in - guard let self, let data, error == nil else { connection.cancel() - return - } - let header = accumulated + data - guard header.count <= 8192 else { connection.cancel() - return - } - if header.range(of: Data("\r\n\r\n".utf8)) == nil { - guard !complete, header.count < 8192 else { connection.cancel() + connection + .receive( + minimumIncompleteLength: 1, + maximumLength: 8192 - accumulated.count + ) { [weak self] data, _, complete, error in + Task { @MainActor in + guard let self, let data, error == nil else { connection.cancel() return } - self.receiveHeader(connection, accumulated: header) - return - } - guard let request = String(data: header, encoding: .utf8), - let first = request.components(separatedBy: "\r\n").first else { connection.cancel() - return - } - let pieces = first.split(separator: " ") - guard pieces.count == 3, pieces[0] == "GET" else { connection.cancel() - return - } - let path = String(pieces[1]) - let body: Data - let type: String - if path == self.feedURL?.path { body = self.feed - type = "application/xml" - } else if path == self.archiveURL?.path { - do { try await self.sendArchive(connection) } - catch { - self.failureHandler?(error) - connection.send(content: Data("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), - completion: .contentProcessed { _ in connection.cancel() }) + let header = accumulated + data + guard header.count <= 8192 else { connection.cancel() + return + } + if header.range(of: Data("\r\n\r\n".utf8)) == nil { + guard !complete, header.count < 8192 else { connection.cancel() + return + } + self.receiveHeader(connection, accumulated: header) + return } - return - } else { connection.cancel() - return + guard let request = String(data: header, encoding: .utf8), + let first = request.components(separatedBy: "\r\n").first else { connection.cancel() + return + } + let pieces = first.split(separator: " ") + guard pieces.count == 3, pieces[0] == "GET" else { connection.cancel() + return + } + let path = String(pieces[1]) + let body: Data + let type: String + if path == self.feedURL?.path { body = self.feed + type = "application/xml" + } else if path == self.archiveURL?.path { + do { try await self.sendArchive(connection) } catch { + self.failureHandler?(error) + connection.send( + content: Data( + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .utf8 + ), + completion: .contentProcessed { _ in connection.cancel() } + ) + } + return + } else { connection.cancel() + return + } + let responseHeader = Data( + "HTTP/1.1 200 OK\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" + .utf8 + ) + connection.send( + content: responseHeader + body, + completion: .contentProcessed { _ in connection.cancel() } + ) } - let responseHeader = Data("HTTP/1.1 200 OK\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n".utf8) - connection.send(content: responseHeader + body, completion: .contentProcessed { _ in connection.cancel() }) } - } } + // swiftlint:disable:next function_body_length private func sendArchive(_ connection: NWConnection) async throws { guard let asset = archiveAsset, let tokenProvider else { throw Failure.bridge } if archiveFile == nil { let token = try tokenProvider() - var request = URLRequest(url: URL(string: "https://api.github.com/repos/" + repository + "/releases/assets/" + String(asset.id))!) + guard let url = + URL(string: "https://api.github.com/repos/" + repository + "/releases/assets/" + String(asset.id)) + else { throw Failure.unexpectedResponse } + var request = URLRequest(url: url) if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") - let config = sessionConfiguration.copy() as! URLSessionConfiguration + guard let config = sessionConfiguration.copy() as? URLSessionConfiguration else { throw Failure.bridge } config.httpCookieStorage = nil config.urlCache = nil - let downloadSession = URLSession(configuration: config, delegate: UpdateRedirectPolicy(maximum: Int64(asset.size)), delegateQueue: nil) + let downloadSession = URLSession( + configuration: config, + delegate: UpdateRedirectPolicy(maximum: Int64(asset.size)), + delegateQueue: nil + ) defer { downloadSession.invalidateAndCancel() } let (temporary, response) = try await downloadSession.download(for: request) guard let response = response as? HTTPURLResponse, response.statusCode == 200, - try temporary.resourceValues(forKeys: [.fileSizeKey]).fileSize == asset.size else { throw Failure.access } - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + try temporary.resourceValues(forKeys: [.fileSizeKey]).fileSize == asset.size + else { throw Failure.access } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) let destination = directory.appendingPathComponent(asset.name) try FileManager.default.moveItem(at: temporary, to: destination) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) @@ -282,7 +360,13 @@ guard let archiveFile else { throw Failure.bridge } let file = try FileHandle(forReadingFrom: archiveFile) defer { try? file.close() } - try await send(Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(asset.size)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n".utf8), to: connection) + try await send( + Data( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(asset.size)\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" + .utf8 + ), + to: connection + ) while let chunk = try file.read(upToCount: 256 * 1024), !chunk.isEmpty { try Task.checkCancellation() try await send(chunk, to: connection) @@ -293,8 +377,7 @@ private func send(_ data: Data, to connection: NWConnection) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send(content: data, completion: .contentProcessed { error in - if let error { continuation.resume(throwing: error) } - else { continuation.resume() } + if let error { continuation.resume(throwing: error) } else { continuation.resume() } }) } } diff --git a/WindowCleaner/Updates/UpdateController.swift b/WindowCleaner/Updates/UpdateController.swift index 57cda4a..1560760 100644 --- a/WindowCleaner/Updates/UpdateController.swift +++ b/WindowCleaner/Updates/UpdateController.swift @@ -4,7 +4,8 @@ import Sparkle import SwiftUI - @MainActor @Observable + @MainActor + @Observable final class UpdateController: NSObject, SPUUpdaterDelegate { static let shared = UpdateController(repository: "fernandobelotto/WindowCleaner", app: "WindowCleaner") private(set) var checking = false @@ -20,18 +21,27 @@ var restartGuard: @MainActor () -> Bool = { false } var isSafeToRestart: Bool { - restartGuard() && !NSDocumentController.shared.hasEditedDocuments && !NSApp.windows.contains { $0.attachedSheet != nil } + restartGuard() && !NSDocumentController.shared.hasEditedDocuments && !NSApp.windows + .contains { $0.attachedSheet != nil } } - init(tokenStore: (any UpdateTokenStore)? = nil, defaults: UserDefaults = .standard, - repository: String? = nil, app: String? = nil, schedulesChecks: Bool = true) { + init( + tokenStore: (any UpdateTokenStore)? = nil, + defaults: UserDefaults = .standard, + repository: String? = nil, + app: String? = nil, + schedulesChecks: Bool = true + ) { self.tokenStore = tokenStore ?? KeychainUpdateTokenStore() self.defaults = defaults let name = app ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "App") transport = PrivateUpdateTransport(repository: repository ?? "fernandobelotto/" + name, app: name) super.init() transport.failureHandler = { [weak self] error in self?.status = error.localizedDescription } - defaults.register(defaults: ["PrivateUpdatesAutomaticChecks": true, "PrivateUpdatesAutomaticInstall": false]) + defaults.register(defaults: [ + "PrivateUpdatesAutomaticChecks": true, + "PrivateUpdatesAutomaticInstall": false, + ]) if schedulesChecks, ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { timer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in Task { @MainActor in self?.scheduledCheck() } @@ -53,7 +63,9 @@ } } - var canCheckForUpdates: Bool { !checking && !hasPendingInstall && (!cycleActive || controller?.updater.canCheckForUpdates == true) } + var canCheckForUpdates: Bool { + !checking && !hasPendingInstall && (!cycleActive || controller?.updater.canCheckForUpdates == true) + } func scheduledCheck() { guard automaticallyChecksForUpdates else { return } @@ -75,15 +87,18 @@ let token = try? tokenStore.readToken() try await transport.prepare(token: token) { [tokenStore] in try? tokenStore.readToken() } if controller == nil { - let next = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) + let next = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil + ) next.updater.automaticallyChecksForUpdates = false next.updater.automaticallyDownloadsUpdates = automaticallyInstallsUpdates try next.updater.start() controller = next } cycleActive = true - if userInitiated { controller?.checkForUpdates(nil) } - else { controller?.updater.checkForUpdatesInBackground() } + if userInitiated { controller?.checkForUpdates(nil) } else { controller?.updater.checkForUpdatesInBackground() } } catch { status = error.localizedDescription if userInitiated { @@ -132,8 +147,11 @@ if status == nil { status = error?.localizedDescription } } - func updater(_: SPUUpdater, shouldPostponeRelaunchForUpdate _: SUAppcastItem, - untilInvokingBlock installHandler: @escaping () -> Void) -> Bool { + func updater( + _: SPUUpdater, + shouldPostponeRelaunchForUpdate _: SUAppcastItem, + untilInvokingBlock installHandler: @escaping () -> Void + ) -> Bool { deferInstallation(installHandler) installDownloadedUpdate() return true @@ -169,7 +187,8 @@ } struct PrivateUpdateSettings: View { - @State private var updater = UpdateController.shared + @State + private var updater = UpdateController.shared var body: some View { Form { Section("Updates") { @@ -191,11 +210,13 @@ #if os(macOS) && !APP_STORE struct UpdateCommands: Commands { - @State private var updater = UpdateController.shared + @State + private var updater = UpdateController.shared var body: some Commands { CommandGroup(after: .appInfo) { Button("Check for Updates…") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates) - if updater.hasPendingInstall { Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } } + if updater + .hasPendingInstall { Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } } } } } diff --git a/WindowCleanerTests/UpdateTests.swift b/WindowCleanerTests/UpdateTests.swift index f21b340..d7719e0 100644 --- a/WindowCleanerTests/UpdateTests.swift +++ b/WindowCleanerTests/UpdateTests.swift @@ -13,7 +13,8 @@ func saveToken(_ token: String) throws { value = token } } - @Test func tokensRejectWhitespaceAndMissingAccess() { + @Test + func tokensRejectWhitespaceAndMissingAccess() { #expect(PrivateUpdateAccess.validToken("github_pat_abc123")) for token in ["", "github pat", "token\n", "token\r", "é"] { #expect(!PrivateUpdateAccess.validToken(token)) @@ -21,14 +22,21 @@ #expect(Tokens().value == nil) } - @Test func restartIsDeniedUntilLifecycleGuardAllowsIt() { - let updater = UpdateController(tokenStore: Tokens(), repository: "fernandobelotto/WindowCleaner", app: "WindowCleaner", schedulesChecks: false) + @Test + func restartIsDeniedUntilLifecycleGuardAllowsIt() { + let updater = UpdateController( + tokenStore: Tokens(), + repository: "fernandobelotto/WindowCleaner", + app: "WindowCleaner", + schedulesChecks: false + ) #expect(!updater.isSafeToRestart) updater.restartGuard = { false } #expect(!updater.isSafeToRestart) } - @Test func unsafeInstallRetainsPendingHandlerWithoutRestarting() { + @Test + func unsafeInstallRetainsPendingHandlerWithoutRestarting() { let updater = UpdateController(tokenStore: Tokens(), schedulesChecks: false) var restarted = false updater.deferInstallation { restarted = true } @@ -39,7 +47,8 @@ #expect(!restarted) } - @Test func checkAndInstallPreferencesPersistIndependently() throws { + @Test + func checkAndInstallPreferencesPersistIndependently() throws { let suite = "update-tests-" + UUID().uuidString let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } @@ -51,7 +60,8 @@ #expect(restored.automaticallyInstallsUpdates) } - @Test func incompleteDraftAndPrereleaseArtifactsAreIgnored() throws { + @Test + func incompleteDraftAndPrereleaseArtifactsAreIgnored() throws { let json = #"[{"id":1,"tag_name":"v1.0.0","draft":false,"prerelease":false,"assets":[{"id":2,"name":"appcast.xml","size":100,"state":"uploaded"},{"id":3,"name":"release.json","size":100,"state":"uploaded"},{"id":4,"name":"WindowCleaner-1.0.0-universal.zip","size":100,"state":"uploaded"}]}]"# let release = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(json.utf8))[0] #expect(release.completeArchive(app: "WindowCleaner")?.id == 4) @@ -70,10 +80,19 @@ } } - @Test func malformedFeedAndMissingSignatureAreRejected() { + @Test + func malformedFeedAndMissingSignatureAreRejected() { let asset = PrivateUpdateRelease.Asset(id: 4, name: "WindowCleaner-1.0.0-universal.zip", size: 10) - for xml in ["bad XML", "", ""] { - #expect(throws: (any Error).self) { try UpdateFeedValidator.validate(Data(xml.utf8), asset: asset, repository: "fernandobelotto/WindowCleaner") } + for xml in [ + "bad XML", + "", + "", + ] { + #expect(throws: (any Error).self) { try UpdateFeedValidator.validate( + Data(xml.utf8), + asset: asset, + repository: "fernandobelotto/WindowCleaner" + ) } } } } From 5b71d95185ea1e17e7e8925826af88587796dc4a Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Wed, 7 Oct 2026 11:20:12 -0300 Subject: [PATCH 4/7] style: align updater sources with CI formatting rules --- Tests/Updater/TransportFixtures.swift | 10 +++- .../Updates/PrivateUpdateAccess.swift | 4 +- .../Updates/PrivateUpdateTransport.swift | 47 ++++++++++++++----- WindowCleaner/Updates/UpdateController.swift | 28 ++++++++--- .../Views/Settings/SettingsView.swift | 2 +- WindowCleaner/WindowCleanerApp.swift | 6 ++- WindowCleanerTests/UpdateTests.swift | 9 +++- 7 files changed, 81 insertions(+), 25 deletions(-) diff --git a/Tests/Updater/TransportFixtures.swift b/Tests/Updater/TransportFixtures.swift index e402375..aee24ca 100644 --- a/Tests/Updater/TransportFixtures.swift +++ b/Tests/Updater/TransportFixtures.swift @@ -112,8 +112,14 @@ final class FixtureUpdateProtocol: URLProtocol, @unchecked Sendable { static let responses = Mutex<[String: Data]>([:]) static let paths = Mutex<[String]>([]) static let statuses = Mutex<[String: Int]>([:]) - override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "api.github.com" } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.github.com" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + override func startLoading() { precondition(request.value(forHTTPHeaderField: "Authorization") == nil, "Public updates must not need a token") let path = request.url!.path diff --git a/WindowCleaner/Updates/PrivateUpdateAccess.swift b/WindowCleaner/Updates/PrivateUpdateAccess.swift index 2b3957c..c168acb 100644 --- a/WindowCleaner/Updates/PrivateUpdateAccess.swift +++ b/WindowCleaner/Updates/PrivateUpdateAccess.swift @@ -50,7 +50,9 @@ request[kSecUseAuthenticationContext as String] = context var result: CFTypeRef? let status = SecItemCopyMatching(request as CFDictionary, &result) - if status == errSecItemNotFound { return nil } + if status == errSecItemNotFound { + return nil + } guard status == errSecSuccess else { throw PrivateUpdateAccess.AccessError.keychain(status) } guard let data = result as? Data, let token = String(data: data, encoding: .utf8), PrivateUpdateAccess.validToken(token) else { throw PrivateUpdateAccess.AccessError.invalidToken } diff --git a/WindowCleaner/Updates/PrivateUpdateTransport.swift b/WindowCleaner/Updates/PrivateUpdateTransport.swift index fed2e97..b65bb60 100644 --- a/WindowCleaner/Updates/PrivateUpdateTransport.swift +++ b/WindowCleaner/Updates/PrivateUpdateTransport.swift @@ -6,7 +6,10 @@ /// Reject redirects outside GitHub's credential-free artifact delivery hosts. final class UpdateRedirectPolicy: NSObject, URLSessionDownloadDelegate, Sendable { let maximum: Int64 - init(maximum: Int64 = 512 * 1024 * 1024) { self.maximum = maximum } + init(maximum: Int64 = 512 * 1024 * 1024) { + self.maximum = maximum + } + func urlSession( _: URLSession, downloadTask: URLSessionDownloadTask, @@ -14,7 +17,9 @@ totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { - if totalBytesWritten > maximum || totalBytesExpectedToWrite > maximum { downloadTask.cancel() } + if totalBytesWritten > maximum || totalBytesExpectedToWrite > maximum { + downloadTask.cancel() + } } func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo _: URL) {} @@ -85,7 +90,9 @@ qualifiedName _: String?, attributes attributeDict: [String: String] ) { - if elementName == "enclosure" { enclosures.append(attributeDict) } + if elementName == "enclosure" { + enclosures.append(attributeDict) + } if elementName.hasSuffix("releaseNotesLink") || elementName.hasSuffix("fullReleaseNotesLink") { invalid = true } @@ -174,7 +181,9 @@ guard let url = URL(string: "https://api.github.com/repos/" + repository + "/" + path) else { throw Failure.unexpectedResponse } var request = URLRequest(url: url) - if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } + if let token { + request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } request.setValue( binary ? "application/octet-stream" : "application/vnd.github+json", forHTTPHeaderField: "Accept" @@ -182,7 +191,9 @@ request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw Failure.unexpectedResponse } - if [401, 403, 404].contains(response.statusCode) { throw Failure.access } + if [401, 403, 404].contains(response.statusCode) { + throw Failure.access + } guard response.statusCode == 200, data.count <= maximum else { throw Failure.unexpectedResponse } return data } @@ -214,7 +225,9 @@ listener?.cancel() nonce = UUID().uuidString feed = nextFeed - if let archiveFile { try? FileManager.default.removeItem(at: archiveFile) } + if let archiveFile { + try? FileManager.default.removeItem(at: archiveFile) + } archiveFile = nil archiveAsset = asset sourceArchiveURL = source @@ -236,7 +249,11 @@ switch state { case .ready: self.bridgeContinuation = nil - if let port = bridge.port { pending.resume(returning: port) } else { pending.resume(throwing: Failure.bridge) } + if let port = bridge.port { + pending.resume(returning: port) + } else { + pending.resume(throwing: Failure.bridge) + } case .failed, .cancelled: self.bridgeContinuation = nil pending.resume(throwing: Failure.bridge) @@ -289,7 +306,8 @@ let path = String(pieces[1]) let body: Data let type: String - if path == self.feedURL?.path { body = self.feed + if path == self.feedURL?.path { + body = self.feed type = "application/xml" } else if path == self.archiveURL?.path { do { try await self.sendArchive(connection) } catch { @@ -303,7 +321,8 @@ ) } return - } else { connection.cancel() + } else { + connection.cancel() return } let responseHeader = Data( @@ -327,7 +346,9 @@ URL(string: "https://api.github.com/repos/" + repository + "/releases/assets/" + String(asset.id)) else { throw Failure.unexpectedResponse } var request = URLRequest(url: url) - if let token { request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } + if let token { + request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") guard let config = sessionConfiguration.copy() as? URLSessionConfiguration else { throw Failure.bridge } @@ -377,7 +398,11 @@ private func send(_ data: Data, to connection: NWConnection) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send(content: data, completion: .contentProcessed { error in - if let error { continuation.resume(throwing: error) } else { continuation.resume() } + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } }) } } diff --git a/WindowCleaner/Updates/UpdateController.swift b/WindowCleaner/Updates/UpdateController.swift index 1560760..dcbb1f2 100644 --- a/WindowCleaner/Updates/UpdateController.swift +++ b/WindowCleaner/Updates/UpdateController.swift @@ -98,7 +98,11 @@ controller = next } cycleActive = true - if userInitiated { controller?.checkForUpdates(nil) } else { controller?.updater.checkForUpdatesInBackground() } + if userInitiated { + controller?.checkForUpdates(nil) + } else { + controller?.updater.checkForUpdatesInBackground() + } } catch { status = error.localizedDescription if userInitiated { @@ -130,8 +134,14 @@ } catch { status = error.localizedDescription } } - func feedURLString(for _: SPUUpdater) -> String? { transport.feedURL?.absoluteString } - func updater(_: SPUUpdater, shouldDownloadReleaseNotesForUpdate _: SUAppcastItem) -> Bool { false } + func feedURLString(for _: SPUUpdater) -> String? { + transport.feedURL?.absoluteString + } + + func updater(_: SPUUpdater, shouldDownloadReleaseNotesForUpdate _: SUAppcastItem) -> Bool { + false + } + func updater(_: SPUUpdater, willDownloadUpdate _: SUAppcastItem, with request: NSMutableURLRequest) { guard request.url == transport.sourceArchiveURL, let destination = transport.archiveURL else { request.url = URL(string: "http://127.0.0.1:1/rejected-update") @@ -144,7 +154,9 @@ func updater(_: SPUUpdater, didFinishUpdateCycleFor _: SPUUpdateCheck, error: (any Error)?) { cycleActive = false - if status == nil { status = error?.localizedDescription } + if status == nil { + status = error?.localizedDescription + } } func updater( @@ -201,7 +213,9 @@ if updater.hasPendingInstall { Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } } - if let status = updater.status { Text(status).font(.caption).foregroundStyle(.secondary) } + if let status = updater.status { + Text(status).font(.caption).foregroundStyle(.secondary) + } } }.padding() } @@ -216,7 +230,9 @@ CommandGroup(after: .appInfo) { Button("Check for Updates…") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates) if updater - .hasPendingInstall { Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } } + .hasPendingInstall { + Button("Install Downloaded Update…") { updater.installDownloadedUpdate() } + } } } } diff --git a/WindowCleaner/Views/Settings/SettingsView.swift b/WindowCleaner/Views/Settings/SettingsView.swift index 572da76..166a82b 100644 --- a/WindowCleaner/Views/Settings/SettingsView.swift +++ b/WindowCleaner/Views/Settings/SettingsView.swift @@ -10,7 +10,7 @@ struct SettingsView: View { var body: some View { TabView { #if !APP_STORE - PrivateUpdateSettings().tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath") } + PrivateUpdateSettings().tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath") } #endif GeneralSettingsTab() .tabItem { diff --git a/WindowCleaner/WindowCleanerApp.swift b/WindowCleaner/WindowCleanerApp.swift index 758a24e..7444a3a 100644 --- a/WindowCleaner/WindowCleanerApp.swift +++ b/WindowCleaner/WindowCleanerApp.swift @@ -31,7 +31,9 @@ struct WindowCleanerApp: App { MainContentView() .task { #if !APP_STORE - UpdateController.shared.restartGuard = { !StoreManager.shared.isLoading && !sharedModelContainer.mainContext.hasChanges } + UpdateController.shared.restartGuard = { + !StoreManager.shared.isLoading && !sharedModelContainer.mainContext.hasChanges + } #endif } .frame(minWidth: 700, minHeight: 500) @@ -41,7 +43,7 @@ struct WindowCleanerApp: App { .commands { appCommands #if !APP_STORE - UpdateCommands() + UpdateCommands() #endif } diff --git a/WindowCleanerTests/UpdateTests.swift b/WindowCleanerTests/UpdateTests.swift index d7719e0..9d736c9 100644 --- a/WindowCleanerTests/UpdateTests.swift +++ b/WindowCleanerTests/UpdateTests.swift @@ -9,8 +9,13 @@ struct UpdateTests { final class Tokens: UpdateTokenStore { var value: String? - func readToken() throws -> String? { value } - func saveToken(_ token: String) throws { value = token } + func readToken() throws -> String? { + value + } + + func saveToken(_ token: String) throws { + value = token + } } @Test From bf78b3923d12a68b8b6b356a7aaf324678c2fc3b Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Wed, 7 Oct 2026 11:25:54 -0300 Subject: [PATCH 5/7] style: satisfy current SwiftLint string length checks --- WindowCleaner/Updates/PrivateUpdateTransport.swift | 4 ++-- WindowCleaner/Updates/UpdateController.swift | 3 ++- WindowCleanerTests/UpdateTests.swift | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/WindowCleaner/Updates/PrivateUpdateTransport.swift b/WindowCleaner/Updates/PrivateUpdateTransport.swift index b65bb60..5e00140 100644 --- a/WindowCleaner/Updates/PrivateUpdateTransport.swift +++ b/WindowCleaner/Updates/PrivateUpdateTransport.swift @@ -314,8 +314,8 @@ self.failureHandler?(error) connection.send( content: Data( - "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - .utf8 + ("HTTP/1.1 503 Service Unavailable\r\n" + + "Content-Length: 0\r\nConnection: close\r\n\r\n").utf8 ), completion: .contentProcessed { _ in connection.cancel() } ) diff --git a/WindowCleaner/Updates/UpdateController.swift b/WindowCleaner/Updates/UpdateController.swift index dcbb1f2..eba1a49 100644 --- a/WindowCleaner/Updates/UpdateController.swift +++ b/WindowCleaner/Updates/UpdateController.swift @@ -182,7 +182,8 @@ } let alert = NSAlert() alert.messageText = "Restart to Install Update?" - alert.informativeText = "Finish active operations and save your work before restarting. You can install later." + alert.informativeText = "Finish active operations and save your work before restarting. " + + "You can install later." alert.addButton(withTitle: "Restart Now") alert.addButton(withTitle: "Later") guard alert.runModal() == .alertFirstButtonReturn else { return } diff --git a/WindowCleanerTests/UpdateTests.swift b/WindowCleanerTests/UpdateTests.swift index 9d736c9..1161fb2 100644 --- a/WindowCleanerTests/UpdateTests.swift +++ b/WindowCleanerTests/UpdateTests.swift @@ -67,7 +67,12 @@ @Test func incompleteDraftAndPrereleaseArtifactsAreIgnored() throws { - let json = #"[{"id":1,"tag_name":"v1.0.0","draft":false,"prerelease":false,"assets":[{"id":2,"name":"appcast.xml","size":100,"state":"uploaded"},{"id":3,"name":"release.json","size":100,"state":"uploaded"},{"id":4,"name":"WindowCleaner-1.0.0-universal.zip","size":100,"state":"uploaded"}]}]"# + let json = #""" + [{"id":1,"tag_name":"v1.0.0","draft":false,"prerelease":false, + "assets":[{"id":2,"name":"appcast.xml","size":100,"state":"uploaded"}, + {"id":3,"name":"release.json","size":100,"state":"uploaded"}, + {"id":4,"name":"WindowCleaner-1.0.0-universal.zip","size":100,"state":"uploaded"}]}] + """# let release = try JSONDecoder().decode([PrivateUpdateRelease].self, from: Data(json.utf8))[0] #expect(release.completeArchive(app: "WindowCleaner")?.id == 4) for value in ["draft", "prerelease"] { From e269d7cc5ba169830ad3800a1e3905565937ff2d Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Thu, 8 Oct 2026 00:16:58 -0300 Subject: [PATCH 6/7] ci: route trusted macOS builds to the M1 Max runner --- .github/actionlint.yaml | 4 ++ .github/workflows/ci.yml | 72 ++++++++++++++++++++++------ .github/workflows/publish-update.yml | 2 +- .github/workflows/updater-checks.yml | 18 ++++++- 4 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..bf3df49 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - ci-build + - windowcleaner-build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285bdb0..44a353f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,33 +10,49 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read +# This public repository uses a personal, persistent runner. Only the owner can +# trigger these jobs, and PRs must originate from this same repository. Fork and +# external-contributor PRs cannot use this runner; review/import trusted changes +# into an owner-authored repository branch before requesting required CI. jobs: build-and-test: + if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: Build & Test - runs-on: macos-15 + runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] timeout-minutes: 30 + defaults: + run: + shell: /opt/homebrew/bin/python3 /Users/fernando/ci-tools/serial-shell.py {0} + env: + DEVELOPER_DIR: /Users/fernando/ci-tools/Xcode.app/Contents/Developer steps: + - name: Use provisioned build tools + run: | + echo /opt/homebrew/bin >> "$GITHUB_PATH" + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Select Xcode + - name: Verify provisioned Xcode run: | - # Use the latest stable Xcode available on the runner - sudo xcode-select -s /Applications/Xcode.app/Contents/Developer xcodebuild -version swift --version - name: Cache DerivedData uses: actions/cache@v4 with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.swift', '**/project.pbxproj') }} + path: ${{ runner.temp }}/WindowCleaner-DerivedData + key: ${{ runner.os }}-windowcleaner-deriveddata-${{ hashFiles('**/*.swift', '**/project.pbxproj') }} restore-keys: | - ${{ runner.os }}-deriveddata- + ${{ runner.os }}-windowcleaner-deriveddata- - - name: Install xcpretty - run: gem install xcpretty + - name: Verify provisioned xcpretty + run: command -v xcpretty - name: Build run: | @@ -46,6 +62,7 @@ jobs: -scheme WindowCleaner \ -destination "platform=macOS" \ -configuration Debug \ + -derivedDataPath "$RUNNER_TEMP/WindowCleaner-DerivedData" \ -skipPackagePluginValidation \ -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ @@ -59,6 +76,7 @@ jobs: -scheme WindowCleaner \ -destination "platform=macOS" \ -configuration Debug \ + -derivedDataPath "$RUNNER_TEMP/WindowCleaner-DerivedData" \ -skipPackagePluginValidation \ -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ @@ -76,31 +94,55 @@ jobs: retention-days: 7 lint: + if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: SwiftLint - runs-on: macos-15 + runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] timeout-minutes: 10 + defaults: + run: + shell: /opt/homebrew/bin/python3 /Users/fernando/ci-tools/serial-shell.py {0} + env: + DEVELOPER_DIR: /Users/fernando/ci-tools/Xcode.app/Contents/Developer steps: + - name: Use provisioned build tools + run: | + echo /opt/homebrew/bin >> "$GITHUB_PATH" + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Install SwiftLint - run: brew install swiftlint + - name: Verify provisioned SwiftLint + run: swiftlint version - name: Run SwiftLint run: swiftlint --strict --reporter github-actions-logging format-check: + if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: Format Check - runs-on: macos-15 + runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] timeout-minutes: 10 + defaults: + run: + shell: /opt/homebrew/bin/python3 /Users/fernando/ci-tools/serial-shell.py {0} + env: + DEVELOPER_DIR: /Users/fernando/ci-tools/Xcode.app/Contents/Developer steps: + - name: Use provisioned build tools + run: | + echo /opt/homebrew/bin >> "$GITHUB_PATH" + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Install SwiftFormat - run: brew install swiftformat + - name: Verify provisioned SwiftFormat + run: swiftformat --version - name: Check Formatting run: | diff --git a/.github/workflows/publish-update.yml b/.github/workflows/publish-update.yml index 0411f48..41c997f 100644 --- a/.github/workflows/publish-update.yml +++ b/.github/workflows/publish-update.yml @@ -17,7 +17,7 @@ concurrency: cancel-in-progress: false jobs: prepare: - if: github.repository == 'fernandobelotto/WindowCleaner' && github.ref == 'refs/heads/main' + if: (github.repository == 'fernandobelotto/WindowCleaner' && github.ref == 'refs/heads/main') && (github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto')) && github.actor == 'fernandobelotto' runs-on: macos-26 environment: release timeout-minutes: 90 diff --git a/.github/workflows/updater-checks.yml b/.github/workflows/updater-checks.yml index 478a37d..549c3aa 100644 --- a/.github/workflows/updater-checks.yml +++ b/.github/workflows/updater-checks.yml @@ -4,12 +4,28 @@ on: workflow_dispatch: permissions: contents: read +# This public repository uses a personal, persistent runner. Only the owner can +# trigger these jobs, and PRs must originate from this same repository. Fork and +# external-contributor PRs cannot use this runner; review/import trusted changes +# into an owner-authored repository branch before requesting required CI. jobs: updater: - runs-on: macos-latest + if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') + runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] timeout-minutes: 30 + defaults: + run: + shell: /opt/homebrew/bin/python3 /Users/fernando/ci-tools/serial-shell.py {0} + env: + DEVELOPER_DIR: /Users/fernando/ci-tools/Xcode.app/Contents/Developer steps: + - name: Use provisioned build tools + run: | + echo /opt/homebrew/bin >> "$GITHUB_PATH" + test -x "$DEVELOPER_DIR/usr/bin/xcodebuild" - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Validate metadata, loopback transport and credential redirects run: Scripts/test-update-transport.sh - name: Test updater preferences and safe installation From f7373e09f6b4389d8f79e7f4a80582c7f6d676e7 Mon Sep 17 00:00:00 2001 From: Fernando Belotto Date: Thu, 8 Oct 2026 00:19:43 -0300 Subject: [PATCH 7/7] ci: bound shared-runner queues and shorten development retention --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/updater-checks.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44a353f..bca77a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: Build & Test runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] - timeout-minutes: 30 + timeout-minutes: 90 defaults: run: @@ -91,13 +91,13 @@ jobs: path: | TestResults.xcresult test-results.xml - retention-days: 7 + retention-days: 1 lint: if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: SwiftLint runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] - timeout-minutes: 10 + timeout-minutes: 90 defaults: run: @@ -124,7 +124,7 @@ jobs: if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') name: Format Check runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] - timeout-minutes: 10 + timeout-minutes: 90 defaults: run: diff --git a/.github/workflows/updater-checks.yml b/.github/workflows/updater-checks.yml index 549c3aa..38fb05d 100644 --- a/.github/workflows/updater-checks.yml +++ b/.github/workflows/updater-checks.yml @@ -12,7 +12,7 @@ jobs: updater: if: github.repository == 'fernandobelotto/WindowCleaner' && github.repository_owner == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor == 'fernandobelotto' && (github.event_name != 'pull_request' || github.event.pull_request.user.login == 'fernandobelotto') runs-on: [self-hosted, macOS, ARM64, ci-build, windowcleaner-build] - timeout-minutes: 30 + timeout-minutes: 90 defaults: run: shell: /opt/homebrew/bin/python3 /Users/fernando/ci-tools/serial-shell.py {0}