#!/usr/bin/env bash set -euo pipefail # ----------------------------------------------------------------------------- # YaCy macOS Packaging Workflow (standalone script) # # Prerequisites: # - macOS host with Xcode command-line tools (needed for hdiutil/iconutil/sips) # - Ant/Java toolchain already used for YaCy builds # - Python 3 (only for resolving absolute paths) # # Typical Flow: # 1. Run this script (it defaults to invoking `ant clean all dist` and uses the # newest RELEASE/yacy_v*.tar.gz). Pass --skip-ant when reusing an existing # tarball. # 2. The script assembles YaCy.app plus YaCy-.dmg directly inside RELEASE/. # 3. On first launch the .app copies its bundled DATA directory into # ~/Library/YaCy/DATA and always starts YaCy with `-gui ~/Library/YaCy`, # matching the GUI info dialog. Existing data is preserved on subsequent runs. # # Useful options: # --skip-ant Skip invoking Ant before packaging. # -r/--release-tar FILE Use a specific RELEASE/yacy_v*.tar.gz file. # -o/--output-dir DIR Where to place the resulting .app/.dmg (default RELEASE/). # -n/--app-name NAME Display name for the macOS bundle (default YaCy). # --volume-name NAME DMG volume label. # --payload-name NAME Subdirectory name under Contents/Resources inside the .app. # -i/--icon FILE Override the default addon/YaCy.icns icon. # --keep-work Preserve the staging folder for debugging. # --ant-cmd CMD Alternate Ant executable/binary path. # # Customizing the launcher: # The generated launcher lives at YaCy.app/Contents/MacOS/. It changes # into the embedded payload, ensures ~/Library/YaCy/DATA exists (copying defaults # on first run), then runs Java with `net.yacy.yacy -gui ~/Library/YaCy`. # To tweak JVM flags or the data directory, edit this script's launcher section # (search for JAVA_ARGS) before rebuilding, or modify the generated launcher in # the .app bundle directly. # ----------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="${SCRIPT_DIR}" RELEASE_DIR="${PROJECT_ROOT}/RELEASE" DEFAULT_ICON="${PROJECT_ROOT}/addon/YaCy.icns" OUTPUT_DIR="${RELEASE_DIR}" APP_NAME="YaCy" VOLUME_NAME="YaCy" PAYLOAD_DIR_NAME="YaCy" RUN_ANT_BUILD=1 ANT_CMD="ant" ANT_TARGETS=("clean" "all" "dist") RELEASE_TAR="" ICON_PATH="" KEEP_WORK=0 usage() { cat <<'EOF' Usage: ./package_macos_app.sh [options] Build the standard YaCy distribution with Ant (unless --skip-ant is provided), wrap the resulting release into a macOS .app bundle, and create a .dmg. Options: -r, --release-tar Path to an existing yacy_v*.tar.gz to wrap. Defaults to the newest tarball under RELEASE/. -i, --icon Custom .icns file for the app icon. -o, --output-dir Directory for the .app and .dmg (default RELEASE/). -n, --app-name Name displayed for the .app bundle (default YaCy). --volume-name Volume name for the DMG (default YaCy). --payload-name Directory name used inside Contents/Resources (default YaCy). --skip-ant Do not run "ant clean all dist" automatically. --ant-cmd Ant executable to invoke (default ant). --keep-work Keep the temporary staging directory (for debugging). -h, --help Show this help message. EOF } abs_path() { python3 - <<'PY' "$1" import os, sys print(os.path.abspath(sys.argv[1])) PY } while [[ $# -gt 0 ]]; do case "$1" in -r|--release-tar) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } RELEASE_TAR="$(abs_path "$2")" shift 2 ;; -i|--icon) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } ICON_PATH="$(abs_path "$2")" shift 2 ;; -o|--output-dir) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } OUTPUT_DIR="$(abs_path "$2")" shift 2 ;; -n|--app-name) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } APP_NAME="$2" shift 2 ;; --volume-name) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } VOLUME_NAME="$2" shift 2 ;; --payload-name) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } PAYLOAD_DIR_NAME="$2" shift 2 ;; --skip-ant) RUN_ANT_BUILD=0 shift ;; --ant-cmd) [[ $# -lt 2 ]] && { echo "Missing value for $1" >&2; exit 1; } ANT_CMD="$2" shift 2 ;; --keep-work) KEEP_WORK=1 shift ;; -h|--help) usage exit 0 ;; --) shift break ;; *) echo "Unknown option: $1" >&2 usage exit 1 ;; esac done if [[ ${RUN_ANT_BUILD} -eq 1 ]]; then echo "Running ${ANT_CMD} ${ANT_TARGETS[*]}..." (cd "${PROJECT_ROOT}" && "${ANT_CMD}" "${ANT_TARGETS[@]}") fi if [[ -z "${RELEASE_TAR}" ]]; then set +e latest_tar="$(ls -t "${RELEASE_DIR}"/yacy_v*.tar.gz 2>/dev/null | head -n 1)" status=$? set -e if [[ ${status} -ne 0 || -z "${latest_tar}" ]]; then echo "No release tarball found under ${RELEASE_DIR}. Run 'ant clean all dist' first." >&2 exit 1 fi RELEASE_TAR="$(abs_path "${latest_tar}")" fi if [[ ! -f "${RELEASE_TAR}" ]]; then echo "Release tarball not found: ${RELEASE_TAR}" >&2 exit 1 fi if [[ -z "${ICON_PATH}" ]]; then if [[ -f "${DEFAULT_ICON}" ]]; then ICON_PATH="${DEFAULT_ICON}" else echo "Warning: default icon not found (${DEFAULT_ICON}); the app bundle will not include an icon." ICON_PATH="" fi fi if [[ -n "${ICON_PATH}" && ! -f "${ICON_PATH}" ]]; then echo "Icon file not found: ${ICON_PATH}" >&2 exit 1 fi if ! command -v hdiutil >/dev/null 2>&1; then echo "hdiutil is required to create a DMG. Please run this script on macOS." >&2 exit 1 fi mkdir -p "${OUTPUT_DIR}" WORK_DIR="$(mktemp -d "${PROJECT_ROOT}/macos_app_work.XXXXXX")" cleanup() { if [[ ${KEEP_WORK} -eq 0 ]]; then rm -rf "${WORK_DIR}" 2>/dev/null || true else echo "Keeping work directory at ${WORK_DIR}" fi } trap cleanup EXIT echo "Using release tarball: ${RELEASE_TAR}" tar -xzf "${RELEASE_TAR}" -C "${WORK_DIR}" PAYLOAD_SOURCE="$(find "${WORK_DIR}" -mindepth 1 -maxdepth 1 -type d | head -n 1)" if [[ -z "${PAYLOAD_SOURCE}" ]]; then echo "Could not find the extracted YaCy payload inside ${WORK_DIR}" >&2 exit 1 fi APP_BUNDLE_SOURCE="${WORK_DIR}/${APP_NAME}.app" CONTENTS_DIR="${APP_BUNDLE_SOURCE}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" PAYLOAD_TARGET="${RESOURCES_DIR}/${PAYLOAD_DIR_NAME}" cp -R "${PAYLOAD_SOURCE}/." "${PAYLOAD_TARGET}" if [[ -n "${ICON_PATH}" ]]; then ICON_NAME="$(basename "${ICON_PATH}")" cp "${ICON_PATH}" "${RESOURCES_DIR}/${ICON_NAME}" else ICON_NAME="" fi RELEASE_STUB="$(basename "${RELEASE_TAR}")" RELEASE_STUB="${RELEASE_STUB%.tar.gz}" APP_VERSION="${RELEASE_STUB#yacy_v}" INFO_PLIST="${CONTENTS_DIR}/Info.plist" cat > "${INFO_PLIST}" < CFBundleDevelopmentRegion English CFBundleExecutable ${APP_NAME} CFBundleIconFile EOF if [[ -n "${ICON_NAME}" ]]; then cat >> "${INFO_PLIST}" <${ICON_NAME} EOF else cat >> "${INFO_PLIST}" <<'EOF' EOF fi cat >> "${INFO_PLIST}" <CFBundleIdentifier net.yacy.search CFBundleInfoDictionaryVersion 6.0 CFBundleName ${APP_NAME} CFBundleDisplayName ${APP_NAME} CFBundlePackageType APPL CFBundleShortVersionString ${APP_VERSION} CFBundleVersion ${APP_VERSION} LSMinimumSystemVersion 10.13 NSHighResolutionCapable EOF LAUNCHER="${MACOS_DIR}/${APP_NAME}" cat > "${LAUNCHER}" <<'EOF' #!/usr/bin/env bash set -euo pipefail APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" YACY_HOME="${APP_ROOT}/Resources/%PAYLOAD_DIR%" cd "${YACY_HOME}" USER_DATA_HOME="${HOME}/Library/YaCy" TEMPLATE_DATA="${YACY_HOME}/DATA" mkdir -p "${USER_DATA_HOME}" if [[ ! -d "${USER_DATA_HOME}/DATA" ]]; then echo "Initializing YaCy data directory at ${USER_DATA_HOME}/DATA" if [[ -d "${TEMPLATE_DATA}" ]]; then if command -v rsync >/dev/null 2>&1; then rsync -a "${TEMPLATE_DATA}/" "${USER_DATA_HOME}/DATA/" else mkdir -p "${USER_DATA_HOME}/DATA" cp -R "${TEMPLATE_DATA}/." "${USER_DATA_HOME}/DATA/" fi else mkdir -p "${USER_DATA_HOME}/DATA" fi fi TEMURIN_URL="https://adoptium.net/temurin/releases/?version=17&os=mac&package=jdk" JAVA_BIN="" JAVA_SPEC_VERSION="" INCOMPATIBLE_JAVA="" try_java() { local candidate="$1" local settings="" local specification_version="" local major_version="" [[ -x "${candidate}" ]] || return 1 if ! settings="$("${candidate}" -XshowSettings:properties -version 2>&1)"; then return 1 fi while IFS= read -r line; do if [[ "${line}" =~ java\.specification\.version[[:space:]]*=[[:space:]]*([^[:space:]]+) ]]; then specification_version="${BASH_REMATCH[1]}" break fi done <<< "${settings}" major_version="${specification_version#1.}" major_version="${major_version%%.*}" [[ "${major_version}" =~ ^[0-9]+$ ]] || return 1 if (( major_version < 17 )); then if [[ -z "${INCOMPATIBLE_JAVA}" ]]; then INCOMPATIBLE_JAVA="${candidate} (Java ${specification_version})" fi return 1 fi JAVA_BIN="${candidate}" JAVA_SPEC_VERSION="${specification_version}" return 0 } show_java_requirement() { local detail="$1" local message="YaCy requires Java 17 or newer, but no compatible Java runtime was found." echo "${message}" >&2 if [[ -n "${detail}" ]]; then echo "${detail}" >&2 fi echo "Please install Eclipse Temurin 17 or newer and start YaCy again:" >&2 echo "${TEMURIN_URL}" >&2 if command -v osascript >/dev/null 2>&1; then local selected_button="" selected_button="$(osascript </dev/null || true set dialogResult to display dialog "YaCy requires Java 17 or newer, but no compatible Java runtime was found.\n\nPlease install Eclipse Temurin 17 or newer and start YaCy again.\n\n${TEMURIN_URL}" with title "YaCy requires Java" buttons {"Quit", "Open Temurin Download"} default button "Open Temurin Download" cancel button "Quit" return button returned of dialogResult APPLESCRIPT )" if [[ "${selected_button}" == "Open Temurin Download" ]]; then open "${TEMURIN_URL}" fi fi } if [[ -n "${JAVA_HOME:-}" ]]; then try_java "${JAVA_HOME}/bin/java" || true fi if [[ -z "${JAVA_BIN}" && -x /usr/libexec/java_home ]]; then java_home_17="$(/usr/libexec/java_home -v '17+' 2>/dev/null || true)" if [[ -n "${java_home_17}" ]]; then try_java "${java_home_17}/bin/java" || true fi fi if [[ -z "${JAVA_BIN}" ]] && command -v java >/dev/null 2>&1; then try_java "$(command -v java)" || true fi if [[ -z "${JAVA_BIN}" ]]; then if [[ -n "${INCOMPATIBLE_JAVA}" ]]; then show_java_requirement "Found an incompatible runtime: ${INCOMPATIBLE_JAVA}" else show_java_requirement "No working Java runtime was detected." fi exit 1 fi JAVA_ARGS=() JAVA_ARGS+=(-server) JAVA_ARGS+=(-Dfile.encoding=UTF-8) JAVA_ARGS+=(-Djava.awt.headless=false) if uname -m | grep -q 64; then JAVA_ARGS+=(-Dsolr.directoryFactory=solr.MMapDirectoryFactory) fi CONFIG_FILE="DATA/SETTINGS/yacy.conf" if [[ -f "${CONFIG_FILE}" ]]; then XMX="$(grep -E '^javastart_Xmx=' "${CONFIG_FILE}" | sed 's/^[^=]*=//' | tail -n 1)" if [[ -n "${XMX}" ]]; then JAVA_ARGS=("-${XMX}" "${JAVA_ARGS[@]}") else JAVA_ARGS=("-Xmx600m" "${JAVA_ARGS[@]}") fi else JAVA_ARGS=("-Xmx600m" "${JAVA_ARGS[@]}") fi exec "${JAVA_BIN}" "${JAVA_ARGS[@]}" -classpath "lib/*" net.yacy.yacy -gui "${USER_DATA_HOME}" EOF perl -0pi -e "s/%PAYLOAD_DIR%/${PAYLOAD_DIR_NAME//\//\\/}/" "${LAUNCHER}" chmod +x "${LAUNCHER}" FINAL_APP="${OUTPUT_DIR}/${APP_NAME}.app" rm -rf "${FINAL_APP}" mv "${APP_BUNDLE_SOURCE}" "${FINAL_APP}" DMG_NAME="${RELEASE_STUB}.dmg" DMG_PATH="${OUTPUT_DIR}/${DMG_NAME}" rm -f "${DMG_PATH}" echo "Creating DMG at ${DMG_PATH}" hdiutil create -volname "${VOLUME_NAME}" -srcfolder "${FINAL_APP}" -ov -format UDZO "${DMG_PATH}" >/dev/null echo "macOS app created:" echo " App bundle: ${FINAL_APP}" echo " DMG: ${DMG_PATH}"