diff --git a/.gitignore b/.gitignore index a26a0a7..6a53234 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,11 @@ diag_* !src/app/diag_log.cpp # (re-allow project-authored images by exception if ever needed, e.g.:) # !docs/img/*.png +# App icon source: hand-authored branding art (not ROM-derived), the single +# tracked exception. Every platform icon (.icns/.ico/AppImage png) is derived +# from this at build/package time — see scripts/ci/check_no_rom_data.sh. +!branding/appicon-source.png +!branding/appicon-windows.ico # --- HD texture-pack tooling (tools/texpack) --- # Fetched-on-demand Real-ESRGAN binary + models (third-party, large) and build diff --git a/CMakeLists.txt b/CMakeLists.txt index a8084f3..4c05009 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,11 @@ if(APPLE) set(CMAKE_OBJCXX_STANDARD 17) endif() +# Windows executable icon (windows/mgb64.rc -> branding/appicon-windows.ico). +if(WIN32) + enable_language(RC) +endif() + set(CMAKE_C_STANDARD 11) include(CTest) @@ -569,6 +574,10 @@ add_executable(ge007 ${LIBULTRARE_AUDIO_SOURCES} ) +if(WIN32 AND EXISTS "${CMAKE_SOURCE_DIR}/branding/appicon-windows.ico") + target_sources(ge007 PRIVATE ${CMAKE_SOURCE_DIR}/windows/mgb64.rc) +endif() + if(PORT_SOUNDPLAYER_REAL) target_compile_definitions(ge007 PRIVATE PORT_SOUNDPLAYER_REAL) endif() diff --git a/branding/appicon-source.png b/branding/appicon-source.png new file mode 100644 index 0000000..747eccf Binary files /dev/null and b/branding/appicon-source.png differ diff --git a/branding/appicon-windows.ico b/branding/appicon-windows.ico new file mode 100644 index 0000000..7fd0865 Binary files /dev/null and b/branding/appicon-windows.ico differ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e6a702a..d5466bf 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -51,6 +51,44 @@ Every packager and the bundler run `verify_asset_free.sh` on the binary. --- +## Code signing + notarization (macOS) + +Requires an active [Apple Developer Program](https://developer.apple.com/programs) +enrollment ($99/yr; Individual is fine for a solo maintainer). Signing runs +**locally**, alongside the rest of the macOS build — it is deliberately not +wired into hosted CI, so the Developer ID private key and Apple credentials +never need to leave this machine (see "The model" above). + +One-time setup: + +1. In Xcode → Settings → Accounts (or the [Certificates page](https://developer.apple.com/account/resources/certificates/list)), + create a **Developer ID Application** certificate (not "Apple Distribution" — + that one's for the App Store). This installs the cert + private key into + your login keychain. +2. Note your **Team ID** (Membership page) and identity string, e.g. + `Developer ID Application: Jane Doe (ABCDE12345)`. +3. Generate an app-specific password at + [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → + App-Specific Passwords, for `notarytool` to authenticate with. + +Export these before running a signed release: + +```sh +export DEVELOPER_ID_APPLICATION="Developer ID Application: Jane Doe (ABCDE12345)" +export APPLE_ID="you@example.com" +export APPLE_TEAM_ID="ABCDE12345" +export APPLE_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" # or @keychain:label +``` + +Then pass `--sign` to `scripts/release.sh` (see below). Under the hood this +calls `macos/Scripts/sign_and_notarize.sh`, which signs the app bundle, +submits it to Apple's notary service, waits for approval, and staples the +ticket — all before the `.zip` is created, so the staple travels with it. +Without `--sign`, the app ships ad-hoc signed and Gatekeeper will warn +end users on first launch. + +--- + ## Cut a release 1. **Windows + Linux (CI):** a maintainer dispatches the release workflow @@ -62,13 +100,16 @@ Every packager and the bundler run `verify_asset_free.sh` on the binary. ```sh # build + validate macOS, stage all dist/ assets, and publish the release: scripts/release.sh --version v0.3.0 --repo akratch/mgb64 --publish + # signed + notarized (credentials exported per "Code signing" above): + scripts/release.sh --version v0.3.0 --repo akratch/mgb64 --sign --publish # or a rolling 'latest' prerelease refreshed from main: scripts/release.sh --version v0.3.0 --repo akratch/mgb64 --rolling-latest --publish ``` Without `--publish` it only builds/stages `dist/` assets and prints the next command. `--publish` requires `gh` auth and creates/updates the GitHub Release, attaching every `dist/mgb64-*-.*` present (macOS locally + - Windows/Linux from CI). + Windows/Linux from CI). Without `--sign`, the macOS asset ships ad-hoc + signed and Gatekeeper will warn end users on first launch. The README's **Download** table links to `/releases/latest`, so a rolling `latest` prerelease keeps the download current between tagged majors. diff --git a/macos/Scripts/build_app_bundle.sh b/macos/Scripts/build_app_bundle.sh index 54ffb74..7426f40 100755 --- a/macos/Scripts/build_app_bundle.sh +++ b/macos/Scripts/build_app_bundle.sh @@ -286,10 +286,18 @@ done ICONSET_DIR="${BUILD_DIR}/AppIcon.iconset" APP_ICON="${OUTPUT_APP}/Contents/Resources/AppIcon.icns" +ICON_SOURCE="${PROJECT_ROOT}/branding/appicon-source.png" +ICON_SOURCE_ARGS=() +if [[ -f "${ICON_SOURCE}" ]]; then + ICON_SOURCE_ARGS=(--source "${ICON_SOURCE}") +else + warn "branding/appicon-source.png not found; using the placeholder procedural icon." +fi info "Generating app icon..." python3 "${PROJECT_ROOT}/macos/Scripts/generate_app_icon.py" \ --iconset "${ICONSET_DIR}" \ --icns "${APP_ICON}" \ + "${ICON_SOURCE_ARGS[@]}" \ || die "App icon generation failed." [[ -s "${APP_ICON}" ]] || die "Generated app icon is missing: ${APP_ICON}" diff --git a/macos/Scripts/build_gl_app.sh b/macos/Scripts/build_gl_app.sh index 567a17b..a697198 100755 --- a/macos/Scripts/build_gl_app.sh +++ b/macos/Scripts/build_gl_app.sh @@ -68,12 +68,20 @@ cp "${PROJECT_ROOT}/macos/Resources/Info.plist" "${INFO_PLIST}" # Ensure a high-DPI-capable, non-transparent GL window is declared sane. /usr/libexec/PlistBuddy -c "Set :CFBundleName MGB64" "${INFO_PLIST}" 2>/dev/null || true -# --- 4. App icon (generated from auditable Python, no binary asset tracked) --- +# --- 4. App icon (generated from branding/appicon-source.png via sips) --- +ICON_SOURCE="${PROJECT_ROOT}/branding/appicon-source.png" +ICON_SOURCE_ARGS=() +if [[ -f "${ICON_SOURCE}" ]]; then + ICON_SOURCE_ARGS=(--source "${ICON_SOURCE}") +else + info "branding/appicon-source.png not found; using the placeholder procedural icon." +fi info "Generating app icon..." ICONSET_DIR="$(mktemp -d)/AppIcon.iconset" python3 "${PROJECT_ROOT}/macos/Scripts/generate_app_icon.py" \ --iconset "${ICONSET_DIR}" \ --icns "${OUTPUT_APP}/Contents/Resources/AppIcon.icns" \ + "${ICON_SOURCE_ARGS[@]}" \ || die "App icon generation failed." # --- 5. Bundle the linked SDL2 dylib --- diff --git a/macos/Scripts/generate_app_icon.py b/macos/Scripts/generate_app_icon.py index 73cff7b..40cea1b 100755 --- a/macos/Scripts/generate_app_icon.py +++ b/macos/Scripts/generate_app_icon.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Generate the project-owned macOS app icon at build time. +"""Generate the macOS app icon at build time. -The release guard intentionally rejects tracked PNG/ICNS files because those -formats are also common ROM-derived asset leaks. This script keeps the app icon -source as auditable code and produces the binary icon only inside build output. +With --source, resizes the tracked branding/appicon-source.png (a narrow, +audited exception to the release guard's tracked-binary ban -- see +scripts/ci/check_no_rom_data.sh) into an .iconset via `sips`. Without --source, +falls back to the original procedurally-rendered placeholder (used by the +asset-free-verifier test harness, which just needs *an* icon, not the real one). """ from __future__ import annotations @@ -180,13 +182,29 @@ def generate_iconset(iconset: Path) -> None: write_png(iconset / filename, base_size * scale) +def generate_iconset_from_source(source: Path, iconset: Path) -> None: + if iconset.exists(): + shutil.rmtree(iconset) + iconset.mkdir(parents=True) + for base_size, scale, filename in ICON_SPECS: + px = base_size * scale + subprocess.run( + ["sips", "-z", str(px), str(px), str(source), "--out", str(iconset / filename)], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--iconset", required=True, type=Path, help="Output .iconset directory") parser.add_argument("--icns", type=Path, help="Optional output .icns path") + parser.add_argument("--source", type=Path, help="Real branding PNG to resize instead of the procedural placeholder") args = parser.parse_args() - generate_iconset(args.iconset) + if args.source is not None: + generate_iconset_from_source(args.source, args.iconset) + else: + generate_iconset(args.iconset) if args.icns is not None: args.icns.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/ci/check_no_rom_data.sh b/scripts/ci/check_no_rom_data.sh index bcd2b3d..1b14916 100755 --- a/scripts/ci/check_no_rom_data.sh +++ b/scripts/ci/check_no_rom_data.sh @@ -49,6 +49,10 @@ grep_public_files() { } # 1) Forbidden file types (ROMs, extracted asset binaries, build/vendor binaries) +# Exception: branding/appicon-source.png + branding/appicon-windows.ico are +# hand-authored project branding art (not ROM-derived) — the single tracked +# source every platform icon is generated/wrapped from at build/package +# time. See the .gitignore comment above this same exception. if [ "$HAVE_GIT" -eq 1 ]; then forbidden=$(git ls-files \ '*.z64' '*.n64' '*.v64' '*.rom' '*.bin' '*.rz' '*.eeprom' '*.cdata*' \ @@ -57,10 +61,12 @@ if [ "$HAVE_GIT" -eq 1 ]; then '*.bmp' '*.png' '*.jpg' '*.jpeg' '*.gif' '*.webp' '*.ico' '*.icns' '*.ppm' '*.pgm' '*.pnm' \ '*.raw' '*.wav' '*.mp3' '*.ogg' '*.flac' '*.m4a' '*.aac' \ '*.mp4' '*.mov' '*.m4v' '*.mkv' '*.avi' '*.webm' '*.jsonl' \ - '*.dmg' '*.zip' '*.7z' '*.tar' '*.tgz' '*.gz' 2>/dev/null || true) + '*.dmg' '*.zip' '*.7z' '*.tar' '*.tgz' '*.gz' \ + ':!branding/appicon-source.png' ':!branding/appicon-windows.ico' 2>/dev/null || true) else forbidden=$(public_file_list \ | grep -E '(^|/)baserom|\.((z64|n64|v64|bin|rz|eeprom|ctl|tbl|sbk|seq|aifc|aiff|seg|o|a|so|dll|dylib|exe|bmp|png|jpe?g|gif|webp|ico|icns|ppm|pgm|pnm|raw|wav|mp3|ogg|flac|m4a|aac|mp4|mov|m4v|mkv|avi|webm|jsonl|dmg|zip|7z|tar|tgz|gz))$|\.cdata($|\.)' \ + | grep -v -E '^branding/appicon-(source\.png|windows\.ico)$' \ || true) fi if [ -n "$forbidden" ]; then diff --git a/scripts/ci/check_release_ready.sh b/scripts/ci/check_release_ready.sh index 4f05ab0..d81c6d9 100755 --- a/scripts/ci/check_release_ready.sh +++ b/scripts/ci/check_release_ready.sh @@ -43,10 +43,13 @@ scripts/ci/check_no_rom_data.sh echo echo "== Git history filename audit ==" if [ "$HAVE_GIT" -eq 1 ]; then + # branding/appicon-source.png + branding/appicon-windows.ico: same tracked + # exception as check_no_rom_data.sh (hand-authored branding, not ROM-derived). history_hits=$(git log --all --name-only --pretty=format: \ | awk 'NF' \ | sort -u \ | grep -E '\.(z64|n64|v64|rom|bin|bmp|png|jpe?g|gif|webp|ico|icns|ppm|raw|wav|mp3|ogg|flac|m4a|aac|mp4|mov|m4v|mkv|avi|webm|jsonl|ctl|tbl|aifc|aiff|sbk|seq|cdata|dmg|zip|7z|tar|tgz|gz)$|(^|/)baserom|(^|/)[^/]+\.app(/|$)|ge007_eeprom|ge007\.ini|(^|/)screenshot_[^/]*\.(bmp|png|jpe?g|gif|webp|ppm|raw|jsonl|mp4|mov|m4v|webm)$' \ + | grep -v -E '^branding/appicon-(source\.png|windows\.ico)$' \ || true) if [ -n "$history_hits" ]; then while IFS= read -r f; do note "ROM/media/build artifact path found in git history: $f"; done <<< "$history_hits" @@ -83,6 +86,12 @@ fi binary_hits=$( while IFS= read -r f; do [ -s "$f" ] || continue + # Exception: hand-authored branding art (not ROM-derived), the single + # source every platform icon is generated/wrapped from. See the matching + # exception in .gitignore and scripts/ci/check_no_rom_data.sh. + case "$f" in + branding/appicon-source.png|branding/appicon-windows.ico) continue ;; + esac if ! grep -Iq . "$f" 2>/dev/null; then printf '%s\n' "$f" fi diff --git a/scripts/make_public_source_archive.sh b/scripts/make_public_source_archive.sh index a3af878..cec8f67 100755 --- a/scripts/make_public_source_archive.sh +++ b/scripts/make_public_source_archive.sh @@ -88,7 +88,11 @@ tar -tzf "$tmparchive" > "$listfile" echo echo "== Validating archive listing ==" -forbidden="$(grep -E '\.(z64|n64|v64|rom|bin|bmp|png|jpe?g|gif|webp|ico|icns|ppm|raw|wav|mp3|ogg|flac|ctl|tbl|aifc|aiff|sbk|seq|cdata|dmg|zip|7z|tar|tgz|gz)$|(^|/)baserom|(^|/)[^/]+\.app(/|$)|ge007_eeprom|ge007\.ini|(^|/)screenshot_[^/]*\.(bmp|png|jpe?g|gif|webp|ppm|raw|jsonl|mp4|mov|m4v|webm)$' "$listfile" || true)" +# branding/appicon-source.png + branding/appicon-windows.ico are hand-authored +# project branding (not ROM-derived) — the same tracked exception as +# .gitignore / scripts/ci/check_no_rom_data.sh. +forbidden="$(grep -E '\.(z64|n64|v64|rom|bin|bmp|png|jpe?g|gif|webp|ico|icns|ppm|raw|wav|mp3|ogg|flac|ctl|tbl|aifc|aiff|sbk|seq|cdata|dmg|zip|7z|tar|tgz|gz)$|(^|/)baserom|(^|/)[^/]+\.app(/|$)|ge007_eeprom|ge007\.ini|(^|/)screenshot_[^/]*\.(bmp|png|jpe?g|gif|webp|ppm|raw|jsonl|mp4|mov|m4v|webm)$' "$listfile" \ + | grep -v -E '/branding/appicon-(source\.png|windows\.ico)$' || true)" if [ -n "$forbidden" ]; then echo "Archive contains forbidden ROM/media/build-artifact path(s):" >&2 printf '%s\n' "$forbidden" >&2 diff --git a/scripts/package_linux_appimage.sh b/scripts/package_linux_appimage.sh index ca31672..e09f1a5 100755 --- a/scripts/package_linux_appimage.sh +++ b/scripts/package_linux_appimage.sh @@ -58,10 +58,15 @@ Type=Application Categories=Game; Comment=The Man with the Golden Build EOF -# 1x1 charcoal PNG placeholder (base64) — replace with a real icon later. -base64 -d > "$appdir/mgb64.png" <<'EOF' +icon_source="branding/appicon-source.png" +if [[ -f "$icon_source" ]]; then + cp "$icon_source" "$appdir/mgb64.png" +else + # 1x1 charcoal PNG placeholder (base64) — used if branding art is missing. + base64 -d > "$appdir/mgb64.png" <<'EOF' iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== EOF +fi cp LICENSE README.md "$appdir/" 2>/dev/null || true diff --git a/scripts/release.sh b/scripts/release.sh index e55767c..186cc1f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -19,6 +19,8 @@ rolling=0 repo="" skip_macos=0 universal=0 +sign=0 +skip_notarize=0 usage() { cat <<'USAGE' @@ -32,6 +34,12 @@ Usage: scripts/release.sh [options] arch only. A universal build needs a universal SDL2; a plain Homebrew SDL2 is single-arch and will fail the x86_64 link, so the shipped prebuilt is Apple-Silicon-only (see README). + --sign Code-sign + notarize MGB64.app with a Developer ID cert + before zipping. Requires DEVELOPER_ID_APPLICATION, + APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD in the + environment (see docs/RELEASING.md). + --skip-notarize With --sign, sign only (skip the notarization submission). + Useful for a quick local check of the signing identity. USAGE } while [[ $# -gt 0 ]]; do @@ -42,11 +50,23 @@ while [[ $# -gt 0 ]]; do --rolling-latest) rolling=1; shift ;; --skip-macos) skip_macos=1; shift ;; --universal) universal=1; shift ;; + --sign) sign=1; shift ;; + --skip-notarize) skip_notarize=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown arg: $1" >&2; usage; exit 1 ;; esac done +# Fail fast on missing signing credentials, before the (slow) build below. +if [[ "$sign" -eq 1 ]]; then + : "${DEVELOPER_ID_APPLICATION:?--sign requires DEVELOPER_ID_APPLICATION (e.g. 'Developer ID Application: Name (TEAMID)')}" + if [[ "$skip_notarize" -eq 0 ]]; then + : "${APPLE_ID:?--sign requires APPLE_ID, or pass --skip-notarize}" + : "${APPLE_TEAM_ID:?--sign requires APPLE_TEAM_ID, or pass --skip-notarize}" + : "${APPLE_APP_PASSWORD:?--sign requires APPLE_APP_PASSWORD, or pass --skip-notarize}" + fi +fi + dist="dist"; mkdir -p "$dist" # 1. macOS app + .zip (built + validated on this machine). @@ -62,6 +82,14 @@ if [[ "$skip_macos" -eq 0 ]]; then ./macos/Scripts/build_gl_app.sh --output build-macos-app/MGB64.app fi ./macos/Scripts/verify_asset_free.sh build-macos-app/MGB64.app + if [[ "$sign" -eq 1 ]]; then + echo "[release] signing MGB64.app (Developer ID)..." + sign_args=(build-macos-app/MGB64.app) + [[ "$skip_notarize" -eq 1 ]] && sign_args+=(--skip-notarize) + ./macos/Scripts/sign_and_notarize.sh "${sign_args[@]}" + else + echo "[release] --sign not passed: MGB64.app will ship ad-hoc signed (Gatekeeper will warn)." + fi ( cd build-macos-app && ditto -c -k --sequesterRsrc --keepParent MGB64.app \ "$OLDPWD/$dist/mgb64-macos-$version.zip" ) echo "[release] macOS asset: dist/mgb64-macos-$version.zip" diff --git a/scripts/wrap_png_as_ico.sh b/scripts/wrap_png_as_ico.sh new file mode 100755 index 0000000..1b10cc4 --- /dev/null +++ b/scripts/wrap_png_as_ico.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# wrap_png_as_ico.sh -- wrap a PNG into a minimal single-image .ico container +# (the Vista+ "PNG-compressed icon" format) using only printf/cat -- no image +# library required. Windows decodes the embedded PNG for its true dimensions +# and auto-scales it for smaller UI (taskbar, title bar, etc.). +# +# Usage: wrap_png_as_ico.sh +set -euo pipefail + +src="${1:?Usage: $0 }" +dst="${2:?Usage: $0 }" +[[ -f "$src" ]] || { echo "ERROR: source PNG not found: $src" >&2; exit 1; } + +size=$(wc -c < "$src" | tr -d ' ') + +# u32 little-endian byte count, as four \xHH escapes for printf. +b0=$(( size & 0xFF )); b1=$(( (size >> 8) & 0xFF )) +b2=$(( (size >> 16) & 0xFF )); b3=$(( (size >> 24) & 0xFF )) +size_le=$(printf '\\x%02x\\x%02x\\x%02x\\x%02x' "$b0" "$b1" "$b2" "$b3") + +{ + # ICONDIR: reserved=0 (u16), type=1 (u16, "icon"), count=1 (u16) + printf '\x00\x00\x01\x00\x01\x00' + # ICONDIRENTRY: width=0(=256) height=0(=256) colorCount=0 reserved=0 + # planes=1 (u16) bitCount=32 (u16) + printf '\x00\x00\x00\x00\x01\x00\x20\x00' + # bytesInRes (u32 LE) -- the embedded PNG's byte length + printf -- "$size_le" + # imageOffset (u32 LE) -- always 22: a fixed 6-byte ICONDIR + one 16-byte entry + printf '\x16\x00\x00\x00' + cat "$src" +} > "$dst" + +echo "wrote $dst ($(wc -c < "$dst" | tr -d ' ') bytes)" diff --git a/windows/mgb64.rc b/windows/mgb64.rc new file mode 100644 index 0000000..b24eee6 --- /dev/null +++ b/windows/mgb64.rc @@ -0,0 +1,4 @@ +// MGB64 Windows executable icon. The .ico is a single-entry, PNG-compressed +// (Vista+) icon wrapping branding/appicon-source.png byte-for-byte -- +// see scripts/wrap_png_as_ico.sh. +IDI_ICON1 ICON "../branding/appicon-windows.ico"