diff --git a/assets/macos/acecode-dmg-background.svg b/assets/macos/acecode-dmg-background.svg
new file mode 100644
index 00000000..ece96b5e
--- /dev/null
+++ b/assets/macos/acecode-dmg-background.svg
@@ -0,0 +1,66 @@
+
+
diff --git a/assets/macos/acecode-installer.svg b/assets/macos/acecode-installer.svg
new file mode 100644
index 00000000..d0e700dd
--- /dev/null
+++ b/assets/macos/acecode-installer.svg
@@ -0,0 +1,75 @@
+
+
diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake
index 6d103a5e..dde31176 100644
--- a/cmake/acecode_desktop.cmake
+++ b/cmake/acecode_desktop.cmake
@@ -104,10 +104,30 @@ endif()
if(APPLE)
set(ACECODE_MACOS_ICON "${CMAKE_SOURCE_DIR}/assets/macos/acecode.icns")
+ set(ACECODE_MACOS_INSTALLER_ICON_SOURCE
+ "${CMAKE_SOURCE_DIR}/assets/macos/acecode-installer.svg")
+ set(ACECODE_MACOS_INSTALLER_ICON
+ "${CMAKE_BINARY_DIR}/generated/macos/acecode-installer.icns")
+ add_custom_command(
+ OUTPUT "${ACECODE_MACOS_INSTALLER_ICON}"
+ COMMAND /bin/bash
+ "${CMAKE_SOURCE_DIR}/scripts/macos_generate_icns.sh"
+ --source "${ACECODE_MACOS_INSTALLER_ICON_SOURCE}"
+ --output "${ACECODE_MACOS_INSTALLER_ICON}"
+ DEPENDS
+ "${ACECODE_MACOS_INSTALLER_ICON_SOURCE}"
+ "${CMAKE_SOURCE_DIR}/scripts/macos_generate_icns.sh"
+ COMMENT "Generating ACECode current-user installer icon"
+ VERBATIM
+ )
target_sources(acecode-desktop PRIVATE "${ACECODE_MACOS_ICON}")
set_source_files_properties("${ACECODE_MACOS_ICON}" PROPERTIES
MACOSX_PACKAGE_LOCATION "Resources"
)
+ set_source_files_properties("${ACECODE_MACOS_INSTALLER_ICON}" PROPERTIES
+ GENERATED TRUE
+ MACOSX_PACKAGE_LOCATION "Resources"
+ )
set_target_properties(acecode-desktop PROPERTIES
# Keep the app bundle user-facing while avoiding a case-insensitive
# collision with the bundled daemon binary copied below.
@@ -141,7 +161,7 @@ if(APPLE)
${CMAKE_SOURCE_DIR}/src/macos_installer/main.mm
${CMAKE_SOURCE_DIR}/src/desktop/user_install_policy.cpp
${CMAKE_SOURCE_DIR}/src/desktop/user_install_policy.hpp
- ${ACECODE_MACOS_ICON}
+ ${ACECODE_MACOS_INSTALLER_ICON}
)
target_include_directories(acecode-user-installer PRIVATE
${CMAKE_SOURCE_DIR}/src
@@ -153,7 +173,7 @@ if(APPLE)
set_target_properties(acecode-user-installer PROPERTIES
RUNTIME_OUTPUT_NAME "Install ACECode"
MACOSX_BUNDLE_BUNDLE_NAME "Install ACECode"
- MACOSX_BUNDLE_ICON_FILE "acecode.icns"
+ MACOSX_BUNDLE_ICON_FILE "acecode-installer.icns"
MACOSX_BUNDLE_GUI_IDENTIFIER "dev.acecode.installer"
MACOSX_BUNDLE_INFO_PLIST
"${CMAKE_SOURCE_DIR}/cmake/macos/ACECodeUserInstallerInfo.plist.in"
diff --git a/cmake/macos/ACECodeUserInstallerInfo.plist.in b/cmake/macos/ACECodeUserInstallerInfo.plist.in
index f9028629..70649a5a 100644
--- a/cmake/macos/ACECodeUserInstallerInfo.plist.in
+++ b/cmake/macos/ACECodeUserInstallerInfo.plist.in
@@ -5,6 +5,25 @@
CFBundleDevelopmentRegion
en
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeExtensions
+
+ app
+
+ CFBundleTypeName
+ ACECode Application Bundle
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.apple.application-bundle
+
+
+
CFBundleDisplayName
${MACOSX_BUNDLE_BUNDLE_NAME}
CFBundleExecutable
diff --git a/scripts/macos_create_dmg.sh b/scripts/macos_create_dmg.sh
index 4e4a98d8..f0e64290 100755
--- a/scripts/macos_create_dmg.sh
+++ b/scripts/macos_create_dmg.sh
@@ -6,11 +6,11 @@ usage() {
Usage:
scripts/macos_create_dmg.sh --app \
--installer --output \
- [--volume-name ] [--instructions ]
+ [--volume-name ] [--instructions ] [--background ]
-Creates a compressed read-only DMG containing ACECode.app, the current-user
-installer, and bilingual instructions. The image never contains a link to the
-system /Applications directory.
+Creates a styled compressed read-only DMG with ACECode.app on the left and the
+current-user installer on the right. The installer writes only to
+~/Applications; the image never contains a link to system /Applications.
USAGE
}
@@ -19,6 +19,7 @@ installer_path=""
output_path=""
volume_name="ACECode"
instructions_path=""
+background_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -47,6 +48,11 @@ while [[ $# -gt 0 ]]; do
instructions_path="$2"
shift 2
;;
+ --background)
+ [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --background" >&2; exit 2; }
+ background_path="$2"
+ shift 2
+ ;;
--help|-h)
usage
exit 0
@@ -88,10 +94,23 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -z "$instructions_path" ]]; then
instructions_path="$script_dir/../assets/macos/DMG_INSTALL.txt"
fi
+if [[ -z "$background_path" ]]; then
+ background_path="$script_dir/../assets/macos/acecode-dmg-background.svg"
+fi
+volume_icon_path="$script_dir/../assets/macos/acecode.icns"
+
if [[ ! -f "$instructions_path" ]]; then
echo "Missing DMG instructions: $instructions_path" >&2
exit 1
fi
+if [[ ! -f "$background_path" ]]; then
+ echo "Missing DMG background: $background_path" >&2
+ exit 1
+fi
+if [[ ! -f "$volume_icon_path" ]]; then
+ echo "Missing DMG volume icon: $volume_icon_path" >&2
+ exit 1
+fi
output_parent="$(dirname "$output_path")"
mkdir -p "$output_parent"
@@ -99,7 +118,16 @@ mkdir -p "$output_parent"
temporary_base="${TMPDIR:-/tmp}"
temporary_base="${temporary_base%/}"
temporary_root="$(mktemp -d "${temporary_base}/acecode-dmg.XXXXXX")"
+mount_root=""
+detach_target=""
+mounted=0
cleanup() {
+ if [[ "${mounted:-0}" -eq 1 ]]; then
+ local cleanup_target="${mount_root:-${detach_target:-}}"
+ if [[ -n "$cleanup_target" ]]; then
+ /usr/bin/hdiutil detach "$cleanup_target" -force >/dev/null 2>&1 || true
+ fi
+ fi
if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then
rm -rf -- "$temporary_root"
fi
@@ -107,23 +135,141 @@ cleanup() {
trap cleanup EXIT
staging_root="$temporary_root/root"
-mkdir -p "$staging_root"
+background_root="$staging_root/.background"
+mkdir -p "$staging_root" "$background_root"
/usr/bin/ditto "$app_path" "$staging_root/ACECode.app"
/usr/bin/ditto "$installer_path" "$staging_root/Install ACECode.app"
-/usr/bin/ditto "$instructions_path" "$staging_root/Install Instructions 安装说明.txt"
+/usr/bin/ditto "$instructions_path" "$staging_root/README 安装说明.txt"
+/usr/bin/sips -s format png "$background_path" \
+ --out "$background_root/ACECode-DMG.png" >/dev/null
if [[ -e "$staging_root/Applications" || -L "$staging_root/Applications" ]]; then
echo "Refusing to package a system /Applications link." >&2
exit 1
fi
+staging_size_kb="$(/usr/bin/du -sk "$staging_root" | /usr/bin/awk '{print $1}')"
+image_size_mb=$(( (staging_size_kb + 1023) / 1024 + 48 ))
+writable_dmg="$temporary_root/ACECode-layout.dmg"
+
/usr/bin/hdiutil create \
-volname "$volume_name" \
-srcfolder "$staging_root" \
+ -size "${image_size_mb}m" \
+ -fs HFS+ \
+ -format UDRW \
+ -ov \
+ "$writable_dmg"
+
+attach_plist="$temporary_root/attach.plist"
+/usr/bin/hdiutil attach \
+ -readwrite \
+ -noverify \
+ -noautoopen \
+ -plist \
+ "$writable_dmg" >"$attach_plist"
+mounted=1
+
+for entity_index in 0 1 2 3 4 5 6 7; do
+ device_candidate="$(/usr/libexec/PlistBuddy \
+ -c "Print :system-entities:${entity_index}:dev-entry" \
+ "$attach_plist" 2>/dev/null || true)"
+ if [[ -n "$device_candidate" && -z "$detach_target" ]]; then
+ detach_target="$device_candidate"
+ fi
+ mount_candidate="$(/usr/libexec/PlistBuddy \
+ -c "Print :system-entities:${entity_index}:mount-point" \
+ "$attach_plist" 2>/dev/null || true)"
+ if [[ -n "$mount_candidate" ]]; then
+ mount_root="$mount_candidate"
+ if [[ -n "$device_candidate" ]]; then
+ detach_target="$device_candidate"
+ fi
+ break
+ fi
+done
+if [[ -z "$mount_root" || ! -d "$mount_root" ]]; then
+ echo "Could not determine the mounted DMG path." >&2
+ exit 1
+fi
+
+# Keep the background resources out of the visible Finder layout.
+/usr/bin/SetFile -a V "$mount_root/.background"
+
+apply_finder_layout() {
+ /usr/bin/osascript - "$mount_root" <<'APPLESCRIPT'
+on run argv
+ set expectedMountPath to item 1 of argv
+
+ tell application "Finder"
+ activate
+ set mountedFolder to folder (POSIX file expectedMountPath as alias)
+ tell mountedFolder
+ open
+ set dmgWindow to container window
+ set current view of dmgWindow to icon view
+ set toolbar visible of dmgWindow to false
+ set statusbar visible of dmgWindow to false
+ set pathbar visible of dmgWindow to false
+ set bounds of dmgWindow to {100, 100, 820, 620}
+
+ set iconOptions to icon view options of dmgWindow
+ set arrangement of iconOptions to not arranged
+ set icon size of iconOptions to 104
+ set text size of iconOptions to 13
+ set label position of iconOptions to bottom
+ set shows icon preview of iconOptions to false
+ set background picture of iconOptions to file ".background:ACECode-DMG.png"
+
+ set position of item "ACECode.app" of dmgWindow to {170, 220}
+ set position of item "Install ACECode.app" of dmgWindow to {550, 220}
+ set position of item "README 安装说明.txt" of dmgWindow to {360, 365}
+
+ update without registering applications
+ delay 2
+ close dmgWindow
+ end tell
+ end tell
+end run
+APPLESCRIPT
+}
+
+layout_log="$temporary_root/finder-layout.log"
+layout_applied=0
+for attempt in 1 2 3; do
+ if apply_finder_layout >"$layout_log" 2>&1; then
+ layout_applied=1
+ break
+ fi
+ echo "Finder layout attempt $attempt failed:" >&2
+ sed -n '1,120p' "$layout_log" >&2
+ sleep 2
+done
+if [[ "$layout_applied" -ne 1 ]]; then
+ echo "Could not apply the ACECode DMG Finder layout." >&2
+ exit 1
+fi
+
+# Finder may clean volume-icon metadata while first creating .DS_Store, so add
+# the custom mounted-volume icon only after the layout window has closed.
+/usr/bin/ditto "$volume_icon_path" "$mount_root/.VolumeIcon.icns"
+/usr/bin/SetFile -a V "$mount_root/.VolumeIcon.icns"
+/usr/bin/SetFile -a C "$mount_root"
+if [[ ! -f "$mount_root/.VolumeIcon.icns" ]]; then
+ echo "Could not add the DMG volume icon." >&2
+ exit 1
+fi
+
+/bin/sync
+/usr/bin/hdiutil detach "$mount_root"
+mounted=0
+
+/usr/bin/hdiutil convert "$writable_dmg" \
-format UDZO \
+ -imagekey zlib-level=9 \
-ov \
- "$output_path"
+ -o "$output_path"
/usr/bin/hdiutil verify "$output_path"
echo "Created $output_path"
diff --git a/scripts/macos_generate_icns.sh b/scripts/macos_generate_icns.sh
new file mode 100755
index 00000000..4c76fb9a
--- /dev/null
+++ b/scripts/macos_generate_icns.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ scripts/macos_generate_icns.sh --source --output
+
+Renders an SVG at every macOS iconset size using sips, then creates an ICNS
+file using iconutil. This helper must run on macOS.
+USAGE
+}
+
+source_path=""
+output_path=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --source)
+ [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --source" >&2; exit 2; }
+ source_path="$2"
+ shift 2
+ ;;
+ --output)
+ [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --output" >&2; exit 2; }
+ output_path="$2"
+ shift 2
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "$source_path" || -z "$output_path" ]]; then
+ echo "--source and --output are required." >&2
+ usage >&2
+ exit 2
+fi
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ echo "ICNS generation must run on macOS." >&2
+ exit 1
+fi
+if [[ ! -f "$source_path" ]]; then
+ echo "Missing icon source: $source_path" >&2
+ exit 1
+fi
+if [[ "$output_path" != *.icns ]]; then
+ echo "ICNS output must end in .icns: $output_path" >&2
+ exit 2
+fi
+
+output_parent="$(dirname "$output_path")"
+mkdir -p "$output_parent"
+
+temporary_base="${TMPDIR:-/tmp}"
+temporary_base="${temporary_base%/}"
+temporary_root="$(mktemp -d "${temporary_base}/acecode-icon.XXXXXX")"
+cleanup() {
+ if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then
+ rm -rf -- "$temporary_root"
+ fi
+}
+trap cleanup EXIT
+
+iconset_path="$temporary_root/acecode-installer.iconset"
+mkdir -p "$iconset_path"
+
+render_icon() {
+ local pixels="$1"
+ local filename="$2"
+ /usr/bin/sips -s format png -z "$pixels" "$pixels" \
+ "$source_path" --out "$iconset_path/$filename" >/dev/null
+}
+
+render_icon 16 icon_16x16.png
+render_icon 32 icon_16x16@2x.png
+render_icon 32 icon_32x32.png
+render_icon 64 icon_32x32@2x.png
+render_icon 128 icon_128x128.png
+render_icon 256 icon_128x128@2x.png
+render_icon 256 icon_256x256.png
+render_icon 512 icon_256x256@2x.png
+render_icon 512 icon_512x512.png
+render_icon 1024 icon_512x512@2x.png
+
+/usr/bin/iconutil -c icns "$iconset_path" -o "$output_path"
+echo "Created $output_path"
diff --git a/src/macos_installer/main.mm b/src/macos_installer/main.mm
index fc19f2bb..8c3b28f5 100644
--- a/src/macos_installer/main.mm
+++ b/src/macos_installer/main.mm
@@ -19,6 +19,7 @@ void show_alert(NSAlertStyle style, NSString* message, NSString* detail) {
[alert setInformativeText:detail ?: @""];
[alert addButtonWithTitle:@"OK"];
[alert runModal];
+ [alert release];
}
void show_error(NSString* detail) {
@@ -77,180 +78,295 @@ bool bundle_identifier_matches(NSURL* bundle_url) {
isEqualToString:kAcecodeBundleIdentifier];
}
-} // namespace
+NSURL* expected_source_url() {
+ NSURL* installer_url = [[NSBundle mainBundle] bundleURL];
+ NSURL* image_root = [installer_url URLByDeletingLastPathComponent];
+ return [image_root URLByAppendingPathComponent:@"ACECode.app"
+ isDirectory:YES];
+}
-int main() {
- @autoreleasepool {
- [NSApplication sharedApplication];
- [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
- [NSApp activateIgnoringOtherApps:YES];
-
- NSFileManager* file_manager = [NSFileManager defaultManager];
- NSURL* installer_url = [[NSBundle mainBundle] bundleURL];
- NSURL* image_root = [installer_url URLByDeletingLastPathComponent];
- NSURL* source_url = [image_root URLByAppendingPathComponent:@"ACECode.app"
- isDirectory:YES];
-
- BOOL source_is_directory = NO;
- if (![file_manager fileExistsAtPath:[source_url path]
- isDirectory:&source_is_directory] ||
- source_is_directory == NO) {
- show_error(@"ACECode.app is missing beside the installer. Reopen the original DMG.\n\n安装器旁缺少 ACECode.app,请重新打开原始 DMG。");
- return 1;
- }
+bool dropped_source_is_expected(NSURL* requested_url, NSURL* expected_url) {
+ if (!requested_url || !expected_url || ![requested_url isFileURL]) {
+ return false;
+ }
+ return filesystem_path(requested_url).lexically_normal() ==
+ filesystem_path(expected_url).lexically_normal();
+}
- NSError* error = nil;
- bool source_is_symlink = false;
- if (!resource_flag(source_url, NSURLIsSymbolicLinkKey,
- &source_is_symlink, &error) || source_is_symlink) {
- show_error(error_detail(
- @"The ACECode payload is not a safe application bundle.\n\nACECode 安装文件不是安全的应用包。", error));
- return 1;
- }
- if (!bundle_identifier_matches(source_url)) {
- show_error(@"The ACECode payload has an unexpected bundle identifier.\n\nACECode 安装文件的 Bundle ID 不正确。");
- return 1;
- }
+int install_application(NSURL* source_url) {
+ NSFileManager* file_manager = [NSFileManager defaultManager];
- NSArray* running =
- [NSRunningApplication runningApplicationsWithBundleIdentifier:
- kAcecodeBundleIdentifier];
- if ([running count] != 0) {
- show_error(@"Quit ACECode, then open this installer again.\n\n请先退出 ACECode,然后重新打开安装器。");
- return 1;
- }
+ BOOL source_is_directory = NO;
+ if (![file_manager fileExistsAtPath:[source_url path]
+ isDirectory:&source_is_directory] ||
+ source_is_directory == NO) {
+ show_error(@"ACECode.app is missing beside the installer. Reopen the original DMG.\n\n安装器旁缺少 ACECode.app,请重新打开原始 DMG。");
+ return 1;
+ }
- const char* home_bytes = [NSHomeDirectory() fileSystemRepresentation];
- if (!home_bytes) {
- show_error(@"The current user's home directory is unavailable.\n\n无法读取当前用户的主目录。");
- return 1;
- }
- const auto paths = acecode::desktop::macos_user_install_paths(
- fs::path(home_bytes));
- if (paths.home.empty()) {
- show_error(@"The current user's home directory is invalid.\n\n当前用户的主目录无效。");
- return 1;
- }
+ NSError* error = nil;
+ bool source_is_symlink = false;
+ if (!resource_flag(source_url, NSURLIsSymbolicLinkKey,
+ &source_is_symlink, &error) || source_is_symlink) {
+ show_error(error_detail(
+ @"The ACECode payload is not a safe application bundle.\n\nACECode 安装文件不是安全的应用包。", error));
+ return 1;
+ }
+ if (!bundle_identifier_matches(source_url)) {
+ show_error(@"The ACECode payload has an unexpected bundle identifier.\n\nACECode 安装文件的 Bundle ID 不正确。");
+ return 1;
+ }
- NSURL* home_url = file_url(paths.home, YES);
- NSURL* applications_url = file_url(paths.applications, YES);
- NSURL* destination_url = file_url(paths.destination, YES);
- if (!home_url || !applications_url || !destination_url) {
- show_error(@"The user installation path could not be represented safely.\n\n无法安全表示用户安装路径。");
- return 1;
- }
+ NSArray* running =
+ [NSRunningApplication runningApplicationsWithBundleIdentifier:
+ kAcecodeBundleIdentifier];
+ if ([running count] != 0) {
+ show_error(@"Quit ACECode, then open this installer again.\n\n请先退出 ACECode,然后重新打开安装器。");
+ return 1;
+ }
- BOOL applications_is_directory = NO;
- const BOOL applications_exists =
- [file_manager fileExistsAtPath:[applications_url path]
- isDirectory:&applications_is_directory];
- if (applications_exists) {
- bool applications_is_symlink = false;
- error = nil;
- if (!resource_flag(applications_url, NSURLIsSymbolicLinkKey,
- &applications_is_symlink, &error) ||
- applications_is_symlink || applications_is_directory == NO) {
- show_error(error_detail(
- @"~/Applications must be a real directory inside your home folder.\n\n~/Applications 必须是主目录中的真实文件夹,不能是符号链接。",
- error));
- return 1;
- }
- } else {
- error = nil;
- if (![file_manager createDirectoryAtURL:applications_url
- withIntermediateDirectories:YES
- attributes:nil
- error:&error]) {
- show_error(error_detail(
- @"The installer could not create ~/Applications.\n\n安装器无法创建 ~/Applications。", error));
- return 1;
- }
- }
+ const char* home_bytes = [NSHomeDirectory() fileSystemRepresentation];
+ if (!home_bytes) {
+ show_error(@"The current user's home directory is unavailable.\n\n无法读取当前用户的主目录。");
+ return 1;
+ }
+ const auto paths = acecode::desktop::macos_user_install_paths(
+ fs::path(home_bytes));
+ if (paths.home.empty()) {
+ show_error(@"The current user's home directory is invalid.\n\n当前用户的主目录无效。");
+ return 1;
+ }
- NSURL* resolved_home = [home_url URLByResolvingSymlinksInPath];
- NSURL* resolved_applications =
- [applications_url URLByResolvingSymlinksInPath];
- NSURL* resolved_destination =
- [resolved_applications URLByAppendingPathComponent:@"ACECode.app"
- isDirectory:YES];
- if (!acecode::desktop::macos_user_install_destination_is_safe(
- filesystem_path(resolved_home),
- filesystem_path(resolved_applications),
- filesystem_path(resolved_destination))) {
- show_error(@"The resolved installation path is outside ~/Applications.\n\n解析后的安装路径不在 ~/Applications 中,安装已停止。");
+ NSURL* home_url = file_url(paths.home, YES);
+ NSURL* applications_url = file_url(paths.applications, YES);
+ NSURL* destination_url = file_url(paths.destination, YES);
+ if (!home_url || !applications_url || !destination_url) {
+ show_error(@"The user installation path could not be represented safely.\n\n无法安全表示用户安装路径。");
+ return 1;
+ }
+
+ BOOL applications_is_directory = NO;
+ const BOOL applications_exists =
+ [file_manager fileExistsAtPath:[applications_url path]
+ isDirectory:&applications_is_directory];
+ if (applications_exists) {
+ bool applications_is_symlink = false;
+ error = nil;
+ if (!resource_flag(applications_url, NSURLIsSymbolicLinkKey,
+ &applications_is_symlink, &error) ||
+ applications_is_symlink || applications_is_directory == NO) {
+ show_error(error_detail(
+ @"~/Applications must be a real directory inside your home folder.\n\n~/Applications 必须是主目录中的真实文件夹,不能是符号链接。",
+ error));
return 1;
}
- destination_url = resolved_destination;
-
- BOOL destination_is_directory = NO;
- const BOOL destination_exists =
- [file_manager fileExistsAtPath:[destination_url path]
- isDirectory:&destination_is_directory];
- if (destination_exists) {
- bool destination_is_symlink = false;
- error = nil;
- if (!resource_flag(destination_url, NSURLIsSymbolicLinkKey,
- &destination_is_symlink, &error) ||
- destination_is_symlink || destination_is_directory == NO) {
- show_error(error_detail(
- @"The existing ~/Applications/ACECode.app is not a replaceable application bundle.\n\n现有的 ~/Applications/ACECode.app 不是可安全替换的应用包。",
- error));
- return 1;
- }
- }
-
- NSString* temporary_name = [NSString stringWithFormat:
- @".ACECode-%@.installing.app", [[NSUUID UUID] UUIDString]];
- NSURL* temporary_url =
- [resolved_applications URLByAppendingPathComponent:temporary_name
- isDirectory:YES];
+ } else {
error = nil;
- if (![file_manager copyItemAtURL:source_url
- toURL:temporary_url
- error:&error]) {
+ if (![file_manager createDirectoryAtURL:applications_url
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:&error]) {
show_error(error_detail(
- @"The installer could not copy ACECode into ~/Applications.\n\n安装器无法将 ACECode 复制到 ~/Applications。", error));
+ @"The installer could not create ~/Applications.\n\n安装器无法创建 ~/Applications。", error));
return 1;
}
+ }
- bool installed = false;
- if (destination_exists) {
- NSURL* resulting_url = nil;
- error = nil;
- installed = [file_manager replaceItemAtURL:destination_url
- withItemAtURL:temporary_url
- backupItemName:nil
- options:NSFileManagerItemReplacementUsingNewMetadataOnly
- resultingItemURL:&resulting_url
- error:&error] == YES;
- } else {
- error = nil;
- installed = [file_manager moveItemAtURL:temporary_url
- toURL:destination_url
- error:&error] == YES;
- }
+ NSURL* resolved_home = [home_url URLByResolvingSymlinksInPath];
+ NSURL* resolved_applications =
+ [applications_url URLByResolvingSymlinksInPath];
+ NSURL* resolved_destination =
+ [resolved_applications URLByAppendingPathComponent:@"ACECode.app"
+ isDirectory:YES];
+ if (!acecode::desktop::macos_user_install_destination_is_safe(
+ filesystem_path(resolved_home),
+ filesystem_path(resolved_applications),
+ filesystem_path(resolved_destination))) {
+ show_error(@"The resolved installation path is outside ~/Applications.\n\n解析后的安装路径不在 ~/Applications 中,安装已停止。");
+ return 1;
+ }
+ destination_url = resolved_destination;
- if (!installed) {
- NSError* cleanup_error = nil;
- [file_manager removeItemAtURL:temporary_url error:&cleanup_error];
+ BOOL destination_is_directory = NO;
+ const BOOL destination_exists =
+ [file_manager fileExistsAtPath:[destination_url path]
+ isDirectory:&destination_is_directory];
+ if (destination_exists) {
+ bool destination_is_symlink = false;
+ error = nil;
+ if (!resource_flag(destination_url, NSURLIsSymbolicLinkKey,
+ &destination_is_symlink, &error) ||
+ destination_is_symlink || destination_is_directory == NO) {
show_error(error_detail(
- @"The installer could not finish replacing ACECode.\n\n安装器无法完成 ACECode 的替换。", error));
+ @"The existing ~/Applications/ACECode.app is not a replaceable application bundle.\n\n现有的 ~/Applications/ACECode.app 不是可安全替换的应用包。",
+ error));
return 1;
}
+ }
- if (!bundle_identifier_matches(destination_url)) {
- show_error(@"The installed application did not pass the final bundle check.\n\n安装后的应用未通过最终 Bundle 检查。");
- return 1;
- }
+ NSString* temporary_name = [NSString stringWithFormat:
+ @".ACECode-%@.installing.app", [[NSUUID UUID] UUIDString]];
+ NSURL* temporary_url =
+ [resolved_applications URLByAppendingPathComponent:temporary_name
+ isDirectory:YES];
+ error = nil;
+ if (![file_manager copyItemAtURL:source_url
+ toURL:temporary_url
+ error:&error]) {
+ show_error(error_detail(
+ @"The installer could not copy ACECode into ~/Applications.\n\n安装器无法将 ACECode 复制到 ~/Applications。", error));
+ return 1;
+ }
+
+ bool installed = false;
+ if (destination_exists) {
+ NSURL* resulting_url = nil;
+ error = nil;
+ installed = [file_manager replaceItemAtURL:destination_url
+ withItemAtURL:temporary_url
+ backupItemName:nil
+ options:NSFileManagerItemReplacementUsingNewMetadataOnly
+ resultingItemURL:&resulting_url
+ error:&error] == YES;
+ } else {
+ error = nil;
+ installed = [file_manager moveItemAtURL:temporary_url
+ toURL:destination_url
+ error:&error] == YES;
+ }
- show_alert(NSAlertStyleInformational,
- @"ACECode installed / ACECode 已安装",
- @"Installed for the current user at ~/Applications/ACECode.app. No administrator access was requested.\n\n已为当前用户安装到 ~/Applications/ACECode.app,全程未请求管理员权限。ACECode 即将启动。");
+ if (!installed) {
+ NSError* cleanup_error = nil;
+ [file_manager removeItemAtURL:temporary_url error:&cleanup_error];
+ show_error(error_detail(
+ @"The installer could not finish replacing ACECode.\n\n安装器无法完成 ACECode 的替换。", error));
+ return 1;
+ }
- if (![[NSWorkspace sharedWorkspace] openURL:destination_url]) {
- show_error(@"ACECode was installed, but macOS could not open it automatically. Open ~/Applications/ACECode.app manually.\n\nACECode 已安装,但 macOS 无法自动启动。请手动打开 ~/Applications/ACECode.app。");
- return 1;
- }
- return 0;
+ if (!bundle_identifier_matches(destination_url)) {
+ show_error(@"The installed application did not pass the final bundle check.\n\n安装后的应用未通过最终 Bundle 检查。");
+ return 1;
+ }
+
+ show_alert(NSAlertStyleInformational,
+ @"ACECode installed / ACECode 已安装",
+ @"Installed for the current user at ~/Applications/ACECode.app. No administrator access was requested.\n\n已为当前用户安装到 ~/Applications/ACECode.app,全程未请求管理员权限。ACECode 即将启动。");
+
+ if (![[NSWorkspace sharedWorkspace] openURL:destination_url]) {
+ show_error(@"ACECode was installed, but macOS could not open it automatically. Open ~/Applications/ACECode.app manually.\n\nACECode 已安装,但 macOS 无法自动启动。请手动打开 ~/Applications/ACECode.app。");
+ return 1;
+ }
+ return 0;
+}
+
+} // namespace
+
+@interface ACECodeInstallerDelegate : NSObject {
+@private
+ NSURL* requested_source_url_;
+ BOOL installation_started_;
+ int exit_code_;
+}
+
+- (int)exitCode;
+- (void)acceptOpenURLs:(NSArray*)urls;
+- (void)beginInstallation;
+
+@end
+
+@implementation ACECodeInstallerDelegate
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ requested_source_url_ = nil;
+ installation_started_ = NO;
+ exit_code_ = 1;
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [requested_source_url_ release];
+ [super dealloc];
+}
+
+- (int)exitCode {
+ return exit_code_;
+}
+
+- (void)applicationDidFinishLaunching:(NSNotification*)notification {
+ (void)notification;
+ [NSApp activateIgnoringOtherApps:YES];
+
+ // Finder normally delivers open-file events during launch. A short delay
+ // lets that event select the dragged payload before direct-open fallback.
+ [self performSelector:@selector(beginInstallation)
+ withObject:nil
+ afterDelay:0.25];
+}
+
+- (void)application:(NSApplication*)application
+ openURLs:(NSArray*)urls {
+ (void)application;
+ [self acceptOpenURLs:urls];
+}
+
+- (void)application:(NSApplication*)application
+ openFiles:(NSArray*)filenames {
+ NSMutableArray* urls =
+ [NSMutableArray arrayWithCapacity:[filenames count]];
+ for (NSString* filename in filenames) {
+ [urls addObject:[NSURL fileURLWithPath:filename isDirectory:YES]];
+ }
+ [self acceptOpenURLs:urls];
+ [application replyToOpenOrPrint:NSApplicationDelegateReplySuccess];
+}
+
+- (void)acceptOpenURLs:(NSArray*)urls {
+ if (installation_started_ || requested_source_url_ || [urls count] == 0) {
+ return;
+ }
+
+ requested_source_url_ = [[urls objectAtIndex:0] retain];
+ [NSObject cancelPreviousPerformRequestsWithTarget:self
+ selector:@selector(beginInstallation)
+ object:nil];
+ [self performSelector:@selector(beginInstallation)
+ withObject:nil
+ afterDelay:0.05];
+}
+
+- (void)beginInstallation {
+ if (installation_started_) return;
+ installation_started_ = YES;
+
+ NSURL* source_url = expected_source_url();
+ if (requested_source_url_ &&
+ !dropped_source_is_expected(requested_source_url_, source_url)) {
+ show_error(@"Drag the ACECode.app from beside this installer. Other applications are not accepted.\n\n请拖入安装器旁边的 ACECode.app,不能安装其他应用。");
+ exit_code_ = 1;
+ } else {
+ exit_code_ = install_application(source_url);
+ }
+
+ [NSApp stop:self];
+}
+
+@end
+
+int main() {
+ int result = 1;
+ @autoreleasepool {
+ NSApplication* application = [NSApplication sharedApplication];
+ ACECodeInstallerDelegate* delegate =
+ [[ACECodeInstallerDelegate alloc] init];
+ [application setDelegate:delegate];
+ [application setActivationPolicy:NSApplicationActivationPolicyRegular];
+ [application run];
+ result = [delegate exitCode];
+ [application setDelegate:nil];
+ [delegate release];
}
+ return result;
}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 72b76ee6..67db82d2 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -7,6 +7,13 @@ if(UNIX)
${CMAKE_CURRENT_SOURCE_DIR}/scripts/macos_release_scripts_test.sh
)
set_tests_properties(macos_release_scripts_contract PROPERTIES LABELS "unit")
+
+ add_test(
+ NAME macos_dmg_layout_contract
+ COMMAND /bin/bash
+ ${CMAKE_CURRENT_SOURCE_DIR}/scripts/macos_dmg_layout_test.sh
+ )
+ set_tests_properties(macos_dmg_layout_contract PROPERTIES LABELS "unit")
endif()
file(GLOB_RECURSE ACECODE_TEST_SOURCES CONFIGURE_DEPENDS
diff --git a/tests/scripts/macos_dmg_layout_test.sh b/tests/scripts/macos_dmg_layout_test.sh
new file mode 100755
index 00000000..70077db7
--- /dev/null
+++ b/tests/scripts/macos_dmg_layout_test.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+dmg_script="$repo_root/scripts/macos_create_dmg.sh"
+icon_script="$repo_root/scripts/macos_generate_icns.sh"
+desktop_cmake="$repo_root/cmake/acecode_desktop.cmake"
+installer_plist="$repo_root/cmake/macos/ACECodeUserInstallerInfo.plist.in"
+installer_source="$repo_root/src/macos_installer/main.mm"
+installer_icon="$repo_root/assets/macos/acecode-installer.svg"
+dmg_background="$repo_root/assets/macos/acecode-dmg-background.svg"
+
+temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/acecode-dmg-layout-test.XXXXXX")"
+cleanup() {
+ if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then
+ rm -rf -- "$temporary_root"
+ fi
+}
+trap cleanup EXIT
+
+expect_status() {
+ local expected="$1"
+ local label="$2"
+ shift 2
+
+ local output="$temporary_root/output.txt"
+ local actual=0
+ "$@" >"$output" 2>&1 || actual=$?
+ if [[ "$actual" -ne "$expected" ]]; then
+ echo "$label: expected exit $expected, got $actual" >&2
+ sed -n '1,120p' "$output" >&2
+ exit 1
+ fi
+}
+
+bash -n "$dmg_script"
+bash -n "$icon_script"
+expect_status 0 "DMG help" bash "$dmg_script" --help
+expect_status 0 "ICNS help" bash "$icon_script" --help
+expect_status 2 "unknown DMG argument" bash "$dmg_script" --unknown
+expect_status 2 "unknown ICNS argument" bash "$icon_script" --unknown
+
+test -f "$installer_icon"
+test -f "$dmg_background"
+grep -Fq 'acecode-installer.icns' "$desktop_cmake"
+grep -Fq 'macos_generate_icns.sh' "$desktop_cmake"
+grep -Fq 'MACOSX_PACKAGE_LOCATION "Resources"' "$desktop_cmake"
+grep -Fq 'CFBundleDocumentTypes' "$installer_plist"
+grep -Fq 'com.apple.application-bundle' "$installer_plist"
+grep -Fq 'Alternate' "$installer_plist"
+grep -Fq 'application:(NSApplication*)application' "$installer_source"
+grep -Fq 'openURLs:(NSArray*)urls' "$installer_source"
+grep -Fq 'dropped_source_is_expected' "$installer_source"
+grep -Fq 'expected_source_url' "$installer_source"
+
+grep -Fq -- '-format UDRW' "$dmg_script"
+grep -Fq '/usr/bin/osascript' "$dmg_script"
+grep -Fq 'set background picture of iconOptions' "$dmg_script"
+grep -Fq 'set position of item "ACECode.app"' "$dmg_script"
+grep -Fq 'set position of item "Install ACECode.app"' "$dmg_script"
+grep -Fq '/usr/bin/hdiutil convert' "$dmg_script"
+grep -Fq -- '-format UDZO' "$dmg_script"
+grep -Fq '.VolumeIcon.icns' "$dmg_script"
+grep -Fq 'Refusing to package a system /Applications link.' "$dmg_script"
+
+if grep -Eq 'ln[[:space:]].*/Applications|ln[[:space:]]+-s[[:space:]]+/Applications' "$dmg_script"; then
+ echo "DMG helper must not create a system /Applications link" >&2
+ exit 1
+fi
+if grep -Eq 'Authorization(Create|CopyRights)|[[:space:]]sudo[[:space:]]' "$installer_source"; then
+ echo "Installer must not request administrator privileges" >&2
+ exit 1
+fi
+if grep -Fq '@"/Applications"' "$installer_source"; then
+ echo "Installer must not target system /Applications" >&2
+ exit 1
+fi
+
+if [[ "$(uname -s)" == "Darwin" ]]; then
+ generated_icon="$temporary_root/acecode-installer.icns"
+ expect_status 0 "generate installer icon" \
+ bash "$icon_script" --source "$installer_icon" --output "$generated_icon"
+ test -s "$generated_icon"
+
+ /usr/bin/iconutil -c iconset -o "$temporary_root/icon.iconset" \
+ "$generated_icon"
+ test "$(find "$temporary_root/icon.iconset" -type f -name '*.png' | wc -l | tr -d ' ')" -eq 10
+
+ background_png="$temporary_root/ACECode-DMG.png"
+ /usr/bin/sips -s format png "$dmg_background" \
+ --out "$background_png" >/dev/null
+ test -s "$background_png"
+ /usr/bin/sips -g pixelWidth -g pixelHeight "$background_png" \
+ >"$temporary_root/background-size.txt"
+ grep -Fq 'pixelWidth: 720' "$temporary_root/background-size.txt"
+ grep -Fq 'pixelHeight: 520' "$temporary_root/background-size.txt"
+fi
+
+echo "macOS DMG layout contract checks passed"