Skip to content

Commit cdd491e

Browse files
committed
feat: Add macOS build
1 parent 61fbdd0 commit cdd491e

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

.github/workflows/macos.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
on:
2+
push:
3+
branches: [ "main" ]
4+
pull_request:
5+
branches: [ "main" ]
6+
types: [ "review_requested", "ready_for_review" ]
7+
workflow_dispatch:
8+
name: macOS
9+
permissions:
10+
id-token: write
11+
contents: read
12+
env:
13+
PROJECT_NAME: Nickvision.Application.GNOME
14+
APP_NAME: Application
15+
jobs:
16+
gnome-macos:
17+
name: "GNOME on macOS"
18+
if: ${{ github.event.pull_request.user.login != 'weblate' }}
19+
strategy:
20+
matrix:
21+
variant:
22+
- arch: arm64
23+
runner: macos-latest
24+
runtime: osx-arm64
25+
runs-on: ${{ matrix.variant.runner }}
26+
steps:
27+
- uses: actions/checkout@v6
28+
with:
29+
submodules: recursive
30+
- name: "Setup Environment"
31+
run: brew install gtk4 libadwaita blueprint-compiler gettext glib
32+
- uses: actions/setup-dotnet@v5
33+
with:
34+
dotnet-version: '10.0.x'
35+
- name: "Restore"
36+
run: dotnet restore ${{ env.PROJECT_NAME }} --runtime ${{ matrix.variant.runtime }} /p:PublishReadyToRun=true
37+
- name: "Package"
38+
working-directory: ${{ github.workspace }}
39+
run: |
40+
chmod +x resources/macos/publish-and-package.sh
41+
./resources/macos/publish-and-package.sh ${{ matrix.variant.runtime }}
42+
- uses: actions/upload-artifact@v4
43+
with:
44+
path: ${{ github.workspace }}/resources/macos/${{ env.APP_NAME }}.app
45+
name: ${{ env.APP_NAME }}-${{ matrix.variant.arch }}.app

