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 @@ + + + ACECode DMG installation background + A branded left-to-right drag guide for installing ACECode for the current user. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Install ACECode · 安装 ACECode + + + Installs to ~/Applications · 当前用户安装 · No administrator password + + + + + + + + + ACECode.app + + + Drag here · 拖到这里 + + + + + + + + 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 @@ + + + ACECode current-user installer icon + The ACECode app icon with a prominent download-to-tray badge. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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"