diff --git a/docs/pages/editor.md b/docs/pages/editor.md index f0269a70..5b00545d 100644 --- a/docs/pages/editor.md +++ b/docs/pages/editor.md @@ -65,3 +65,20 @@ Shift-click selects multiple hierarchy objects. Dropping OBJ, FBX, glTF, GLB, or | Command palette | Command Shift P | The command palette lists available commands and their shortcuts and supports keyboard filtering, arrow navigation, and Enter. Scripts are watched for changes and the editor reloads the runtime automatically when JavaScript or TypeScript files change. + +## Packaging Atlas Engine + +Run the macOS packer from the repository root: + +```shell +./scripts/package_app.py --debug --macOS +./scripts/package_app.py --release --macOS +``` + +The equivalent `just` recipes are `just package-debug-macos` and `just package-release-macos`. Products are written below `dist/macOS/` and intermediate files are written below `build/package`; both directories are ignored by version control. + +The package is self-contained and includes Qt, the Atlas CLI, and `runtime.dylib`. On first launch, Atlas Engine offers to install the bundled CLI and runtime into `~/Library/Application Support/Atlas Engine/toolchains/alpha9` and registers them in `~/.atlas/config.json`. Installation does not require administrator access and preserves other configured Atlas versions. + +Debug packages use an ad-hoc signature. A release intended for GitHub requires `ATLAS_SIGNING_IDENTITY` to name a Developer ID Application certificate and `ATLAS_NOTARY_PROFILE` to name a `notarytool` keychain profile. The packer creates, signs, notarizes, staples, mounts, and validates a drag-to-Applications DMG. It refuses to create an accidentally unnotarized release unless `ATLAS_ALLOW_UNNOTARIZED_RELEASE=1` is explicitly set; that local-only artifact is named `UNNOTARIZED`. + +The packer builds the host architecture by default. Set `ATLAS_MACOS_ARCHITECTURES='arm64;x86_64'` when the selected Qt installation contains both architectures to create a universal archive. `ATLAS_MACOS_DEPLOYMENT_TARGET` changes the default macOS 14.0 deployment target, and `ATLAS_MACDEPLOYQT` can select a specific `macdeployqt` executable. diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 47608e5e..64dbf688 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -24,13 +24,20 @@ file(GLOB_RECURSE ATLAS_CLI_SOURCES CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/cli/src/*.rs" ) -set(ATLAS_EDITOR_CLI "${CMAKE_SOURCE_DIR}/target/debug/atlas") +if (CMAKE_BUILD_TYPE STREQUAL "Release") + set(ATLAS_EDITOR_CARGO_ARGS --release) + set(ATLAS_EDITOR_CLI "${CMAKE_SOURCE_DIR}/target/release/atlas") +else () + set(ATLAS_EDITOR_CARGO_ARGS) + set(ATLAS_EDITOR_CLI "${CMAKE_SOURCE_DIR}/target/debug/atlas") +endif () add_custom_command( OUTPUT "${ATLAS_EDITOR_CLI}" COMMAND "${CMAKE_COMMAND}" -E env CARGO_TERM_COLOR=always "${ATLAS_CARGO_EXECUTABLE}" build + ${ATLAS_EDITOR_CARGO_ARGS} --manifest-path "${CMAKE_SOURCE_DIR}/cli/Cargo.toml" DEPENDS "${CMAKE_SOURCE_DIR}/Cargo.toml" @@ -75,15 +82,54 @@ add_subdirectory( "${CMAKE_BINARY_DIR}/extern/QtDockingSystem" ) +if (APPLE) + set(ATLAS_APP_ICON + "${CMAKE_SOURCE_DIR}/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png" + CACHE FILEPATH "Atlas Engine macOS bundle icon" + ) + get_filename_component(ATLAS_APP_ICON_NAME "${ATLAS_APP_ICON}" NAME) + set_source_files_properties("${ATLAS_APP_ICON}" PROPERTIES + MACOSX_PACKAGE_LOCATION "Resources" + ) + list(APPEND EDITOR_FILES "${ATLAS_APP_ICON}") +endif () + qt_add_executable(AtlasEditor ${EDITOR_FILES}) add_dependencies(AtlasEditor generate_atlas_themes atlas_editor_cli) -add_custom_command(TARGET AtlasEditor POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${ATLAS_EDITOR_CLI}" - "$/atlas" - VERBATIM -) +if (APPLE) + set_target_properties(AtlasEditor PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Atlas Engine" + MACOSX_BUNDLE_GUI_IDENTIFIER "neutralsoftware.atlas" + MACOSX_BUNDLE_ICON_FILE "${ATLAS_APP_ICON_NAME}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/editor/macos/Info.plist.in" + MACOSX_BUNDLE_SHORT_VERSION_STRING "0.9.0" + MACOSX_BUNDLE_BUNDLE_VERSION "10" + OUTPUT_NAME "Atlas Engine" + BUILD_RPATH "@executable_path/../Frameworks" + INSTALL_RPATH "@executable_path/../Frameworks" + ) + add_custom_command(TARGET AtlasEditor POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory + "$/Contents/Helpers" + "$/Contents/Frameworks" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${ATLAS_EDITOR_CLI}" + "$/Contents/Helpers/atlas" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/Contents/Frameworks/runtime.dylib" + VERBATIM + ) +else () + add_custom_command(TARGET AtlasEditor POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${ATLAS_EDITOR_CLI}" + "$/atlas" + VERBATIM + ) +endif () qt_add_resources(AtlasEditor "atlas_editor_assets" PREFIX "/editor" @@ -105,6 +151,7 @@ target_include_directories(AtlasEditor string(TIMESTAMP ATLAS_EDITOR_BUILD_DATE "%Y%m%d") target_compile_definitions(AtlasEditor PRIVATE + ATLAS_TOOLCHAIN_VERSION="alpha9" "$<$:ATLAS_DEBUG_BUILD>" "$<$:ATLAS_BUILD_STRING=\"${ATLAS_EDITOR_BUILD_DATE}\">" ) diff --git a/editor/application/toolchainInstaller.cpp b/editor/application/toolchainInstaller.cpp new file mode 100644 index 00000000..78b84ea1 --- /dev/null +++ b/editor/application/toolchainInstaller.cpp @@ -0,0 +1,235 @@ +#include "editor/application/toolchainInstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct ToolchainPaths { + QString bundledCli; + QString bundledRuntime; + QString installedCli; + QString installedRuntime; + QString config; +}; + +QByteArray digest(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&file)) + return {}; + return hash.result(); +} + +bool filesMatch(const QString& left, const QString& right) { + const QFileInfo leftInfo(left); + const QFileInfo rightInfo(right); + return leftInfo.isFile() && rightInfo.isFile() && + leftInfo.size() == rightInfo.size() && digest(left) == digest(right); +} + +ToolchainPaths paths() { + const QDir contents(QCoreApplication::applicationDirPath() + "/.."); + const QString installRoot = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + "/Atlas Engine/toolchains/" + ATLAS_TOOLCHAIN_VERSION; + const QString home = QDir::homePath(); + return { + contents.filePath("Helpers/atlas"), + contents.filePath("Frameworks/runtime.dylib"), + installRoot + "/bin/atlas", + installRoot + "/lib/runtime.dylib", + home + "/.atlas/config.json", + }; +} + +QJsonObject readConfig(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + return document.isObject() ? document.object() : QJsonObject(); +} + +bool configMatches(const ToolchainPaths& toolchain) { + const QJsonObject root = readConfig(toolchain.config); + const QJsonObject entry = root.value(ATLAS_TOOLCHAIN_VERSION).toObject(); + const QJsonObject onboarding = entry.value("onboardingData").toObject(); + return onboarding.value("atlasExecutablePath").toString() == + toolchain.installedCli && + onboarding.value("runtimeLib").toString() == + toolchain.installedRuntime; +} + +bool copyFile(const QString& source, const QString& destination, + QString* error) { + QFile input(source); + if (!input.open(QIODevice::ReadOnly)) { + *error = input.errorString(); + return false; + } + QSaveFile output(destination); + if (!output.open(QIODevice::WriteOnly)) { + *error = output.errorString(); + return false; + } + while (!input.atEnd()) { + const QByteArray data = input.read(1024 * 1024); + if (data.isEmpty() && input.error() != QFileDevice::NoError) { + *error = input.errorString(); + return false; + } + if (output.write(data) != data.size()) { + *error = output.errorString(); + return false; + } + } + if (!output.commit()) { + *error = output.errorString(); + return false; + } + if (!QFile::setPermissions(destination, QFile::permissions(source))) { + *error = QStringLiteral("Could not apply permissions to %1") + .arg(destination); + return false; + } + return true; +} + +bool writeConfig(const ToolchainPaths& toolchain, QString* error) { + QJsonObject root = readConfig(toolchain.config); + QJsonObject entry = root.value(ATLAS_TOOLCHAIN_VERSION).toObject(); + QJsonObject onboarding = entry.value("onboardingData").toObject(); + onboarding.insert("atlasExecutablePath", toolchain.installedCli); + onboarding.insert("runtimeLib", toolchain.installedRuntime); + entry.insert("onboardingData", onboarding); + root.insert(ATLAS_TOOLCHAIN_VERSION, entry); + + const QFileInfo configInfo(toolchain.config); + if (!QDir().mkpath(configInfo.absolutePath())) { + *error = QStringLiteral("Could not create %1") + .arg(configInfo.absolutePath()); + return false; + } + QSaveFile output(toolchain.config); + if (!output.open(QIODevice::WriteOnly)) { + *error = output.errorString(); + return false; + } + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Indented); + if (output.write(data) != data.size() || !output.commit()) { + *error = output.errorString(); + return false; + } + return true; +} + +bool isInstalled(const ToolchainPaths& toolchain) { + return filesMatch(toolchain.bundledCli, toolchain.installedCli) && + filesMatch(toolchain.bundledRuntime, toolchain.installedRuntime) && + configMatches(toolchain); +} + +bool promptDismissed() { + QSettings settings("Neutral Software", "Atlas Engine"); + return settings.value("toolchain/installationPromptDismissed", false) + .toBool(); +} + +void setPromptDismissed(bool dismissed) { + QSettings settings("Neutral Software", "Atlas Engine"); + settings.setValue("toolchain/installationPromptDismissed", dismissed); + settings.sync(); +} + +bool installToolchain(const ToolchainPaths& toolchain, QWidget* parent, + bool reportExistingInstallation) { + if (isInstalled(toolchain)) { + setPromptDismissed(false); + if (reportExistingInstallation) { + QMessageBox::information( + parent, "Atlas Toolchain Ready", + "The bundled Atlas toolchain is already installed for this user."); + } + return true; + } + + const QFileInfo cliInfo(toolchain.installedCli); + const QFileInfo runtimeInfo(toolchain.installedRuntime); + QString error; + if (!QDir().mkpath(cliInfo.absolutePath()) || + !QDir().mkpath(runtimeInfo.absolutePath())) { + error = "Could not create the Atlas toolchain directory."; + } else if (!copyFile(toolchain.bundledCli, toolchain.installedCli, + &error) || + !copyFile(toolchain.bundledRuntime, + toolchain.installedRuntime, &error) || + !writeConfig(toolchain, &error)) { + } + if (!error.isEmpty()) { + QMessageBox::critical(parent, "Toolchain Installation Failed", error); + return false; + } + + setPromptDismissed(false); + QMessageBox::information( + parent, "Atlas Toolchain Installed", + "The Atlas toolchain is ready. Projects can now be created, run, and exported from Atlas Engine."); + return true; +} +} + +bool ToolchainInstaller::ensureInstalled(QWidget* parent) { + const ToolchainPaths toolchain = paths(); + if (!QFileInfo::exists(toolchain.bundledCli) || + !QFileInfo::exists(toolchain.bundledRuntime)) + return true; + + if (isInstalled(toolchain)) { + setPromptDismissed(false); + return true; + } + if (promptDismissed()) + return false; + + QMessageBox prompt(parent); + prompt.setWindowTitle("Welcome to Atlas Engine"); + prompt.setIcon(QMessageBox::Information); + prompt.setText("Install the Atlas toolchain for this user?"); + prompt.setInformativeText( + "Atlas Engine includes the command-line tools and runtime required to create, run, and export projects. They will be installed in your user Library and do not require administrator access."); + auto* installButton = prompt.addButton("Install Toolchain", + QMessageBox::AcceptRole); + prompt.addButton("Not Now", QMessageBox::RejectRole); + prompt.setDefaultButton(installButton); + prompt.exec(); + if (prompt.clickedButton() != installButton) { + setPromptDismissed(true); + return false; + } + + return installToolchain(toolchain, parent, false); +} + +bool ToolchainInstaller::install(QWidget* parent) { + const ToolchainPaths toolchain = paths(); + if (!QFileInfo::exists(toolchain.bundledCli) || + !QFileInfo::exists(toolchain.bundledRuntime)) { + QMessageBox::warning( + parent, "Atlas Toolchain Unavailable", + "This copy of Atlas Engine does not include the packaged toolchain."); + return false; + } + return installToolchain(toolchain, parent, true); +} diff --git a/editor/assets/AtlasEngine.icon.json b/editor/assets/AtlasEngine.icon.json new file mode 100644 index 00000000..6dbc047d --- /dev/null +++ b/editor/assets/AtlasEngine.icon.json @@ -0,0 +1,43 @@ +{ + "fill": { + "linear-gradient": [ + "srgb:1.00000,0.95276,0.98370,1.00000", + "srgb:0.69138,0.67074,0.76447,1.00000" + ], + "orientation": { + "start": { + "x": 0.5, + "y": 0 + }, + "stop": { + "x": 0.5, + "y": 0.7 + } + } + }, + "groups": [ + { + "layers": [ + { + "glass": true, + "image-name": "atlas_ball_bright.png", + "name": "atlas_ball_bright" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/editor/assets/AtlasEngineDev.icon.json b/editor/assets/AtlasEngineDev.icon.json new file mode 100644 index 00000000..a2c2a8c8 --- /dev/null +++ b/editor/assets/AtlasEngineDev.icon.json @@ -0,0 +1,29 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "atlas_ball_bright.png", + "name": "atlas_ball_bright" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/editor/macos/Info.plist.in b/editor/macos/Info.plist.in new file mode 100644 index 00000000..fff7b0b4 --- /dev/null +++ b/editor/macos/Info.plist.in @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/editor/main.cpp b/editor/main.cpp index ed83a49b..23f52a26 100644 --- a/editor/main.cpp +++ b/editor/main.cpp @@ -23,6 +23,7 @@ #include "DockWidget.h" #include "../include/editor/application/styling.h" #include "editor/debug.h" +#include "editor/application/toolchainInstaller.h" #include "editor/styling/icons.h" #include "editor/views/editorWindow.h" #include "editor/views/projectBrowser.h" @@ -51,6 +52,7 @@ int main(int argc, char **argv) { app.setFont(applicationFont); styling::loadIconFont(); +#ifndef Q_OS_MACOS #ifdef ATLAS_DEBUG_BUILD app.setWindowIcon( QIcon(":/editor/assets/Icon-iOS-Default-1024x1024@1x.png")); @@ -58,6 +60,7 @@ int main(int argc, char **argv) { app.setWindowIcon( QIcon(":/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png")); #endif +#endif #if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) app.styleHints()->setColorScheme(Qt::ColorScheme::Dark); @@ -65,6 +68,7 @@ int main(int argc, char **argv) { app.setStyle("Fusion"); styling::applyTheme(app); + ToolchainInstaller::ensureInstalled(); auto *startupSplash = new SplashScreen(); startupSplash->start("Preparing the project browser..."); diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 62b98f67..871577db 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -9,6 +9,7 @@ #include +#include #include #include @@ -549,6 +550,8 @@ void EditorWindow::setupMenus() { addCommand(toolsMenu, "Project Settings…", QString(), [this] { showProjectSettings(); }); toolsSettings->setMenuRole(QAction::NoRole); + addCommand(toolsMenu, "Install Atlas Toolchain…", QString(), + [this] { ToolchainInstaller::install(this); }); addCommand(toolsMenu, "Command Palette…", "Meta+Shift+P", [this] { showCommandPalette(); }); diff --git a/include/editor/application/toolchainInstaller.h b/include/editor/application/toolchainInstaller.h new file mode 100644 index 00000000..6e81b899 --- /dev/null +++ b/include/editor/application/toolchainInstaller.h @@ -0,0 +1,11 @@ +#ifndef ATLAS_TOOLCHAININSTALLER_H +#define ATLAS_TOOLCHAININSTALLER_H + +class QWidget; + +namespace ToolchainInstaller { +bool ensureInstalled(QWidget* parent = nullptr); +bool install(QWidget* parent = nullptr); +} + +#endif diff --git a/justfile b/justfile index e35f5786..3f1b1502 100644 --- a/justfile +++ b/justfile @@ -62,6 +62,12 @@ frametest: cli: cargo build +package-debug-macos: + ./scripts/package_app.py --debug --macOS + +package-release-macos: + ./scripts/package_app.py --release --macOS + release-metal: rm -rf build/release-metal mkdir -p build/release-metal dist/release diff --git a/scripts/package_app.py b/scripts/package_app.py new file mode 100755 index 00000000..734bada9 --- /dev/null +++ b/scripts/package_app.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 + +import argparse +import os +import platform +import plistlib +import shutil +import subprocess +import sys +from pathlib import Path + + +def run(command, cwd=None, capture=False): + print("+", " ".join(str(part) for part in command), flush=True) + return subprocess.run( + [str(part) for part in command], + cwd=cwd, + check=True, + text=True, + capture_output=capture, + ) + + +def require(name, override=None): + candidate = override or shutil.which(name) + if not candidate: + raise RuntimeError(f"Required tool was not found: {name}") + return Path(candidate) + + +def compile_icon(config, artwork, output_directory, work_directory, + deployment_target): + source = work_directory / "AtlasEngine.icon" + if source.exists(): + shutil.rmtree(source) + (source / "Assets").mkdir(parents=True) + shutil.copy2(config, source / "icon.json") + shutil.copy2(artwork, source / "Assets" / "atlas_ball_bright.png") + if output_directory.exists(): + shutil.rmtree(output_directory) + output_directory.mkdir(parents=True) + partial_plist = output_directory / "icon-info.plist" + run([ + "/usr/bin/xcrun", + "actool", + "--compile", + output_directory, + "--platform", + "macosx", + "--minimum-deployment-target", + deployment_target, + "--app-icon", + "AtlasEngine", + "--output-partial-info-plist", + partial_plist, + source, + ]) + icon = output_directory / "AtlasEngine.icns" + assets = output_directory / "Assets.car" + if not icon.is_file() or not assets.is_file(): + raise RuntimeError("Xcode did not compile the complete Atlas app icon") + return icon, assets + + +def locate_macdeployqt(): + configured = os.environ.get("ATLAS_MACDEPLOYQT") + if configured: + return require("macdeployqt", configured) + found = shutil.which("macdeployqt") + if found: + return Path(found) + qtpaths = shutil.which("qtpaths6") or shutil.which("qtpaths") + if qtpaths: + result = run([qtpaths, "--query", "QT_INSTALL_BINS"], capture=True) + candidate = Path(result.stdout.strip()) / "macdeployqt" + if candidate.is_file(): + return candidate + raise RuntimeError("macdeployqt was not found. Install Qt 6 or set ATLAS_MACDEPLOYQT.") + + +def macho_dependencies(bundle): + file_tool = require("file", "/usr/bin/file") + otool = require("otool", "/usr/bin/otool") + invalid = [] + for path in bundle.rglob("*"): + if not path.is_file() or path.is_symlink(): + continue + kind = subprocess.run( + [file_tool, "-b", path], + check=True, + text=True, + capture_output=True, + ).stdout + if "Mach-O" not in kind: + continue + install_ids = set( + subprocess.run( + [otool, "-D", path], + check=False, + text=True, + capture_output=True, + ).stdout.splitlines()[1:] + ) + output = subprocess.run( + [otool, "-L", path], + check=True, + text=True, + capture_output=True, + ).stdout.splitlines()[1:] + for line in output: + dependency = line.strip().split(" (", 1)[0] + if dependency in install_ids: + continue + if dependency.startswith(("@", "/System/Library/", "/usr/lib/")): + continue + invalid.append((path, dependency)) + return invalid + + +def sign_bundle(bundle, identity): + codesign = require("codesign", "/usr/bin/codesign") + command = [codesign, "--force", "--deep"] + if identity != "-": + command.extend(["--options", "runtime", "--timestamp"]) + command.extend(["--sign", identity, bundle]) + run(command) + run([codesign, "--verify", "--deep", "--strict", "--verbose=2", bundle]) + + +def archive_bundle(bundle, archive): + if archive.exists(): + archive.unlink() + run([ + "/usr/bin/ditto", + "-c", + "-k", + "--sequesterRsrc", + "--keepParent", + bundle, + archive, + ]) + + +def create_dmg(bundle, dmg, staging_directory): + if staging_directory.exists(): + shutil.rmtree(staging_directory) + staging_directory.mkdir(parents=True) + run(["/usr/bin/ditto", bundle, staging_directory / bundle.name]) + os.symlink("/Applications", staging_directory / "Applications") + if dmg.exists(): + dmg.unlink() + run([ + "/usr/bin/hdiutil", + "create", + "-volname", + "Atlas Engine", + "-srcfolder", + staging_directory, + "-format", + "UDZO", + "-ov", + dmg, + ]) + + +def sign_dmg(dmg, identity): + if identity == "-": + return + run([ + "/usr/bin/codesign", + "--force", + "--timestamp", + "--sign", + identity, + dmg, + ]) + run(["/usr/bin/codesign", "--verify", "--verbose=2", dmg]) + + +def validate_release_identity(identity): + result = subprocess.run( + ["/usr/bin/security", "find-identity", "-p", "codesigning", "-v"], + check=True, + text=True, + capture_output=True, + ) + matches = [line for line in result.stdout.splitlines() if identity in line] + if not any('"Developer ID Application:' in line for line in matches): + raise RuntimeError( + "ATLAS_SIGNING_IDENTITY must select an installed Developer ID " + "Application certificate for a publishable release" + ) + + +def notarize(artifact, profile): + run([ + "/usr/bin/xcrun", + "notarytool", + "submit", + artifact, + "--keychain-profile", + profile, + "--wait", + ]) + run(["/usr/bin/xcrun", "stapler", "staple", artifact]) + run(["/usr/bin/xcrun", "stapler", "validate", artifact]) + + +def validate_dmg(dmg, mountpoint): + if mountpoint.exists(): + shutil.rmtree(mountpoint) + mountpoint.mkdir(parents=True) + run([ + "/usr/bin/hdiutil", + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mountpoint, + dmg, + ]) + try: + mounted_app = mountpoint / "Atlas Engine.app" + if not mounted_app.is_dir(): + raise RuntimeError("DMG does not contain Atlas Engine.app") + if not (mountpoint / "Applications").is_symlink(): + raise RuntimeError("DMG does not contain the Applications link") + run([ + "/usr/bin/codesign", + "--verify", + "--deep", + "--strict", + "--verbose=2", + mounted_app, + ]) + finally: + run(["/usr/bin/hdiutil", "detach", mountpoint]) + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Build and package Atlas Engine as a self-contained macOS app." + ) + configuration = parser.add_mutually_exclusive_group(required=True) + configuration.add_argument("--debug", action="store_true") + configuration.add_argument("--release", action="store_true") + parser.add_argument("--macOS", dest="macos", action="store_true", required=True) + return parser.parse_args() + + +def main(): + args = parse_arguments() + if platform.system() != "Darwin": + raise RuntimeError("--macOS packaging must run on macOS") + + root = Path(__file__).resolve().parent.parent + mode = "release" if args.release else "debug" + configuration = mode.capitalize() + architectures = os.environ.get("ATLAS_MACOS_ARCHITECTURES", platform.machine()) + architecture_tag = "universal" if ";" in architectures else architectures + deployment_target = os.environ.get("ATLAS_MACOS_DEPLOYMENT_TARGET", "14.0") + signing_identity = os.environ.get("ATLAS_SIGNING_IDENTITY", "-") + notary_profile = os.environ.get("ATLAS_NOTARY_PROFILE") + allow_unnotarized = os.environ.get("ATLAS_ALLOW_UNNOTARIZED_RELEASE") == "1" + if args.release and (signing_identity == "-" or not notary_profile): + if not allow_unnotarized: + raise RuntimeError( + "A publishable release requires ATLAS_SIGNING_IDENTITY and " + "ATLAS_NOTARY_PROFILE. Set ATLAS_ALLOW_UNNOTARIZED_RELEASE=1 " + "only to create a local test DMG." + ) + if args.release and not allow_unnotarized: + validate_release_identity(signing_identity) + build_directory = root / "build" / "package" / f"macos-{mode}-{architecture_tag}" + assets_directory = build_directory / "package-assets" + dist_directory = root / "dist" / "macOS" / mode + app_name = "Atlas Engine.app" + built_app = build_directory / "bin" / app_name + packaged_app = dist_directory / app_name + icon_config = root / "editor" / "assets" / ( + "AtlasEngine.icon.json" if args.release else "AtlasEngineDev.icon.json" + ) + icon_artwork = root / "editor" / "assets" / "atlas_ball_bright.png" + + assets_directory.mkdir(parents=True, exist_ok=True) + dist_directory.mkdir(parents=True, exist_ok=True) + icon, icon_assets = compile_icon( + icon_config, + icon_artwork, + assets_directory / "compiled-icon", + assets_directory, + deployment_target, + ) + + run([ + require("cmake"), + "-S", + root, + "-B", + build_directory, + "-G", + "Ninja", + f"-DCMAKE_BUILD_TYPE={configuration}", + "-DBACKEND=METAL", + f"-DCMAKE_OSX_ARCHITECTURES={architectures}", + f"-DCMAKE_OSX_DEPLOYMENT_TARGET={deployment_target}", + f"-DATLAS_APP_ICON={icon}", + ]) + run([ + require("cmake"), + "--build", + build_directory, + "--target", + "AtlasEditor", + "--parallel", + str(os.cpu_count() or 4), + ]) + if not built_app.is_dir(): + raise RuntimeError(f"Atlas Engine app bundle was not produced at {built_app}") + + if packaged_app.exists(): + shutil.rmtree(packaged_app) + run(["/usr/bin/ditto", built_app, packaged_app]) + + deploy = [ + locate_macdeployqt(), + packaged_app, + "-always-overwrite", + f"-libpath={build_directory / 'lib'}", + ] + if signing_identity == "-": + deploy.append("-codesign=-") + elif notary_profile: + deploy.append(f"-sign-for-notarization={signing_identity}") + else: + deploy.extend([ + f"-codesign={signing_identity}", + "-hardened-runtime", + "-timestamp", + ]) + run(deploy) + + plist_path = packaged_app / "Contents" / "Info.plist" + resources_directory = packaged_app / "Contents" / "Resources" + shutil.copy2(icon_assets, resources_directory / "Assets.car") + with plist_path.open("rb") as stream: + plist = plistlib.load(stream) + plist["CFBundleIconFile"] = "AtlasEngine" + plist["CFBundleIconName"] = "AtlasEngine" + with plist_path.open("wb") as stream: + plistlib.dump(plist, stream) + sign_bundle(packaged_app, signing_identity) + + if plist.get("CFBundleIdentifier") != "neutralsoftware.atlas": + raise RuntimeError("Packaged app has the wrong bundle identifier") + if plist.get("LSMinimumSystemVersion") != deployment_target: + raise RuntimeError("Packaged app has the wrong minimum macOS version") + if not (packaged_app / "Contents" / "Helpers" / "atlas").is_file(): + raise RuntimeError("Packaged app is missing the Atlas CLI") + if not (packaged_app / "Contents" / "Frameworks" / "runtime.dylib").is_file(): + raise RuntimeError("Packaged app is missing the Atlas runtime") + if not (resources_directory / "AtlasEngine.icns").is_file(): + raise RuntimeError("Packaged app is missing the legacy macOS icon") + if not (resources_directory / "Assets.car").is_file(): + raise RuntimeError("Packaged app is missing the modern macOS icon") + + invalid_dependencies = macho_dependencies(packaged_app) + if invalid_dependencies: + details = "\n".join( + f"{path.relative_to(packaged_app)}: {dependency}" + for path, dependency in invalid_dependencies + ) + raise RuntimeError(f"The app contains non-portable library paths:\n{details}") + + archive = dist_directory / ( + f"Atlas-Engine-alpha9-macOS-{architecture_tag}-{mode}.zip" + ) + archive_bundle(packaged_app, archive) + dmg_suffix = "" + if args.release and allow_unnotarized and not notary_profile: + dmg_suffix = "-UNNOTARIZED" + dmg = dist_directory / ( + f"Atlas-Engine-alpha9-macOS-{architecture_tag}-{mode}{dmg_suffix}.dmg" + ) + create_dmg(packaged_app, dmg, build_directory / "dmg-root") + sign_dmg(dmg, signing_identity) + if notary_profile: + if signing_identity == "-": + raise RuntimeError("ATLAS_NOTARY_PROFILE requires ATLAS_SIGNING_IDENTITY") + notarize(dmg, notary_profile) + run([ + "/usr/sbin/spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + dmg, + ]) + validate_dmg(dmg, build_directory / "dmg-mount") + signature = "ad-hoc development signature" + if notary_profile: + signature = "Developer ID signature and notarization" + elif signing_identity != "-": + signature = "Developer ID signature" + print(f"Packaged app: {packaged_app}") + print(f"Archive: {archive}") + print(f"DMG: {dmg}") + print(f"Architecture: {architectures}") + print(f"Minimum macOS: {deployment_target}") + print(f"Trust: {signature}") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.CalledProcessError) as error: + print(f"Packaging failed: {error}", file=sys.stderr) + raise SystemExit(1)