From b1de59c47bce6bafae5334952ee0df5f580350e4 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 18:38:10 +0200 Subject: [PATCH 1/6] packaging | Prepare Atlas Engine macOS app bundle --- editor/CMakeLists.txt | 61 +++- editor/application/toolchainInstaller.cpp | 187 +++++++++++++ editor/macos/Info.plist.in | 34 +++ editor/main.cpp | 2 + .../editor/application/toolchainInstaller.h | 10 + justfile | 6 + scripts/package_app.py | 261 ++++++++++++++++++ 7 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 editor/application/toolchainInstaller.cpp create mode 100644 editor/macos/Info.plist.in create mode 100644 include/editor/application/toolchainInstaller.h create mode 100755 scripts/package_app.py diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 47608e5e..4b62a201 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 "9" + 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..56816e54 --- /dev/null +++ b/editor/application/toolchainInstaller.cpp @@ -0,0 +1,187 @@ +#include "editor/application/toolchainInstaller.h" + +#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 ToolchainInstaller::ensureInstalled(QWidget* parent) { + const ToolchainPaths toolchain = paths(); + if (!QFileInfo::exists(toolchain.bundledCli) || + !QFileInfo::exists(toolchain.bundledRuntime)) + return true; + + const bool installed = filesMatch(toolchain.bundledCli, + toolchain.installedCli) && + filesMatch(toolchain.bundledRuntime, + toolchain.installedRuntime) && + configMatches(toolchain); + if (installed) + return true; + + 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) + return false; + + 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; + } + + QMessageBox::information( + parent, "Atlas Toolchain Installed", + "The Atlas toolchain is ready. Projects can now be created, run, and exported from Atlas Engine."); + return true; +} diff --git a/editor/macos/Info.plist.in b/editor/macos/Info.plist.in new file mode 100644 index 00000000..2de6e095 --- /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 + ${MACOSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/editor/main.cpp b/editor/main.cpp index ed83a49b..50d17ad7 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" @@ -65,6 +66,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/include/editor/application/toolchainInstaller.h b/include/editor/application/toolchainInstaller.h new file mode 100644 index 00000000..bd5997e9 --- /dev/null +++ b/include/editor/application/toolchainInstaller.h @@ -0,0 +1,10 @@ +#ifndef ATLAS_TOOLCHAININSTALLER_H +#define ATLAS_TOOLCHAININSTALLER_H + +class QWidget; + +namespace ToolchainInstaller { +bool ensureInstalled(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..8e7c129f --- /dev/null +++ b/scripts/package_app.py @@ -0,0 +1,261 @@ +#!/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 create_icon(source, destination, work_directory): + sips = require("sips", "/usr/bin/sips") + iconutil = require("iconutil", "/usr/bin/iconutil") + iconset = work_directory / "AtlasEngine.iconset" + if iconset.exists(): + shutil.rmtree(iconset) + iconset.mkdir(parents=True) + sizes = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, + } + for filename, size in sizes.items(): + run([sips, "-z", size, size, source, "--out", iconset / filename]) + run([iconutil, "-c", "icns", iconset, "-o", destination]) + + +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 = run([file_tool, "-b", path], capture=True).stdout + if "Mach-O" not in kind: + continue + output = run([otool, "-L", path], capture=True).stdout.splitlines()[1:] + for line in output: + dependency = line.strip().split(" (", 1)[0] + 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", "--options", "runtime"] + if identity != "-": + command.append("--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 notarize(bundle, profile, temporary_archive): + archive_bundle(bundle, temporary_archive) + run([ + "/usr/bin/xcrun", + "notarytool", + "submit", + temporary_archive, + "--keychain-profile", + profile, + "--wait", + ]) + run(["/usr/bin/xcrun", "stapler", "staple", bundle]) + temporary_archive.unlink() + + +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") + 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_source = root / "editor" / "assets" / ( + "iconFile-iOS-Dark-1024x1024@1x.png" + if args.release + else "Icon-iOS-Default-1024x1024@1x.png" + ) + icon = assets_directory / "AtlasEngine.icns" + + assets_directory.mkdir(parents=True, exist_ok=True) + dist_directory.mkdir(parents=True, exist_ok=True) + create_icon(icon_source, icon, assets_directory) + + 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'}", + "-hardened-runtime", + ] + if signing_identity == "-": + deploy.append("-codesign=-") + elif notary_profile: + deploy.append(f"-sign-for-notarization={signing_identity}") + else: + deploy.extend([f"-codesign={signing_identity}", "-timestamp"]) + run(deploy) + sign_bundle(packaged_app, signing_identity) + + plist_path = packaged_app / "Contents" / "Info.plist" + with plist_path.open("rb") as stream: + plist = plistlib.load(stream) + if plist.get("CFBundleIdentifier") != "neutralsoftware.atlas": + raise RuntimeError("Packaged app has the wrong bundle identifier") + 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") + + 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}") + + temporary_archive = dist_directory / "Atlas-Engine-notarization.zip" + if notary_profile: + if signing_identity == "-": + raise RuntimeError("ATLAS_NOTARY_PROFILE requires ATLAS_SIGNING_IDENTITY") + notarize(packaged_app, notary_profile, temporary_archive) + + archive = dist_directory / ( + f"Atlas-Engine-alpha9-macOS-{architecture_tag}-{mode}.zip" + ) + archive_bundle(packaged_app, archive) + 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"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) From e3d304b96d99e1833f9c8bc20a307c60eca44143 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 18:42:35 +0200 Subject: [PATCH 2/6] packaging | Document and verify macOS distribution --- docs/pages/editor.md | 17 +++++++++++++++++ editor/macos/Info.plist.in | 2 +- scripts/package_app.py | 26 ++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/pages/editor.md b/docs/pages/editor.md index f0269a70..4840a542 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. + +Development packages use an ad-hoc signature. For a Developer ID package, set `ATLAS_SIGNING_IDENTITY` to the certificate name. Set `ATLAS_NOTARY_PROFILE` to a `notarytool` keychain profile to submit, wait for notarization, and staple the result automatically. + +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/macos/Info.plist.in b/editor/macos/Info.plist.in index 2de6e095..fff7b0b4 100644 --- a/editor/macos/Info.plist.in +++ b/editor/macos/Info.plist.in @@ -25,7 +25,7 @@ LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion - ${MACOSX_DEPLOYMENT_TARGET} + ${CMAKE_OSX_DEPLOYMENT_TARGET} NSHighResolutionCapable NSPrincipalClass diff --git a/scripts/package_app.py b/scripts/package_app.py index 8e7c129f..d1454f31 100755 --- a/scripts/package_app.py +++ b/scripts/package_app.py @@ -75,12 +75,32 @@ def macho_dependencies(bundle): for path in bundle.rglob("*"): if not path.is_file() or path.is_symlink(): continue - kind = run([file_tool, "-b", path], capture=True).stdout + kind = subprocess.run( + [file_tool, "-b", path], + check=True, + text=True, + capture_output=True, + ).stdout if "Mach-O" not in kind: continue - output = run([otool, "-L", path], capture=True).stdout.splitlines()[1:] + 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)) @@ -218,6 +238,8 @@ def main(): plist = plistlib.load(stream) 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(): From 94ce2743437664cf2992d3408b26ac5ffa637c22 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 19:25:33 +0200 Subject: [PATCH 3/6] packaging | Add publishable macOS DMG workflow --- docs/pages/editor.md | 2 +- scripts/package_app.py | 142 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 16 deletions(-) diff --git a/docs/pages/editor.md b/docs/pages/editor.md index 4840a542..5b00545d 100644 --- a/docs/pages/editor.md +++ b/docs/pages/editor.md @@ -79,6 +79,6 @@ The equivalent `just` recipes are `just package-debug-macos` and `just package-r 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. -Development packages use an ad-hoc signature. For a Developer ID package, set `ATLAS_SIGNING_IDENTITY` to the certificate name. Set `ATLAS_NOTARY_PROFILE` to a `notarytool` keychain profile to submit, wait for notarization, and staple the result automatically. +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/scripts/package_app.py b/scripts/package_app.py index d1454f31..4315bf58 100755 --- a/scripts/package_app.py +++ b/scripts/package_app.py @@ -109,9 +109,9 @@ def macho_dependencies(bundle): def sign_bundle(bundle, identity): codesign = require("codesign", "/usr/bin/codesign") - command = [codesign, "--force", "--deep", "--options", "runtime"] + command = [codesign, "--force", "--deep"] if identity != "-": - command.append("--timestamp") + command.extend(["--options", "runtime", "--timestamp"]) command.extend(["--sign", identity, bundle]) run(command) run([codesign, "--verify", "--deep", "--strict", "--verbose=2", bundle]) @@ -131,19 +131,100 @@ def archive_bundle(bundle, archive): ]) -def notarize(bundle, profile, temporary_archive): - archive_bundle(bundle, temporary_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", - temporary_archive, + artifact, "--keychain-profile", profile, "--wait", ]) - run(["/usr/bin/xcrun", "stapler", "staple", bundle]) - temporary_archive.unlink() + 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(): @@ -170,6 +251,16 @@ def main(): 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 @@ -222,14 +313,17 @@ def main(): packaged_app, "-always-overwrite", f"-libpath={build_directory / 'lib'}", - "-hardened-runtime", ] if signing_identity == "-": deploy.append("-codesign=-") elif notary_profile: deploy.append(f"-sign-for-notarization={signing_identity}") else: - deploy.extend([f"-codesign={signing_identity}", "-timestamp"]) + deploy.extend([ + f"-codesign={signing_identity}", + "-hardened-runtime", + "-timestamp", + ]) run(deploy) sign_bundle(packaged_app, signing_identity) @@ -253,16 +347,33 @@ def main(): ) raise RuntimeError(f"The app contains non-portable library paths:\n{details}") - temporary_archive = dist_directory / "Atlas-Engine-notarization.zip" - if notary_profile: - if signing_identity == "-": - raise RuntimeError("ATLAS_NOTARY_PROFILE requires ATLAS_SIGNING_IDENTITY") - notarize(packaged_app, notary_profile, temporary_archive) - 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" @@ -270,6 +381,7 @@ def main(): 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}") From 66c874e7863dacf04c72c22ec597db54fab5451d Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 20:10:27 +0200 Subject: [PATCH 4/6] onboarding | Remember toolchain installation choice --- editor/application/toolchainInstaller.cpp | 98 ++++++++++++++----- editor/views/editor/editor.cpp | 3 + .../editor/application/toolchainInstaller.h | 1 + 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/editor/application/toolchainInstaller.cpp b/editor/application/toolchainInstaller.cpp index 56816e54..78b84ea1 100644 --- a/editor/application/toolchainInstaller.cpp +++ b/editor/application/toolchainInstaller.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace { @@ -133,35 +134,36 @@ bool writeConfig(const ToolchainPaths& toolchain, QString* error) { } return true; } + +bool isInstalled(const ToolchainPaths& toolchain) { + return filesMatch(toolchain.bundledCli, toolchain.installedCli) && + filesMatch(toolchain.bundledRuntime, toolchain.installedRuntime) && + configMatches(toolchain); } -bool ToolchainInstaller::ensureInstalled(QWidget* parent) { - const ToolchainPaths toolchain = paths(); - if (!QFileInfo::exists(toolchain.bundledCli) || - !QFileInfo::exists(toolchain.bundledRuntime)) - return true; +bool promptDismissed() { + QSettings settings("Neutral Software", "Atlas Engine"); + return settings.value("toolchain/installationPromptDismissed", false) + .toBool(); +} - const bool installed = filesMatch(toolchain.bundledCli, - toolchain.installedCli) && - filesMatch(toolchain.bundledRuntime, - toolchain.installedRuntime) && - configMatches(toolchain); - if (installed) - return true; +void setPromptDismissed(bool dismissed) { + QSettings settings("Neutral Software", "Atlas Engine"); + settings.setValue("toolchain/installationPromptDismissed", dismissed); + settings.sync(); +} - 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) - return false; +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); @@ -180,8 +182,54 @@ bool ToolchainInstaller::ensureInstalled(QWidget* parent) { 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/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 index bd5997e9..6e81b899 100644 --- a/include/editor/application/toolchainInstaller.h +++ b/include/editor/application/toolchainInstaller.h @@ -5,6 +5,7 @@ class QWidget; namespace ToolchainInstaller { bool ensureInstalled(QWidget* parent = nullptr); +bool install(QWidget* parent = nullptr); } #endif From cd428fe6b84618bc205d727b019053e03ae83848 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 20:10:27 +0200 Subject: [PATCH 5/6] packaging | Add native modern macOS app icons --- editor/assets/AtlasEngine.icon.json | 43 +++++++++++++ editor/assets/AtlasEngineDev.icon.json | 29 +++++++++ scripts/package_app.py | 83 +++++++++++++++++--------- 3 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 editor/assets/AtlasEngine.icon.json create mode 100644 editor/assets/AtlasEngineDev.icon.json 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/scripts/package_app.py b/scripts/package_app.py index 4315bf58..734bada9 100755 --- a/scripts/package_app.py +++ b/scripts/package_app.py @@ -28,28 +28,38 @@ def require(name, override=None): return Path(candidate) -def create_icon(source, destination, work_directory): - sips = require("sips", "/usr/bin/sips") - iconutil = require("iconutil", "/usr/bin/iconutil") - iconset = work_directory / "AtlasEngine.iconset" - if iconset.exists(): - shutil.rmtree(iconset) - iconset.mkdir(parents=True) - sizes = { - "icon_16x16.png": 16, - "icon_16x16@2x.png": 32, - "icon_32x32.png": 32, - "icon_32x32@2x.png": 64, - "icon_128x128.png": 128, - "icon_128x128@2x.png": 256, - "icon_256x256.png": 256, - "icon_256x256@2x.png": 512, - "icon_512x512.png": 512, - "icon_512x512@2x.png": 1024, - } - for filename, size in sizes.items(): - run([sips, "-z", size, size, source, "--out", iconset / filename]) - run([iconutil, "-c", "icns", iconset, "-o", destination]) +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(): @@ -267,16 +277,20 @@ def main(): app_name = "Atlas Engine.app" built_app = build_directory / "bin" / app_name packaged_app = dist_directory / app_name - icon_source = root / "editor" / "assets" / ( - "iconFile-iOS-Dark-1024x1024@1x.png" - if args.release - else "Icon-iOS-Default-1024x1024@1x.png" + icon_config = root / "editor" / "assets" / ( + "AtlasEngine.icon.json" if args.release else "AtlasEngineDev.icon.json" ) - icon = assets_directory / "AtlasEngine.icns" + 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) - create_icon(icon_source, icon, assets_directory) + icon, icon_assets = compile_icon( + icon_config, + icon_artwork, + assets_directory / "compiled-icon", + assets_directory, + deployment_target, + ) run([ require("cmake"), @@ -325,11 +339,18 @@ def main(): "-timestamp", ]) run(deploy) - sign_bundle(packaged_app, signing_identity) 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: @@ -338,6 +359,10 @@ def main(): 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: From 02d7b1a3131df1546ece0d0202086124268689b9 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sat, 18 Jul 2026 20:10:27 +0200 Subject: [PATCH 6/6] macOS | Let the app bundle own the application icon --- editor/CMakeLists.txt | 2 +- editor/main.cpp | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 4b62a201..64dbf688 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -105,7 +105,7 @@ if (APPLE) 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 "9" + MACOSX_BUNDLE_BUNDLE_VERSION "10" OUTPUT_NAME "Atlas Engine" BUILD_RPATH "@executable_path/../Frameworks" INSTALL_RPATH "@executable_path/../Frameworks" diff --git a/editor/main.cpp b/editor/main.cpp index 50d17ad7..23f52a26 100644 --- a/editor/main.cpp +++ b/editor/main.cpp @@ -52,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")); @@ -59,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);