diff --git a/.github/workflows/cibuild.yml b/.github/workflows/cibuild.yml index 0c1c392fe40..22572363caa 100644 --- a/.github/workflows/cibuild.yml +++ b/.github/workflows/cibuild.yml @@ -138,7 +138,7 @@ jobs: - name: Install macOS packages if: ${{ startsWith(matrix.platform.name, 'macOS') }} run: | - brew install ccache cmake sdl2 lzo libogg libvorbis theora openal-soft jpeg-turbo + brew install ccache cmake dylibbundler sdl2 lzo libogg libvorbis theora openal-soft jpeg-turbo - name: Install Fedora packages if: ${{ matrix.platform.name == 'Fedora' }} @@ -181,6 +181,13 @@ jobs: if: ${{ startsWith(matrix.platform.name, 'macOS') }} run: ccache -s || true + - name: Make macOS app bundle + if: ${{ startsWith(matrix.platform.name, 'macOS') && matrix.configuration == 'Release' && steps.cmake-build.outcome == 'success' }} + id: make-macos-app-bundle + run: | + bash misc/macos/make_app_bundle.sh "${{ matrix.platform.arch }}" "${{ matrix.configuration }}" + file build/artifacts/openxray*.* + # TODO: Merge this step with 'Make AppImage' once we switch to CMake 4.2 which directly supports AppImage generation with CPack # https://cmake.org/cmake/help/latest/cpack_gen/appimage.html - name: Make package @@ -203,7 +210,7 @@ jobs: file build/artifacts/openxray*.* - name: Upload OpenXRay artifact - if: ${{ steps.make-package.outcome == 'success' || steps.make-appimage.outcome == 'success' }} + if: ${{ steps.make-package.outcome == 'success' || steps.make-appimage.outcome == 'success' || steps.make-macos-app-bundle.outcome == 'success' }} uses: actions/upload-artifact@main with: name: ${{ matrix.platform.name }} ${{ matrix.configuration }} ${{ matrix.platform.arch }} (${{ matrix.platform.cc }} github-${{ github.run_number }}) diff --git a/misc/macos/make_app_bundle.sh b/misc/macos/make_app_bundle.sh new file mode 100755 index 00000000000..44feb43e4a8 --- /dev/null +++ b/misc/macos/make_app_bundle.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + exit 1 +fi + +ARCH="$1" +CONFIGURATION="$2" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +BIN_DIR="${ROOT_DIR}/bin/${ARCH}/${CONFIGURATION}" +ARTIFACTS_DIR="${ROOT_DIR}/build/artifacts" + +APP_DIR="${ARTIFACTS_DIR}/OpenXRay.app" +CONTENTS_DIR="${APP_DIR}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +LIBS_DIR="${CONTENTS_DIR}/libs" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +OXR_RES_DIR="${RESOURCES_DIR}/openxray" + +if [[ ! -x "${BIN_DIR}/xr_3da" ]]; then + echo "Cannot find executable: ${BIN_DIR}/xr_3da" + exit 1 +fi + +for tool in install_name_tool dylibbundler ditto hdiutil; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "Required tool is missing: ${tool}" + exit 1 + fi +done + +mkdir -p "${ARTIFACTS_DIR}" +rm -rf "${APP_DIR}" +mkdir -p "${MACOS_DIR}" "${LIBS_DIR}" "${OXR_RES_DIR}" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleName + OpenXRay + CFBundleDisplayName + OpenXRay + CFBundleIdentifier + org.openxray.xray-16 + CFBundlePackageType + APPL + CFBundleExecutable + xr_3da + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSApplicationCategoryType + public.app-category.games + NSHighResolutionCapable + + + +PLIST + +printf 'APPL????' > "${CONTENTS_DIR}/PkgInfo" + +cp "${BIN_DIR}/xr_3da" "${MACOS_DIR}/xr_3da" +chmod +x "${MACOS_DIR}/xr_3da" + +find "${BIN_DIR}" -maxdepth 1 -type f -name '*.dylib' -exec cp {} "${LIBS_DIR}/" \; + +# Bundle only open-source engine resources from this repository. +cp "${ROOT_DIR}/res/fsgame.ltx" "${OXR_RES_DIR}/fsgame.ltx" +cp -R "${ROOT_DIR}/res/gamedata" "${OXR_RES_DIR}/gamedata" + +# Ensure runtime can resolve in-bundle libraries. +install_name_tool -add_rpath "@executable_path/../libs" "${MACOS_DIR}/xr_3da" || true +for lib in "${LIBS_DIR}"/*.dylib; do + [[ -e "${lib}" ]] || continue + install_name_tool -add_rpath "@loader_path" "${lib}" || true +done + +# Bundle non-system dynamic libraries (Homebrew deps etc.). +dylibbundler \ + -of -cd -b \ + -x "${MACOS_DIR}/xr_3da" \ + -d "${LIBS_DIR}" \ + -s "${BIN_DIR}" \ + -s "${LIBS_DIR}" + +APP_ZIP="${ARTIFACTS_DIR}/openxray-${CONFIGURATION}-${ARCH}.app.zip" +DMG_PATH="${ARTIFACTS_DIR}/openxray-${CONFIGURATION}-${ARCH}.dmg" + +rm -f "${APP_ZIP}" "${DMG_PATH}" +ditto -c -k --sequesterRsrc --keepParent "${APP_DIR}" "${APP_ZIP}" +hdiutil create -volname "OpenXRay ${CONFIGURATION} ${ARCH}" -srcfolder "${APP_DIR}" -format UDZO -ov "${DMG_PATH}" + +echo "Created:" +echo " ${APP_ZIP}" +echo " ${DMG_PATH}" diff --git a/src/xrCore/LocatorAPI.cpp b/src/xrCore/LocatorAPI.cpp index b53178946c0..87ab253d1cc 100644 --- a/src/xrCore/LocatorAPI.cpp +++ b/src/xrCore/LocatorAPI.cpp @@ -799,6 +799,182 @@ bool CLocatorAPI::Recurse(pcstr path) bool file_handle_internal(pcstr file_name, size_t& size, int& file_handle); void* FileDownload(pcstr file_name, const int& file_handle, size_t& file_size); +#if defined(XR_PLATFORM_APPLE) +static bool IsDirectory(pcstr path) +{ + struct stat st; + return stat(path, &st) == 0 && S_ISDIR(st.st_mode); +} + +static bool IsFile(pcstr path) +{ + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +static bool HasRequiredGameData(pcstr rootPath) +{ + if (!rootPath || !rootPath[0]) + return false; + + string_path path; + xr_sprintf(path, "%s/levels", rootPath); + if (!IsDirectory(path)) + return false; + + xr_sprintf(path, "%s/resources", rootPath); + if (!IsDirectory(path)) + return false; + + xr_sprintf(path, "%s/localization", rootPath); + return IsDirectory(path); +} + +static bool PromptForGameRoot(string_path& outPath) +{ + static constexpr pcstr command = + "osascript " + "-e 'try' " + "-e 'POSIX path of (choose folder with prompt \"Select S.T.A.L.K.E.R. game data directory (levels/resources/localization)\")' " + "-e 'on error number -128' " + "-e 'return \"\"' " + "-e 'end try'"; + + FILE* pipe = popen(command, "r"); + if (!pipe) + return false; + + string_path buffer; + buffer[0] = 0; + const bool hasValue = fgets(buffer, sizeof(buffer), pipe) != nullptr; + pclose(pipe); + if (!hasValue) + return false; + + size_t len = SDL_strlen(buffer); + while (len > 0 && (buffer[len - 1] == '\n' || buffer[len - 1] == '\r')) + buffer[--len] = 0; + + if (len == 0) + return false; + + SDL_strlcpy(outPath, buffer, sizeof(outPath)); + return true; +} + +static void LinkIfMissing(pcstr prefPath, pcstr gameRoot, pcstr dirName) +{ + string_path targetPath; + xr_sprintf(targetPath, "%s/%s", gameRoot, dirName); + if (!IsDirectory(targetPath)) + return; + + string_path linkPath; + xr_sprintf(linkPath, "%s%s", prefPath, dirName); + + struct stat st; + if (lstat(linkPath, &st) == 0) + return; + + symlink(targetPath, linkPath); +} + +static void LinkGameDataIntoPrefPath(pcstr prefPath, pcstr gameRoot) +{ + static constexpr pcstr dirs[] = {"levels", "resources", "localization", "mp", "patches"}; + for (const auto dir : dirs) + LinkIfMissing(prefPath, gameRoot, dir); +} + +static bool TryUseGameRoot(pcstr prefPath, pcstr gameRoot) +{ + if (!HasRequiredGameData(gameRoot)) + return false; + + LinkGameDataIntoPrefPath(prefPath, gameRoot); + return HasRequiredGameData(prefPath); +} + +static bool ResolveFromPopularGameLocations(pcstr prefPath) +{ + const char* home = SDL_getenv("HOME"); + if (home && home[0]) + { + string_path steamPath; + xr_sprintf(steamPath, "%s/Library/Application Support/Steam/steamapps/common/STALKER Call of Pripyat", home); + if (TryUseGameRoot(prefPath, steamPath)) + return true; + + string_path gogPath; + xr_sprintf(gogPath, "%s/GOG Games/S.T.A.L.K.E.R. - Call of Pripyat", home); + if (TryUseGameRoot(prefPath, gogPath)) + return true; + + string_path applicationsPath; + xr_sprintf(applicationsPath, "%s/Applications/S.T.A.L.K.E.R. - Call of Pripyat", home); + if (TryUseGameRoot(prefPath, applicationsPath)) + return true; + } + + return TryUseGameRoot(prefPath, "/Applications/S.T.A.L.K.E.R. - Call of Pripyat"); +} + +static void ResolveGameDataForMac(pcstr prefPath) +{ + if (HasRequiredGameData(prefPath)) + return; + + const bool runningFromBundle = strstr(Core.ApplicationPath, ".app/Contents/MacOS/") != nullptr; + if (!runningFromBundle) + return; + + string_path bundleNeighborRoot; + xr_sprintf(bundleNeighborRoot, "%s../../..", Core.ApplicationPath); + if (TryUseGameRoot(prefPath, bundleNeighborRoot)) + return; + + if (ResolveFromPopularGameLocations(prefPath)) + return; + + string_path selectedRoot; + selectedRoot[0] = 0; + if (PromptForGameRoot(selectedRoot) && TryUseGameRoot(prefPath, selectedRoot)) + return; + + SDL_ShowSimpleMessageBox( + SDL_MESSAGEBOX_WARNING, + "OpenXRay: game files are required", + "OpenXRay could not find required game files (levels/resources/localization).\n" + "Please select the game directory when prompted or copy the game files into\n" + "~/Library/Application Support/GSC Game World/S.T.A.L.K.E.R. - Call of Pripyat/", + nullptr); +} + +static bool ResolveOpenXRayResourcesPath(string_path& outPath) +{ + string_path bundleResourcesPath; + xr_sprintf(bundleResourcesPath, "%s../Resources/openxray", Core.ApplicationPath); + + string_path bundleFsgamePath; + xr_sprintf(bundleFsgamePath, "%s/fsgame.ltx", bundleResourcesPath); + if (IsFile(bundleFsgamePath)) + { + SDL_strlcpy(outPath, bundleResourcesPath, sizeof(outPath)); + return true; + } + + string_path installedFsgamePath; + xr_sprintf(installedFsgamePath, "%s/openxray/fsgame.ltx", CMAKE_INSTALL_FULL_DATAROOTDIR); + if (IsFile(installedFsgamePath)) + { + xr_sprintf(outPath, "%s/openxray", CMAKE_INSTALL_FULL_DATAROOTDIR); + return true; + } + + return false; +} +#endif + void CLocatorAPI::setup_fs_path(pcstr fs_name, string_path& fs_path) { xr_strcpy(fs_path, fs_name ? fs_name : ""); @@ -851,8 +1027,10 @@ void CLocatorAPI::setup_fs_path(pcstr fs_name) * I propose adding shaders from /openxray/gamedata/shaders so that we remove unnecessary questions from users who want to start * the game using resources not from the proposed ~/.local/share/GSC Game World/Game in this case, this section of code can be safely removed */ chdir(pref_path); - static constexpr pcstr install_dir = CMAKE_INSTALL_FULL_DATAROOTDIR; string_path tmp, tmp_link; +#if defined(XR_PLATFORM_APPLE) + ResolveGameDataForMac(pref_path); +#endif xr_sprintf(tmp, "%sfsgame.ltx", pref_path); struct stat statbuf; ZeroMemory(&statbuf, sizeof(statbuf)); @@ -868,7 +1046,13 @@ void CLocatorAPI::setup_fs_path(pcstr fs_name) res = lstat(tmp, &statbuf); if (res == 0) xr_unlink(tmp); - xr_sprintf(tmp_link, "%s/openxray/fsgame.ltx", install_dir); +#if defined(XR_PLATFORM_APPLE) + string_path resourcesRoot; + if (ResolveOpenXRayResourcesPath(resourcesRoot)) + xr_sprintf(tmp_link, "%s/fsgame.ltx", resourcesRoot); + else +#endif + xr_sprintf(tmp_link, "%s/openxray/fsgame.ltx", CMAKE_INSTALL_FULL_DATAROOTDIR); symlink(tmp_link, tmp); } xr_sprintf(tmp, "%sgamedata/shaders/gl", pref_path); @@ -885,7 +1069,13 @@ void CLocatorAPI::setup_fs_path(pcstr fs_name) mkdir("gamedata", S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH); mkdir("gamedata/shaders", S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH); } - xr_sprintf(tmp_link, "%s/openxray/gamedata/shaders/gl", install_dir); +#if defined(XR_PLATFORM_APPLE) + string_path resourcesRoot; + if (ResolveOpenXRayResourcesPath(resourcesRoot)) + xr_sprintf(tmp_link, "%s/gamedata/shaders/gl", resourcesRoot); + else +#endif + xr_sprintf(tmp_link, "%s/openxray/gamedata/shaders/gl", CMAKE_INSTALL_FULL_DATAROOTDIR); symlink(tmp_link, tmp); }