diff --git a/.github/workflows/publish-update.yml b/.github/workflows/publish-update.yml
new file mode 100644
index 0000000..41c997f
--- /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') && (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
+ 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/.github/workflows/updater-checks.yml b/.github/workflows/updater-checks.yml
new file mode 100644
index 0000000..38fb05d
--- /dev/null
+++ b/.github/workflows/updater-checks.yml
@@ -0,0 +1,44 @@
+name: Updater checks
+on:
+ pull_request:
+ 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:
+ 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: 90
+ 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
+ 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/.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-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/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/Tests/Updater/TransportFixtures.swift b/Tests/Updater/TransportFixtures.swift
new file mode 100644
index 0000000..aee24ca
--- /dev/null
+++ b/Tests/Updater/TransportFixtures.swift
@@ -0,0 +1,140 @@
+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/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/WindowCleaner/Updates/PrivateUpdateAccess.swift b/WindowCleaner/Updates/PrivateUpdateAccess.swift
new file mode 100644
index 0000000..c168acb
--- /dev/null
+++ b/WindowCleaner/Updates/PrivateUpdateAccess.swift
@@ -0,0 +1,74 @@
+#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..5e00140
--- /dev/null
+++ b/WindowCleaner/Updates/PrivateUpdateTransport.swift
@@ -0,0 +1,410 @@
+// Keep the bridge's asynchronous state transitions together for auditing.
+#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 {
+ 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("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
+ }
+
+ // 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 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<
+ NWEndpoint.Port,
+ any Error
+ >) 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())
+ }
+
+ // 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()
+ 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\n"
+ + "Content-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() }
+ )
+ }
+ }
+ }
+
+ // 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()
+ 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")
+ 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
+ )
+ 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..eba1a49
--- /dev/null
+++ b/WindowCleaner/Updates/UpdateController.swift
@@ -0,0 +1,240 @@
+#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..166a82b 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..7444a3a 100644
--- a/WindowCleaner/WindowCleanerApp.swift
+++ b/WindowCleaner/WindowCleanerApp.swift
@@ -29,12 +29,22 @@ 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..1161fb2
--- /dev/null
+++ b/WindowCleanerTests/UpdateTests.swift
@@ -0,0 +1,109 @@
+#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.
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.