resources/macos/Info.plist

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleExecutable</key>
6+
<string>@OUTPUT_NAME@</string>
7+
<key>CFBundleIdentifier</key>
8+
<string>@APP_ID@</string>
9+
<key>CFBundleName</key>
10+
<string>@APP_NAME@</string>
11+
<key>CFBundleVersion</key>
12+
<string>2026.2.0</string>
13+
<key>CFBundleShortVersionString</key>
14+
<string>2026.2.0</string>
15+
<key>CFBundleIconFile</key>
16+
<string>@APP_ID@</string>
17+
<key>CFBundlePackageType</key>
18+
<string>APPL</string>
19+
<key>LSMinimumSystemVersion</key>
20+
<string>10.15</string>
21+
</dict>
22+
</plist>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env bash
2+
3+
# Define colors and logging functions
4+
RED="\033[0;31m"
5+
GREEN="\033[0;32m"
6+
YELLOW="\033[1;33m"
7+
BLUE="\033[0;34m"
8+
CYAN="\033[0;36m"
9+
BOLD="\033[1m"
10+
RESET="\033[0m"
11+
12+
info() { echo -e "${CYAN}==>${RESET} $1"; }
13+
success() { echo -e "${GREEN}${RESET} $1"; }
14+
warn() { echo -e "${YELLOW}${RESET} $1"; }
15+
error() { echo -e "${RED}${RESET} $1"; exit 1; }
16+
17+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"
18+
echo -e "${BOLD}${BLUE} Nickvision macOS publish-and-package Script${RESET}"
19+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"
20+
21+
# Initialize script and check arguments
22+
CURRENT_PWD=$(pwd)
23+
set -euo pipefail
24+
if [[ $# -lt 1 ]]; then
25+
error "Usage: $0 runtime"
26+
fi
27+
28+
# Change pwd to script directory
29+
info "Changing to script directory..."
30+
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
31+
cd "$SCRIPT_DIR"
32+
success "Changed to script directory: $SCRIPT_DIR"
33+
34+
# Load variables
35+
info "Loading variables..."
36+
APP_ID="org.nickvision.application"
37+
APP_NAME="Application"
38+
PROJECT="Nickvision.Application.GNOME"
39+
RUNTIME="$1"
40+
APP_BUNDLE="${APP_NAME}.app"
41+
info "Runtime: $RUNTIME"
42+
info "App bundle: $APP_BUNDLE"
43+
success "Loaded variables."
44+
45+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"
46+
echo -e "${BOLD}${BLUE} Publishing and Packaging ${APP_NAME}${RESET}"
47+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"
48+
49+
# Publish application
50+
info "Publishing application..."
51+
export DOTNET_CLI_TELEMETRY_OPTOUT=1
52+
dotnet publish -c Release \
53+
"../../$PROJECT/$PROJECT.csproj" \
54+
--runtime $RUNTIME \
55+
--self-contained true \
56+
-p:PublishReadyToRun=true
57+
PUBLISH_DIR="$(find "../../$PROJECT/bin/Release" -type d -name publish | head -n1)"
58+
if [[ ! -d "$PUBLISH_DIR" ]]; then
59+
error "Publish directory not found!"
60+
fi
61+
success "Published application."
62+
63+
# Create app bundle structure
64+
info "Creating app bundle structure..."
65+
mkdir -p "$APP_BUNDLE/Contents/MacOS"
66+
mkdir -p "$APP_BUNDLE/Contents/Resources"
67+
success "Created app bundle structure."
68+
69+
# Copy published files to app bundle
70+
info "Copying published files to app bundle..."
71+
cp -R "$PUBLISH_DIR/"* "$APP_BUNDLE/Contents/MacOS/"
72+
success "Copied published files to app bundle."
73+
cp "Info.plist" "$APP_BUNDLE/Contents/Info.plist"
74+
sed -i '' "s|@APP_ID@|$APP_ID|g" "$APP_BUNDLE/Contents/Info.plist"
75+
sed -i '' "s|@APP_NAME@|$APP_NAME|g" "$APP_BUNDLE/Contents/Info.plist"
76+
sed -i '' "s|@OUTPUT_NAME@|$PROJECT|g" "$APP_BUNDLE/Contents/Info.plist"
77+
success "Created Info.plist."
78+
79+
# Set app icon
80+
info "Setting app icon..."
81+
if [[ -f "../${APP_ID}.png" ]]; then
82+
mkdir -p AppIcon.iconset
83+
sips -z 16 16 "../${APP_ID}.png" --out AppIcon.iconset/icon_16x16.png
84+
sips -z 32 32 "../${APP_ID}.png" --out AppIcon.iconset/icon_16x16@2x.png
85+
sips -z 32 32 "../${APP_ID}.png" --out AppIcon.iconset/icon_32x32.png
86+
sips -z 64 64 "../${APP_ID}.png" --out AppIcon.iconset/icon_32x32@2x.png
87+
sips -z 128 128 "../${APP_ID}.png" --out AppIcon.iconset/icon_128x128.png
88+
sips -z 256 256 "../${APP_ID}.png" --out AppIcon.iconset/icon_128x128@2x.png
89+
sips -z 256 256 "../${APP_ID}.png" --out AppIcon.iconset/icon_256x256.png
90+
sips -z 512 512 "../${APP_ID}.png" --out AppIcon.iconset/icon_256x256@2x.png
91+
sips -z 512 512 "../${APP_ID}.png" --out AppIcon.iconset/icon_512x512.png
92+
sips -z 1024 1024 "../${APP_ID}.png" --out AppIcon.iconset/icon_512x512@2x.png
93+
iconutil -c icns AppIcon.iconset -o "$APP_BUNDLE/Contents/Resources/${APP_ID}.icns"
94+
rm -rf AppIcon.iconset
95+
success "Set app icon."
96+
else
97+
warn "Icon file not found at ../${APP_ID}.png"
98+
fi
99+
100+
# Bundle GTK4 and libadwaita
101+
info "Bundling GTK4 and libadwaita..."
102+
BREW_PREFIX="$(brew --prefix)"
103+
FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
104+
BUNDLE_RESOURCES_DIR="$APP_BUNDLE/Contents/Resources"
105+
mkdir -p "$FRAMEWORKS_DIR"
106+
mkdir -p "$BUNDLE_RESOURCES_DIR"
107+
108+
# Recursively copy a dylib and its Homebrew-installed dependencies into Contents/Frameworks,
109+
# rewriting install names to use @rpath so the dylibs can resolve each other at runtime.
110+
bundle_dylib() {
111+
local lib_path="$1"
112+
local lib_name
113+
lib_name="$(basename "$lib_path")"
114+
[[ -f "$FRAMEWORKS_DIR/$lib_name" ]] && return
115+
[[ ! -f "$lib_path" ]] && { warn "Library not found: $lib_path"; return; }
116+
cp "$lib_path" "$FRAMEWORKS_DIR/$lib_name"
117+
chmod 755 "$FRAMEWORKS_DIR/$lib_name"
118+
install_name_tool -id "@rpath/$lib_name" "$FRAMEWORKS_DIR/$lib_name" 2>/dev/null || true
119+
# Add @loader_path as rpath so this dylib finds its bundled siblings from the same directory
120+
install_name_tool -add_rpath "@loader_path" "$FRAMEWORKS_DIR/$lib_name" 2>/dev/null || true
121+
while IFS= read -r dep_line; do
122+
local dep_path dep_name
123+
dep_path="$(echo "$dep_line" | awk '{print $1}')"
124+
if [[ "$dep_path" == "$BREW_PREFIX"* && -f "$dep_path" && "$dep_path" != "$lib_path" ]]; then
125+
dep_name="$(basename "$dep_path")"
126+
install_name_tool -change "$dep_path" "@rpath/$dep_name" "$FRAMEWORKS_DIR/$lib_name" 2>/dev/null || true
127+
bundle_dylib "$dep_path"
128+
fi
129+
done < <(otool -L "$lib_path" | tail -n +2)
130+
}
131+
132+
# Bundle libgtk-4 and libadwaita; all transitive dependencies are pulled in automatically
133+
for LIB_NAME in "libgtk-4.1.dylib" "libadwaita-1.0.dylib"; do
134+
LIB_PATH="$(find "$BREW_PREFIX/lib" -name "$LIB_NAME" | head -n1)"
135+
if [[ -n "$LIB_PATH" ]]; then
136+
bundle_dylib "$LIB_PATH"
137+
success "Bundled $LIB_NAME and its dependencies."
138+
else
139+
warn "$LIB_NAME not found under $BREW_PREFIX/lib"
140+
fi
141+
done
142+
143+
# Fix any Homebrew references in .NET-published native libs and add Frameworks rpath
144+
find "$APP_BUNDLE/Contents/MacOS" \( -name "*.dylib" -o -name "*.so" \) | while read -r lib; do
145+
install_name_tool -add_rpath "@loader_path/../Frameworks" "$lib" 2>/dev/null || true
146+
while IFS= read -r dep_line; do
147+
dep_path="$(echo "$dep_line" | awk '{print $1}')"
148+
if [[ "$dep_path" == "$BREW_PREFIX"* && -f "$dep_path" ]]; then
149+
dep_name="$(basename "$dep_path")"
150+
install_name_tool -change "$dep_path" "@rpath/$dep_name" "$lib" 2>/dev/null || true
151+
fi
152+
done < <(otool -L "$lib" | tail -n +2)
153+
done
154+
155+
# Add Frameworks rpath and fix any direct Homebrew references in the main executable
156+
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP_BUNDLE/Contents/MacOS/$PROJECT" 2>/dev/null || true
157+
while IFS= read -r dep_line; do
158+
dep_path="$(echo "$dep_line" | awk '{print $1}')"
159+
if [[ "$dep_path" == "$BREW_PREFIX"* && -f "$dep_path" ]]; then
160+
dep_name="$(basename "$dep_path")"
161+
install_name_tool -change "$dep_path" "@rpath/$dep_name" "$APP_BUNDLE/Contents/MacOS/$PROJECT" 2>/dev/null || true
162+
fi
163+
done < <(otool -L "$APP_BUNDLE/Contents/MacOS/$PROJECT" | tail -n +2)
164+
165+
# Bundle GLib schemas required by GTK4 and libadwaita
166+
info "Bundling GLib schemas..."
167+
SCHEMAS_DEST="$BUNDLE_RESOURCES_DIR/share/glib-2.0/schemas"
168+
mkdir -p "$SCHEMAS_DEST"
169+
find "$BREW_PREFIX/share/glib-2.0/schemas" -name "*.xml" -exec cp {} "$SCHEMAS_DEST/" \; 2>/dev/null || true
170+
glib-compile-schemas "$SCHEMAS_DEST"
171+
success "Bundled GLib schemas."
172+
173+
# Create a launcher script that sets up the GTK environment before running the app.
174+
# DYLD_LIBRARY_PATH is needed because GirCore uses dlopen() by library name at runtime.
175+
info "Creating GTK environment launcher script..."
176+
REAL_BINARY="${PROJECT}.bin"
177+
mv "$APP_BUNDLE/Contents/MacOS/$PROJECT" "$APP_BUNDLE/Contents/MacOS/$REAL_BINARY"
178+
chmod +x "$APP_BUNDLE/Contents/MacOS/$REAL_BINARY"
179+
cat > "$APP_BUNDLE/Contents/MacOS/$PROJECT" << 'LAUNCHER_EOF'
180+
#!/usr/bin/env bash
181+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
182+
BUNDLE_CONTENTS="$(cd "$SCRIPT_DIR/.." && pwd)"
183+
export DYLD_LIBRARY_PATH="$BUNDLE_CONTENTS/Frameworks${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
184+
export GSETTINGS_SCHEMA_DIR="$BUNDLE_CONTENTS/Resources/share/glib-2.0/schemas"
185+
exec "$SCRIPT_DIR/__REAL_BINARY__" "$@"
186+
LAUNCHER_EOF
187+
sed -i '' "s|__REAL_BINARY__|${REAL_BINARY}|g" "$APP_BUNDLE/Contents/MacOS/$PROJECT"
188+
chmod +x "$APP_BUNDLE/Contents/MacOS/$PROJECT"
189+
success "Created GTK environment launcher script."
190+
191+
# Set executable permissions
192+
info "Setting executable permissions..."
193+
chmod +x "$APP_BUNDLE/Contents/MacOS/$PROJECT"
194+
success "Set executable permissions."
195+
196+
# Restore pwd
197+
info "Restoring previous working directory..."
198+
cd "$CURRENT_PWD"
199+
success "Restored working directory to $CURRENT_PWD."
200+
201+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"
202+
echo -e "${BOLD}${GREEN}✔ Published and Packaged ${APP_NAME} Successfully!${RESET}"
203+
echo -e "${BOLD}${BLUE}==============================================================${RESET}"

0 commit comments

Comments
 (0)