diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8db960f..9fac5a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -272,30 +272,10 @@ jobs: python3.12 -m pip install . python3.12 -m pip install pyinstaller==6.13.0 - - name: Create macOS icon from ICO + - name: Create macOS icon run: | set -euo pipefail - python3.12 - <<'PY' - from PIL import Image - - image = Image.open('icon.ico') - image = image.resize((1024, 1024), Image.LANCZOS) - image.save('icon_1024.png', 'PNG') - PY - - mkdir -p icon.iconset - sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png - sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png - sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png - sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png - sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png - sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png - sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png - sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png - sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png - sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png - iconutil -c icns icon.iconset -o icon.icns - rm -rf icon.iconset icon_1024.png + python3.12 macos.py --render-app-icon icon.icns - name: Build app with PyInstaller run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm @@ -303,6 +283,11 @@ jobs: - name: Validate universal2 app bundle run: | set -euo pipefail + ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \ + 'dist/TG WS Proxy.app/Contents/Info.plist')" + test -n "$ICON_FILE" + test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE" + found=0 while IFS= read -r -d '' file; do if file "$file" | grep -q "Mach-O"; then @@ -326,22 +311,31 @@ jobs: - name: Create DMG run: | set -euo pipefail - APP_NAME="TG WS Proxy" - DMG_TEMP="dist/dmg_temp" - - rm -rf "$DMG_TEMP" - mkdir -p "$DMG_TEMP" - cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/" - ln -s /Applications "$DMG_TEMP/Applications" - - hdiutil create \ - -volname "$APP_NAME" \ - -srcfolder "$DMG_TEMP" \ - -ov \ - -format UDZO \ + packaging/dmg/build_dmg.sh \ + "dist/TG WS Proxy.app" \ + "TG WS Proxy" \ "dist/TgWsProxy_macos_universal.dmg" - rm -rf "$DMG_TEMP" + - name: Validate DMG + run: | + set -euo pipefail + for DMG in "dist/TgWsProxy_macos_universal.dmg"; do + MOUNT_DIR="$(mktemp -d)" + DEVICE="$(hdiutil attach \ + -readonly \ + -nobrowse \ + -mountpoint "$MOUNT_DIR" \ + "$DMG" \ + | awk '/^\/dev\// { print $1; exit }')" + + test -d "$MOUNT_DIR/TG WS Proxy.app" + test -L "$MOUNT_DIR/Applications" + test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications" + test -f "$MOUNT_DIR/.background/background.tiff" + test -f "$MOUNT_DIR/.DS_Store" + hdiutil detach "$DEVICE" + rmdir "$MOUNT_DIR" + done - name: Upload artifact uses: actions/upload-artifact@v7 diff --git a/macos.py b/macos.py index d26ff7d..45950cb 100644 --- a/macos.py +++ b/macos.py @@ -9,16 +9,53 @@ import webbrowser from pathlib import Path from typing import Optional -try: - import rumps -except ImportError: - rumps = None - try: from PIL import Image, ImageDraw, ImageFont except ImportError: Image = ImageDraw = ImageFont = None + +def render_app_icon(size: int): + scale = size / 1024 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + outer = tuple(round(value * scale) for value in (92, 92, 932, 932)) + draw.ellipse(outer, fill=(0, 151, 221, 255)) + try: + font = ImageFont.truetype( + "/System/Library/Fonts/Helvetica.ttc", + round(430 * scale), + ) + except Exception: + font = ImageFont.load_default() + box = draw.textbbox((0, 0), "T", font=font) + width = box[2] - box[0] + height = box[3] - box[1] + draw.text( + ( + (size - width) / 2 - box[0], + (size - height) / 2 - box[1] - round(10 * scale), + ), + "T", + font=font, + fill=(255, 255, 255, 255), + ) + return image + + +if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon": + if Image is None: + raise SystemExit("Pillow is required to render the macOS app icon") + output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns" + render_app_icon(1024).save(output_path, format="ICNS") + raise SystemExit(0) + + +try: + import rumps +except ImportError: + rumps = None + try: import pyperclip except ImportError: @@ -144,26 +181,10 @@ def _ask_cfworker_domain(default: str) -> Optional[str]: def _make_menubar_icon(size: int = 44): if Image is None: return None - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - margin = size // 11 - draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255)) - try: - font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55)) - except Exception: - font = ImageFont.load_default() - bbox = draw.textbbox((0, 0), "T", font=font) - tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] - draw.text( - ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), - "T", fill=(255, 255, 255, 255), font=font, - ) - return img + return render_app_icon(size) def _ensure_menubar_icon() -> None: - if MENUBAR_ICON_PATH.exists(): - return ensure_dirs() img = _make_menubar_icon(44) if img: diff --git a/packaging/dmg/assets/background-light.png b/packaging/dmg/assets/background-light.png new file mode 100644 index 0000000..f4ab0cd Binary files /dev/null and b/packaging/dmg/assets/background-light.png differ diff --git a/packaging/dmg/assets/background-light@2x.png b/packaging/dmg/assets/background-light@2x.png new file mode 100644 index 0000000..9012ddf Binary files /dev/null and b/packaging/dmg/assets/background-light@2x.png differ diff --git a/packaging/dmg/build_dmg.sh b/packaging/dmg/build_dmg.sh new file mode 100755 index 0000000..f070d53 --- /dev/null +++ b/packaging/dmg/build_dmg.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_PATH="${1:?Usage: build_dmg.sh [assets_dir]}" +VOL_NAME="${2:?missing volume name}" +OUT_DMG="${3:?missing output dmg path}" +ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}" + +WIN_W=660 +WIN_H=440 +ICON_SIZE=128 +APP_X=145 +APPS_X=515 +ICON_Y=220 + +APP_NAME="$(basename "$APP_PATH")" +WORK="$(mktemp -d)" +STAGE="$WORK/stage" +RW_DMG="$WORK/rw.dmg" +MOUNT="/Volumes/$VOL_NAME" +DEVICE="" + +cleanup() { + if [ -n "$DEVICE" ]; then + hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +mkdir -p "$STAGE/.background" +cp -R "$APP_PATH" "$STAGE/" +ln -s /Applications "$STAGE/Applications" + +tiffutil -cathidpicheck \ + "$ASSETS_DIR/background-light.png" \ + "$ASSETS_DIR/background-light@2x.png" \ + -out "$STAGE/.background/background.tiff" + +hdiutil create \ + -volname "$VOL_NAME" \ + -srcfolder "$STAGE" \ + -fs HFS+ \ + -format UDRW \ + -ov \ + "$RW_DMG" + +DEVICE="$(hdiutil attach \ + -readwrite \ + -noverify \ + -noautoopen \ + -mountpoint "$MOUNT" \ + "$RW_DMG" \ + | awk '/^\/dev\// { print $1; exit }')" +test -n "$DEVICE" +test -d "$MOUNT/$APP_NAME" + +sleep 2 + +osascript </dev/null || true +sync + +hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \ + || { sleep 3; hdiutil detach "$DEVICE" -force; } +DEVICE="" + +rm -f "$OUT_DMG" +hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG" + +echo "Created $OUT_DMG"