diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b6659d4..4cacdda 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,5 +50,20 @@ jobs:
test "$clipboard_after" = "$CLIPBOARD_BEFORE"
test "$keychain_items_after" = "$KEYCHAIN_ITEMS_BEFORE"
- - name: Build Release App Bundle
+ - name: Build Release App Bundle (universal)
+ env:
+ UNIVERSAL: "1"
+ SCRATCH_PATH: ${{ runner.temp }}/swift-build-universal
run: ./Scripts/build_app.sh
+
+ - name: Verify App Bundle
+ shell: bash
+ run: |
+ app=MacOptimizer.app
+ test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "$(tr -d '[:space:]' < VERSION)"
+ test -f "$app/Contents/Resources/AppIcon.icns"
+ test -d "$app/Contents/Resources/MacOptimizer_MacOptimizer.bundle/Contents/Resources/en.lproj"
+ test -d "$app/Contents/Resources/MacOptimizer_MacOptimizer.bundle/Contents/Resources/tr.lproj"
+ lipo "$app/Contents/MacOS/MacOptimizer" -verify_arch arm64
+ lipo "$app/Contents/MacOS/MacOptimizer" -verify_arch x86_64
+ "$app/Contents/MacOS/MacOptimizer" version | grep -F "v$(tr -d '[:space:]' < VERSION)"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e21c056..a228b91 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,55 +1,163 @@
name: Release Build
+# Tags v* build an UNSIGNED universal DMG as a workflow artifact (never a release asset).
+# If the signing secrets exist, a second job signs, notarizes and staples the DMG with
+# Scripts/release.sh and creates a DRAFT GitHub release for the human to publish.
+# Without secrets the signed release is produced locally (docs/RELEASING.md).
+
on:
push:
tags:
- 'v*'
permissions:
- contents: write
- id-token: write
- attestations: write
+ contents: read
jobs:
- release:
- name: Build & Release
+ build-unsigned:
+ name: Build unsigned DMG
runs-on: macos-15
-
steps:
- name: Checkout Repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # CFBundleVersion = git rev-list --count HEAD
- name: Select Xcode 16.4
run: |
- ls /Applications | grep Xcode
test -d /Applications/Xcode_16.4.app || { echo "Xcode 16.4 is missing from this runner" >&2; exit 1; }
sudo xcode-select --switch /Applications/Xcode_16.4.app
- - name: Verify Swift Version
- run: swift --version
+ - name: Check tag matches VERSION
+ shell: bash
+ run: |
+ version="$(tr -d '[:space:]' < VERSION)"
+ test "${GITHUB_REF_NAME}" = "v${version}" || { echo "tag ${GITHUB_REF_NAME} != v${version} (VERSION file)" >&2; exit 1; }
+ echo "VERSION=${version}" >> "$GITHUB_ENV"
- - name: Build Standalone Application
+ - name: Build app bundle (universal)
+ env:
+ UNIVERSAL: "1"
+ SCRATCH_PATH: ${{ runner.temp }}/swift-build
run: ./Scripts/build_app.sh
- - name: Package DMG Installer
+ - name: Package unsigned DMG
+ shell: bash
+ run: |
+ mkdir -p dist/dmg-root
+ ditto MacOptimizer.app dist/dmg-root/MacOptimizer.app
+ ln -s /Applications dist/dmg-root/Applications
+ dmg="MacOptimizer-${VERSION}-unsigned.dmg"
+ hdiutil create -volname "MacOptimizer ${VERSION}" -srcfolder dist/dmg-root -fs HFS+ -format UDZO -ov "dist/${dmg}"
+ (cd dist && shasum -a 256 "${dmg}" > "${dmg}.sha256")
+
+ - name: Upload unsigned artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: MacOptimizer-unsigned
+ path: |
+ dist/*.dmg
+ dist/*.sha256
+ if-no-files-found: error
+
+ check-signing-secrets:
+ name: Check signing secrets
+ runs-on: ubuntu-latest
+ outputs:
+ enabled: ${{ steps.check.outputs.enabled }}
+ steps:
+ - id: check
+ env:
+ MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
+ MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
+ ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
+ ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
+ ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
+ run: |
+ if [ -n "$MACOS_CERT_P12" ] && [ -n "$MACOS_CERT_PASSWORD" ] && [ -n "$ASC_KEY_ID" ] \
+ && [ -n "$ASC_ISSUER_ID" ] && [ -n "$ASC_KEY_P8" ]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Signing secrets not configured; skipping signed release (sign locally, see docs/RELEASING.md)."
+ fi
+
+ sign-and-notarize:
+ name: Sign, notarize and draft release
+ needs: [build-unsigned, check-signing-secrets]
+ if: needs.check-signing-secrets.outputs.enabled == 'true'
+ runs-on: macos-15
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Select Xcode 16.4
+ run: sudo xcode-select --switch /Applications/Xcode_16.4.app
+
+ - name: Import signing certificate into a temporary keychain
+ env:
+ MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
+ MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ keychain="${RUNNER_TEMP}/signing.keychain-db"
+ keychain_password="$(uuidgen)"
+ cert="${RUNNER_TEMP}/cert.p12"
+ printf '%s' "${MACOS_CERT_P12}" | base64 --decode > "${cert}"
+ security create-keychain -p "${keychain_password}" "${keychain}"
+ security set-keychain-settings -lut 3600 "${keychain}"
+ security unlock-keychain -p "${keychain_password}" "${keychain}"
+ security import "${cert}" -k "${keychain}" -P "${MACOS_CERT_PASSWORD}" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain}" >/dev/null
+ # shellcheck disable=SC2046 # the existing keychain list must word-split
+ security list-keychains -d user -s "${keychain}" $(security list-keychains -d user | tr -d '"')
+ rm -f "${cert}"
+ identity="$(security find-identity -v -p codesigning "${keychain}" | sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -n 1)"
+ test -n "${identity}" || { echo "No Developer ID Application identity in the certificate" >&2; exit 1; }
+ echo "SIGN_IDENTITY=${identity}" >> "$GITHUB_ENV"
+ echo "SIGNING_KEYCHAIN=${keychain}" >> "$GITHUB_ENV"
+
+ - name: Write notary API key
+ env:
+ ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
+ shell: bash
run: |
- mkdir -p dist
- hdiutil create -volname "MacOptimizer" -srcfolder "MacOptimizer.app" -ov -format UDZO "dist/MacOptimizer.dmg"
- cd dist
- shasum -a 256 MacOptimizer.dmg > MacOptimizer.dmg.sha256
- cd ..
+ umask 077
+ printf '%s' "${ASC_KEY_P8}" > "${RUNNER_TEMP}/AuthKey.p8"
+ echo "NOTARY_KEY_PATH=${RUNNER_TEMP}/AuthKey.p8" >> "$GITHUB_ENV"
- - name: Generate Artifact Attestation
+ - name: Build, sign, notarize and staple
+ env:
+ NOTARY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
+ NOTARY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
+ SCRATCH_PATH: ${{ runner.temp }}/swift-build
+ run: ./Scripts/release.sh
+
+ - name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
- subject-path: 'dist/MacOptimizer.dmg'
+ subject-path: 'dist/MacOptimizer-*.dmg'
- - name: Create GitHub Release
+ - name: Create draft GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
- dist/MacOptimizer.dmg
- dist/MacOptimizer.dmg.sha256
+ dist/MacOptimizer-*.dmg
+ dist/MacOptimizer-*.dmg.sha256
generate_release_notes: true
- draft: false
+ draft: true
prerelease: false
+
+ - name: Clean up signing material
+ if: always()
+ shell: bash
+ run: |
+ rm -f "${RUNNER_TEMP}/AuthKey.p8" "${RUNNER_TEMP}/cert.p12"
+ if [ -n "${SIGNING_KEYCHAIN:-}" ]; then security delete-keychain "${SIGNING_KEYCHAIN}" || true; fi
diff --git a/Resources/Brand/AppIcon-1024.png b/Resources/Brand/AppIcon-1024.png
new file mode 100644
index 0000000..634acbd
Binary files /dev/null and b/Resources/Brand/AppIcon-1024.png differ
diff --git a/Scripts/MacOptimizer.entitlements b/Scripts/MacOptimizer.entitlements
new file mode 100644
index 0000000..08204f6
--- /dev/null
+++ b/Scripts/MacOptimizer.entitlements
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh
index 36e0121..8b57519 100755
--- a/Scripts/build_app.sh
+++ b/Scripts/build_app.sh
@@ -1,30 +1,67 @@
#!/bin/bash
-set -e
+# Builds MacOptimizer.app from the SwiftPM executable.
+#
+# Environment:
+# SCRATCH_PATH SwiftPM scratch path (default: .build)
+# OUTPUT_DIR where MacOptimizer.app is written (default: repo root)
+# UNIVERSAL=1 build a universal (arm64 + x86_64) binary
+# BUILD_NUMBER CFBundleVersion override (default: git rev-list --count HEAD)
+set -euo pipefail
-echo "🚀 Building MacOptimizer in Release mode..."
-SCRATCH_PATH="${SCRATCH_PATH:-.build}"
-swift build -c release --scratch-path "${SCRATCH_PATH}"
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${REPO_ROOT}"
APP_NAME="MacOptimizer"
-BUILD_DIR="${SCRATCH_PATH}/release"
-APP_BUNDLE="${APP_NAME}.app"
+BUNDLE_ID="com.osmancagrigenc.MacOptimizer"
+SCRATCH_PATH="${SCRATCH_PATH:-.build}"
+OUTPUT_DIR="${OUTPUT_DIR:-.}"
+
+# Single version source: the VERSION file at the repo root.
+VERSION="$(tr -d '[:space:]' < "${REPO_ROOT}/VERSION")"
+if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "error: VERSION must be MAJOR.MINOR.PATCH, got '${VERSION}'" >&2
+ exit 1
+fi
+BUILD_NUMBER="${BUILD_NUMBER:-$(git -C "${REPO_ROOT}" rev-list --count HEAD 2>/dev/null || echo 1)}"
+
+ARCH_FLAGS=()
+if [[ "${UNIVERSAL:-0}" == "1" ]]; then
+ ARCH_FLAGS=(--arch arm64 --arch x86_64)
+fi
+
+echo "Building ${APP_NAME} ${VERSION} (${BUILD_NUMBER}) in release mode, universal=${UNIVERSAL:-0}..."
+swift build -c release --scratch-path "${SCRATCH_PATH}" ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"}
+BUILD_DIR="$(swift build -c release --scratch-path "${SCRATCH_PATH}" ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"} --show-bin-path)"
+
+mkdir -p "${OUTPUT_DIR}"
+APP_BUNDLE="${OUTPUT_DIR}/${APP_NAME}.app"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
+RESOURCE_BUNDLE="${APP_NAME}_${APP_NAME}.bundle"
-echo "📦 Creating .app bundle structure: ${APP_BUNDLE}..."
+echo "Creating bundle: ${APP_BUNDLE}"
rm -rf "${APP_BUNDLE}"
-mkdir -p "${MACOS_DIR}"
-mkdir -p "${RESOURCES_DIR}"
+mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
-# Copy binary
cp "${BUILD_DIR}/${APP_NAME}" "${MACOS_DIR}/${APP_NAME}"
chmod +x "${MACOS_DIR}/${APP_NAME}"
-# Bundle.module points to this SwiftPM resource bundle at runtime.
-cp -R "${BUILD_DIR}/MacOptimizer_MacOptimizer.bundle" "${RESOURCES_DIR}/"
+# Bundle.module resolves this SwiftPM resource bundle at runtime (localizations live here).
+cp -R "${BUILD_DIR}/${RESOURCE_BUNDLE}" "${RESOURCES_DIR}/"
+for lang in en tr; do
+ if [[ ! -d "${RESOURCES_DIR}/${RESOURCE_BUNDLE}/Contents/Resources/${lang}.lproj" \
+ && ! -d "${RESOURCES_DIR}/${RESOURCE_BUNDLE}/${lang}.lproj" ]]; then
+ echo "error: ${lang}.lproj missing from ${RESOURCE_BUNDLE}" >&2
+ exit 1
+ fi
+done
+
+# App icon, built from the master PNG Resources/Brand/AppIcon-1024.png.
+"${REPO_ROOT}/Scripts/build_icns.sh" "${RESOURCES_DIR}/AppIcon.icns"
-# Create Info.plist
+# LSUIElement is false on purpose: MacOptimizer is a regular windowed app with a
+# Dock icon; the menu bar extra is optional.
cat < "${CONTENTS_DIR}/Info.plist"
@@ -35,7 +72,7 @@ cat < "${CONTENTS_DIR}/Info.plist"
CFBundleIconFile
AppIcon
CFBundleIdentifier
- com.osmancagrigenc.MacOptimizer
+ ${BUNDLE_ID}
CFBundleInfoDictionaryVersion
6.0
CFBundleName
@@ -45,9 +82,9 @@ cat < "${CONTENTS_DIR}/Info.plist"
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0.0
+ ${VERSION}
CFBundleVersion
- 1
+ ${BUILD_NUMBER}
CFBundleDevelopmentRegion
en
CFBundleLocalizations
@@ -55,15 +92,19 @@ cat < "${CONTENTS_DIR}/Info.plist"
en
tr
+ LSApplicationCategoryType
+ public.app-category.utilities
LSMinimumSystemVersion
14.0
+ LSUIElement
+
NSHighResolutionCapable
NSHumanReadableCopyright
- Copyright © 2026 MacOptimizer. All rights reserved.
+ Copyright © 2026 Osman Cagri Genc (Codifya). Licensed under Apache-2.0.
EOF
+plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null
-echo "✨ ${APP_BUNDLE} created successfully!"
-echo "📍 Path: $(pwd)/${APP_BUNDLE}"
+echo "${APP_BUNDLE} created (version ${VERSION}, build ${BUILD_NUMBER})."
diff --git a/Scripts/build_icns.sh b/Scripts/build_icns.sh
new file mode 100755
index 0000000..2b8a702
--- /dev/null
+++ b/Scripts/build_icns.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Builds AppIcon.icns from the 1024x1024 master PNG.
+#
+# Usage: Scripts/build_icns.sh [master.png]
+# master defaults to Resources/Brand/AppIcon-1024.png. To change the app icon, replace
+# that file. If it is missing, the placeholder generator (Scripts/make_icon.swift)
+# writes it first.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUTPUT="${1:?usage: build_icns.sh [master.png]}"
+MASTER="${2:-${REPO_ROOT}/Resources/Brand/AppIcon-1024.png}"
+
+if [[ ! -f "${MASTER}" ]]; then
+ echo "Master icon missing; generating placeholder at ${MASTER}"
+ swift "${REPO_ROOT}/Scripts/make_icon.swift" "${MASTER}"
+fi
+
+width="$(sips -g pixelWidth "${MASTER}" | awk '/pixelWidth/ {print $2}')"
+height="$(sips -g pixelHeight "${MASTER}" | awk '/pixelHeight/ {print $2}')"
+if [[ "${width}" != "1024" || "${height}" != "1024" ]]; then
+ echo "error: ${MASTER} must be 1024x1024 PNG, got ${width}x${height}" >&2
+ exit 1
+fi
+
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "${WORK_DIR}"' EXIT
+ICONSET="${WORK_DIR}/AppIcon.iconset"
+mkdir -p "${ICONSET}"
+
+# name:pixels for every iconset rendition macOS expects.
+for spec in \
+ icon_16x16.png:16 icon_16x16@2x.png:32 \
+ icon_32x32.png:32 icon_32x32@2x.png:64 \
+ icon_128x128.png:128 icon_128x128@2x.png:256 \
+ icon_256x256.png:256 icon_256x256@2x.png:512 \
+ icon_512x512.png:512 icon_512x512@2x.png:1024; do
+ name="${spec%%:*}"
+ px="${spec##*:}"
+ sips -s format png -z "${px}" "${px}" "${MASTER}" --out "${ICONSET}/${name}" >/dev/null
+done
+
+mkdir -p "$(dirname "${OUTPUT}")"
+iconutil -c icns "${ICONSET}" -o "${OUTPUT}"
+echo "Wrote ${OUTPUT}"
diff --git a/Scripts/make_icon.swift b/Scripts/make_icon.swift
new file mode 100644
index 0000000..9870e93
--- /dev/null
+++ b/Scripts/make_icon.swift
@@ -0,0 +1,124 @@
+#!/usr/bin/env swift
+// Fallback icon generator. Writes the 1024x1024 master PNG that Scripts/build_icns.sh
+// turns into AppIcon.icns: an original, programmatically drawn placeholder
+// (rounded-square gradient, white gauge arc with a needle and a small sparkle).
+// No third-party artwork. Uses only CoreGraphics + ImageIO, so it runs headless.
+// The designed logo replaces the master PNG; this script only runs when it is missing.
+//
+// Usage: swift Scripts/make_icon.swift [output.png] (default: Resources/Brand/AppIcon-1024.png)
+
+import CoreGraphics
+import Foundation
+import ImageIO
+import UniformTypeIdentifiers
+
+let outputPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources/Brand/AppIcon-1024.png"
+
+func color(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> CGColor {
+ CGColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: a)
+}
+
+/// Draws the icon on a 1024-unit canvas; the context is pre-scaled to the target size.
+func drawIcon(in ctx: CGContext) {
+ // macOS icon grid: 824pt body centred on a 1024pt canvas, ~185pt corner radius.
+ let body = CGRect(x: 100, y: 100, width: 824, height: 824)
+ let bodyPath = CGPath(roundedRect: body, cornerWidth: 185, cornerHeight: 185, transform: nil)
+
+ // Soft drop shadow under the body.
+ ctx.saveGState()
+ ctx.setShadow(offset: CGSize(width: 0, height: -12), blur: 28, color: color(0, 0, 0, 0.35))
+ ctx.addPath(bodyPath)
+ ctx.setFillColor(color(20, 40, 80))
+ ctx.fillPath()
+ ctx.restoreGState()
+
+ // Diagonal gradient fill: teal (bottom-left) to deep blue (top-right).
+ ctx.saveGState()
+ ctx.addPath(bodyPath)
+ ctx.clip()
+ let gradient = CGGradient(
+ colorsSpace: CGColorSpace(name: CGColorSpace.sRGB),
+ colors: [color(24, 196, 172), color(38, 110, 230), color(58, 52, 170)] as CFArray,
+ locations: [0, 0.55, 1]
+ )!
+ ctx.drawLinearGradient(gradient, start: CGPoint(x: 100, y: 100), end: CGPoint(x: 924, y: 924), options: [])
+ ctx.restoreGState()
+
+ // Gauge: a 240-degree arc opening downwards.
+ let center = CGPoint(x: 512, y: 470)
+ let radius: CGFloat = 250
+ let startAngle = CGFloat.pi * (-30.0 / 180.0) // lower right
+ let endAngle = CGFloat.pi * (210.0 / 180.0) // lower left
+ ctx.setLineCap(.round)
+
+ // Track.
+ ctx.setStrokeColor(color(255, 255, 255, 0.28))
+ ctx.setLineWidth(58)
+ ctx.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
+ ctx.strokePath()
+
+ // Filled portion (from the left end, clockwise to ~75%).
+ let valueAngle = CGFloat.pi * (20.0 / 180.0)
+ ctx.setStrokeColor(color(255, 255, 255))
+ ctx.addArc(center: center, radius: radius, startAngle: endAngle, endAngle: valueAngle, clockwise: true)
+ ctx.strokePath()
+
+ // Needle pointing at the value.
+ let needleLength: CGFloat = 195
+ let tip = CGPoint(x: center.x + cos(valueAngle) * needleLength, y: center.y + sin(valueAngle) * needleLength)
+ ctx.setLineWidth(34)
+ ctx.move(to: center)
+ ctx.addLine(to: tip)
+ ctx.strokePath()
+ ctx.setFillColor(color(255, 255, 255))
+ ctx.fillEllipse(in: CGRect(x: center.x - 46, y: center.y - 46, width: 92, height: 92))
+
+ // Four-point sparkle in the upper-left corner.
+ drawSparkle(in: ctx, at: CGPoint(x: 300, y: 760), size: 88)
+}
+
+func drawSparkle(in ctx: CGContext, at c: CGPoint, size s: CGFloat) {
+ let waist = s * 0.22
+ let path = CGMutablePath()
+ path.move(to: CGPoint(x: c.x, y: c.y + s))
+ path.addQuadCurve(to: CGPoint(x: c.x + s, y: c.y), control: CGPoint(x: c.x + waist, y: c.y + waist))
+ path.addQuadCurve(to: CGPoint(x: c.x, y: c.y - s), control: CGPoint(x: c.x + waist, y: c.y - waist))
+ path.addQuadCurve(to: CGPoint(x: c.x - s, y: c.y), control: CGPoint(x: c.x - waist, y: c.y - waist))
+ path.addQuadCurve(to: CGPoint(x: c.x, y: c.y + s), control: CGPoint(x: c.x - waist, y: c.y + waist))
+ path.closeSubpath()
+ ctx.addPath(path)
+ ctx.setFillColor(color(255, 255, 255, 0.95))
+ ctx.fillPath()
+}
+
+func renderPNG(size: Int, to url: URL) throws {
+ guard let ctx = CGContext(
+ data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { throw IconError.context(size) }
+ ctx.setShouldAntialias(true)
+ ctx.interpolationQuality = .high
+ ctx.scaleBy(x: CGFloat(size) / 1024, y: CGFloat(size) / 1024)
+ drawIcon(in: ctx)
+ guard let image = ctx.makeImage(),
+ let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)
+ else { throw IconError.encode(url.path) }
+ CGImageDestinationAddImage(dest, image, nil)
+ guard CGImageDestinationFinalize(dest) else { throw IconError.encode(url.path) }
+}
+
+enum IconError: Error {
+ case context(Int)
+ case encode(String)
+}
+
+do {
+ let output = URL(fileURLWithPath: outputPath)
+ try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try renderPNG(size: 1024, to: output)
+ print("Wrote \(output.path)")
+} catch {
+ FileHandle.standardError.write("make_icon failed: \(error)\n".data(using: .utf8)!)
+ exit(1)
+}
diff --git a/Scripts/release.sh b/Scripts/release.sh
new file mode 100755
index 0000000..52bbf7f
--- /dev/null
+++ b/Scripts/release.sh
@@ -0,0 +1,138 @@
+#!/bin/bash
+# Local release: universal build -> Developer ID signing (hardened runtime) -> DMG ->
+# notarization -> stapling -> Gatekeeper checks -> checksum.
+#
+# Flow: the app is signed, placed in a signed DMG, and the DMG is submitted to the notary
+# service once. Notarizing the DMG also notarizes the app inside it; the ticket is stapled
+# to the DMG. See docs/RELEASING.md.
+#
+# Environment:
+# SIGN_IDENTITY codesign identity (default: Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV))
+# NOTARY_PROFILE notarytool keychain profile (default: codifya-notary)
+# NOTARY_KEY_PATH, NOTARY_KEY_ID, NOTARY_ISSUER_ID
+# notarize with an App Store Connect API key instead of the profile (CI)
+# DRY_RUN=1 sign and package, but skip notarization and stapling
+# UNIVERSAL=0 build for the host architecture only (default: universal arm64 + x86_64)
+# SCRATCH_PATH SwiftPM scratch path (default: .build)
+# ALLOW_DIRTY=1 allow a real (non-dry) release from a dirty working tree
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${REPO_ROOT}"
+
+APP_NAME="MacOptimizer"
+SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV)}"
+NOTARY_PROFILE="${NOTARY_PROFILE:-codifya-notary}"
+DRY_RUN="${DRY_RUN:-0}"
+ENTITLEMENTS="${REPO_ROOT}/Scripts/MacOptimizer.entitlements"
+VERSION="$(tr -d '[:space:]' < "${REPO_ROOT}/VERSION")"
+DIST_DIR="${REPO_ROOT}/dist"
+STAGE_DIR="${DIST_DIR}/stage"
+APP_BUNDLE="${STAGE_DIR}/${APP_NAME}.app"
+DMG_PATH="${DIST_DIR}/${APP_NAME}-${VERSION}.dmg"
+
+step() { printf '\n==> %s\n' "$*"; }
+fail() { echo "error: $*" >&2; exit 1; }
+
+preflight() {
+ step "Preflight (version ${VERSION}, dry run: ${DRY_RUN})"
+ if [[ -n "$(git status --porcelain)" ]]; then
+ if [[ "${DRY_RUN}" == "1" || "${ALLOW_DIRTY:-0}" == "1" ]]; then
+ echo "warning: working tree is dirty"
+ else
+ fail "working tree is dirty; commit first or set ALLOW_DIRTY=1"
+ fi
+ fi
+ security find-identity -v -p codesigning | grep -qF "\"${SIGN_IDENTITY}\"" \
+ || fail "signing identity not found in keychain: ${SIGN_IDENTITY}"
+ [[ -f "${ENTITLEMENTS}" ]] || fail "missing ${ENTITLEMENTS}"
+ plutil -lint "${ENTITLEMENTS}" >/dev/null
+}
+
+build_app() {
+ step "Building app bundle"
+ rm -rf "${STAGE_DIR}"
+ mkdir -p "${STAGE_DIR}"
+ UNIVERSAL="${UNIVERSAL:-1}" OUTPUT_DIR="${STAGE_DIR}" "${REPO_ROOT}/Scripts/build_app.sh"
+ lipo -info "${APP_BUNDLE}/Contents/MacOS/${APP_NAME}"
+}
+
+# The bundle must contain exactly one Mach-O (the main executable); the SwiftPM resource
+# bundle is data only. If this ever changes, nested code must be signed explicitly first.
+assert_single_executable() {
+ local machos=0 f
+ while IFS= read -r -d '' f; do
+ if file -b "${f}" | head -n 1 | grep -q 'Mach-O'; then
+ machos=$((machos + 1))
+ fi
+ done < <(find "${APP_BUNDLE}" -type f -print0)
+ [[ "${machos}" == "1" ]] || fail "expected 1 Mach-O in the bundle, found ${machos}; sign nested code explicitly"
+}
+
+sign_app() {
+ step "Signing app (hardened runtime, secure timestamp)"
+ assert_single_executable
+ codesign --force --options runtime --timestamp \
+ --entitlements "${ENTITLEMENTS}" \
+ --sign "${SIGN_IDENTITY}" \
+ "${APP_BUNDLE}"
+ codesign --verify --strict --deep --verbose=2 "${APP_BUNDLE}"
+ codesign -dv --verbose=4 "${APP_BUNDLE}" 2>&1 | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier|Runtime Version)'
+ step "Gatekeeper assessment of the app (rejected as 'Unnotarized' before notarization)"
+ spctl -a -vv "${APP_BUNDLE}" 2>&1 || true
+}
+
+create_dmg() {
+ step "Creating ${DMG_PATH}"
+ local dmg_root="${DIST_DIR}/dmg-root"
+ rm -rf "${dmg_root}" "${DMG_PATH}" "${DMG_PATH}.sha256"
+ mkdir -p "${dmg_root}"
+ ditto "${APP_BUNDLE}" "${dmg_root}/${APP_NAME}.app"
+ ln -s /Applications "${dmg_root}/Applications"
+ hdiutil create -volname "${APP_NAME} ${VERSION}" -srcfolder "${dmg_root}" \
+ -fs HFS+ -format UDZO -ov "${DMG_PATH}"
+ rm -rf "${dmg_root}"
+ codesign --force --timestamp --sign "${SIGN_IDENTITY}" "${DMG_PATH}"
+ codesign --verify --strict --verbose=2 "${DMG_PATH}"
+}
+
+notarize() {
+ if [[ "${DRY_RUN}" == "1" ]]; then
+ step "DRY_RUN=1: skipping notarization and stapling"
+ return
+ fi
+ local auth=()
+ if [[ -n "${NOTARY_KEY_PATH:-}" ]]; then
+ # CI: App Store Connect API key file (never committed).
+ [[ -n "${NOTARY_KEY_ID:-}" && -n "${NOTARY_ISSUER_ID:-}" ]] \
+ || fail "NOTARY_KEY_PATH requires NOTARY_KEY_ID and NOTARY_ISSUER_ID"
+ auth=(--key "${NOTARY_KEY_PATH}" --key-id "${NOTARY_KEY_ID}" --issuer "${NOTARY_ISSUER_ID}")
+ step "Notarizing (API key ${NOTARY_KEY_ID})"
+ else
+ auth=(--keychain-profile "${NOTARY_PROFILE}")
+ step "Notarizing (keychain profile ${NOTARY_PROFILE})"
+ fi
+ xcrun notarytool submit "${DMG_PATH}" "${auth[@]}" --wait
+ xcrun stapler staple "${DMG_PATH}"
+ xcrun stapler validate "${DMG_PATH}"
+}
+
+final_checks() {
+ step "Gatekeeper assessment of the DMG"
+ if [[ "${DRY_RUN}" == "1" ]]; then
+ spctl -a -t open --context context:primary-signature -vv "${DMG_PATH}" 2>&1 || true
+ else
+ spctl -a -t open --context context:primary-signature -vv "${DMG_PATH}"
+ fi
+ step "Checksum"
+ (cd "${DIST_DIR}" && shasum -a 256 "$(basename "${DMG_PATH}")" > "$(basename "${DMG_PATH}").sha256")
+ cat "${DMG_PATH}.sha256"
+}
+
+preflight
+build_app
+sign_app
+create_dmg
+notarize
+final_checks
+step "Done: ${DMG_PATH}"
diff --git a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift
index 99c243f..5cadab0 100644
--- a/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift
+++ b/Sources/MacOptimizer/Core/Operations/CLICommandRunner.swift
@@ -139,7 +139,8 @@ public struct CLICommandRunner {
}
private static func printVersion() {
- // TODO(TASK-009): share the bundle version with the SwiftPM executable target.
+ // Scripts/build_app.sh writes the repo-root VERSION file into Info.plist, so the
+ // bundled app reports the release version. A bare `swift run` has no Info.plist.
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
?? Bundle.main.infoDictionary?["CFBundleVersion"] as? String
?? "Unknown"
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..fd2a018
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+3.1.0
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..b993661
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,100 @@
+# Releasing MacOptimizer
+
+MacOptimizer ships as a Developer ID-signed, notarized DMG on GitHub Releases. It is not
+sandboxed and is not distributed through the Mac App Store.
+
+## Version
+
+The `VERSION` file at the repo root is the only version source (`MAJOR.MINOR.PATCH`).
+`Scripts/build_app.sh` writes it to `CFBundleShortVersionString`, and writes
+`git rev-list --count HEAD` to `CFBundleVersion`. The CLI `version` command reads the bundle,
+and the release tag must be `v` (the release workflow checks this).
+
+To release a new version: bump `VERSION`, update `CHANGELOG.md`, merge, then follow the local
+flow below.
+
+## App icon
+
+Replace `Resources/Brand/AppIcon-1024.png` (a 1024×1024 PNG) to change the icon.
+`Scripts/build_icns.sh` renders every iconset size from it and runs `iconutil` during each
+build. The current master is a placeholder drawn by `Scripts/make_icon.swift`, which runs
+only when the master file is missing.
+
+## Local release (current flow)
+
+Requirements on the release Mac:
+
+- The signing identity `Developer ID Application: OSMAN CAGRI GENC (N83DXM47FV)` in the login
+ keychain.
+- A notarytool keychain profile named `codifya-notary`
+ (`xcrun notarytool store-credentials`).
+
+```bash
+# 1. Rehearsal: builds, signs and packages; skips notarization.
+DRY_RUN=1 ./Scripts/release.sh
+
+# 2. Real release, from a clean tree on main.
+./Scripts/release.sh
+
+# 3. Publish.
+git tag v$(cat VERSION) && git push origin v$(cat VERSION)
+gh release create v$(cat VERSION) dist/MacOptimizer-$(cat VERSION).dmg \
+ dist/MacOptimizer-$(cat VERSION).dmg.sha256 --generate-notes
+```
+
+`Scripts/release.sh` does the following:
+
+1. Builds a universal (arm64 + x86_64) bundle into `dist/stage/` (`UNIVERSAL=0` for the host
+ architecture only).
+2. Checks that the bundle contains exactly one Mach-O file. The SwiftPM resource bundle
+ (localizations) is data, so the app is signed once, without `--deep`.
+3. Signs with `codesign --force --options runtime --timestamp --entitlements
+ Scripts/MacOptimizer.entitlements`, verifies with `codesign --verify --strict --deep`,
+ and runs `spctl -a -vv`. Before notarization, `spctl` reports "Unnotarized Developer ID".
+ That is expected.
+4. Creates `dist/MacOptimizer-.dmg` (the app plus an `/Applications` link) and signs
+ the DMG.
+5. Submits the DMG once with `notarytool submit --wait`, then staples and validates the
+ ticket on the DMG. Notarizing the DMG also notarizes the app inside it. Gatekeeper checks
+ the copied app online on first launch.
+6. Runs `spctl -a -t open --context context:primary-signature -vv` on the DMG and writes
+ `dist/MacOptimizer-.dmg.sha256`.
+
+Overrides: `SIGN_IDENTITY`, `NOTARY_PROFILE`, `SCRATCH_PATH`, and `ALLOW_DIRTY=1`. A real
+release refuses to run from a dirty tree unless `ALLOW_DIRTY=1` is set. `dist/` is ignored
+by git.
+
+## Entitlements
+
+`Scripts/MacOptimizer.entitlements` is intentionally empty. Hardened runtime comes from
+`--options runtime`. The app is pure Swift with no JIT, plugins, third-party dylibs, Apple
+Events or DYLD variables, so it needs no hardened-runtime exceptions. Keychain access to its
+own items and outbound HTTPS both work without entitlements outside the sandbox. Add an
+entitlement only for a need you have reproduced, and record the reason in the file's comment.
+
+## CI (`.github/workflows/release.yml`)
+
+On a `v*` tag:
+
+- **build-unsigned** always runs. It checks that the tag matches `VERSION`, builds a
+ universal unsigned DMG, and uploads it as a workflow artifact. It does not attach the DMG
+ to a release.
+- **sign-and-notarize** runs only when all of the following repository secrets exist.
+ Without them, the job is skipped and the workflow still succeeds.
+
+| Secret | Content |
+|---|---|
+| `MACOS_CERT_P12` | Base64 of the exported Developer ID Application certificate + private key (`base64 -i cert.p12`) |
+| `MACOS_CERT_PASSWORD` | Password of that `.p12` |
+| `ASC_KEY_ID` | App Store Connect API key ID |
+| `ASC_ISSUER_ID` | App Store Connect issuer ID |
+| `ASC_KEY_P8` | Full text of the `.p8` API key |
+
+ The job imports the certificate into a temporary keychain and writes the `.p8` to the
+ runner temp directory with mode 600. It then runs `Scripts/release.sh`, which notarizes
+ with `--key/--key-id/--issuer`, and creates a **draft** GitHub release with the notarized
+ DMG and its checksum. The temporary keychain and key files are deleted even when the job
+ fails. Use a Developer-role API key for CI, not an Admin one.
+
+`ci.yml` builds the universal bundle on every PR. It checks the version, the icon, both
+`.lproj` folders and both architectures.