diff --git a/.github/workflows/builds-macos.yml b/.github/workflows/builds-macos.yml index 9ec9d702..5d73b46b 100644 --- a/.github/workflows/builds-macos.yml +++ b/.github/workflows/builds-macos.yml @@ -37,6 +37,12 @@ jobs: with: python-version: '3.12' + - name: Install Python dependencies for tooling + if: startsWith(github.ref, 'refs/tags/') + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Import Developer Certificate run: | echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 @@ -51,7 +57,7 @@ jobs: MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} MACOS_TEMPORARY_KEYCHAIN_PASSWORD: $ {{ secrets.MACOS_TEMPORARY_KEYCHAIN_PASSWORD }} - + - name: Build the MacOS DMG shell: bash run: | @@ -69,6 +75,35 @@ jobs: TEAM_ID: ${{ secrets.TEAM_ID }} MACOS_TEMPORARY_KEYCHAIN_PASSWORD: $ {{ secrets.MACOS_TEMPORARY_KEYCHAIN_PASSWORD }} + - name: Download Sparkle signing tools + if: startsWith(github.ref, 'refs/tags/') + run: | + curl -L https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz -o Sparkle.tar.xz + tar -xJf Sparkle.tar.xz Sparkle-2.6.4/bin/sign_update + chmod +x Sparkle-2.6.4/bin/sign_update + + - name: Extract Sparkle private key + if: startsWith(github.ref, 'refs/tags/') + env: + MAC_SPARKLE_PRIVATE_KEY: ${{ secrets.MAC_SPARKLE_PRIVATE_KEY }} + run: | + echo "$MAC_SPARKLE_PRIVATE_KEY" | base64 --decode > sparkle_private_key_ed25519.pem + chmod 600 sparkle_private_key_ed25519.pem + + - name: Sign macOS update + if: startsWith(github.ref, 'refs/tags/') + run: | + Sparkle-2.6.4/bin/sign_update sparkle_private_key_ed25519.pem build/KnobKraft_Orm-${{env.ORM_VERSION}}-Darwin.dmg | tail -1 | awk '{print $NF}' > mac_update.sig + + - name: Update macOS appcast feed + if: startsWith(github.ref, 'refs/tags/') + env: + APPCAST_ACCESS_TOKEN: ${{ secrets.APPCAST_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python write_appcast.py --platform mac --signature-file mac_update.sig + python make_github_release.py + - name: Archive DMG artifact uses: actions/upload-artifact@v4 if: always() @@ -86,3 +121,9 @@ jobs: tags: true draft: false + - name: Delete Sparkle signing materials + if: startsWith(github.ref, 'refs/tags/') + run: | + rm -f sparkle_private_key_ed25519.pem mac_update.sig Sparkle.tar.xz + rm -rf Sparkle-2.6.4 + diff --git a/.github/workflows/builds-windows.yml b/.github/workflows/builds-windows.yml index 04f767a9..fa6f2092 100644 --- a/.github/workflows/builds-windows.yml +++ b/.github/workflows/builds-windows.yml @@ -72,7 +72,7 @@ jobs: working-directory: Builds/The-Orm/RelWithDebInfo run: | sentry-cli upload-dif . --log-level=debug - + # Thanks to https://svrooij.io/2021/08/17/github-actions-secret-file/ - name: Extract update private key from secret if: startsWith(github.ref, 'refs/tags/') diff --git a/CMakeLists.txt b/CMakeLists.txt index b98ab0ee..a983170e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,10 @@ # -# Copyright (c) 2020 Christof Ruch. All rights reserved. +# Copyright (c) 2020-2025 Christof Ruch. All rights reserved. # # Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase # -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.20) # Target a specific MacOS version. # JUCE 8 refuses to build for anything older than 10.11, so let's try that. @@ -28,10 +28,16 @@ IF(APPLE) # For old Apple < macOS 10.15 and Linux, do not allow C++ 17 because it won't work. With the newer nlohmann::json, we can specify the C++ version to use add_compile_definitions(JSON_HAS_CPP_14) + + # Required for Sparkle + set(BUILD_WITH_INSTALL_RPATH YES) ENDIF() + project(KnobKraft_Orm) +OPTION(SPARKLE_UPDATES "Turn on Sparkle/WinSparkle update service") + #set(USE_ASIO true) option(ASAN "Use Address Sanitization for Debug version (Windows only for now)" OFF) @@ -72,11 +78,12 @@ else() set(PYTHON_VERSION_TO_EMBED "3.12" CACHE STRING "Specify which version of Python should be used for embedding.") endif() +# Include useful scripts for CMake +cmake_policy(SET CMP0135 NEW) +include(FetchContent REQUIRED) + # On Windows, we need to download external dependencies IF (WIN32) - # Include useful scripts for CMake, and opt in for the new fetch content timestamp behavior - cmake_policy(SET CMP0135 NEW) - include(FetchContent REQUIRED) FetchContent_Declare( icu URL https://github.com/unicode-org/icu/releases/download/release-74-2/icu4c-74_2-Win64-MSVC2019.zip @@ -138,8 +145,15 @@ ELSEIF(APPLE) if (NOT CMAKE_BUILD_TYPE MATCHES Release) add_compile_definitions(DEBUG) endif() - - + + IF(SPARKLE_UPDATES) + FetchContent_Declare( + sparkleframework + URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz + ) + FetchContent_MakeAvailable(sparkleframework) + ENDIF() + # The JUCE font rendering is really fat on macOS, let us try to disable this flag add_definitions(-DJUCE_DISABLE_COREGRAPHICS_FONT_SMOOTHING) ELSEIF(UNIX) diff --git a/Makefile b/Makefile index 61c4ca9f..d603246c 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ apple: notarize staple verify-notarization configure: @echo "Configuring build for type $(BUILD_TYPE) in directory $(BUILD_DIR), using Python from $(PYTHON_TO_USE)" - cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) -DPYTHON_EXECUTABLE=$(PYTHON_TO_USE) -DCODESIGN_CERTIFICATE_NAME="$(APPLE_DEVELOPER_IDENTITY)" + cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) -DPYTHON_EXECUTABLE=$(PYTHON_TO_USE) -DCODESIGN_CERTIFICATE_NAME="$(APPLE_DEVELOPER_IDENTITY)" -DSPARKLE_UPDATES=ON .PHONY: build build $(KNOBKRAFT_DMG): diff --git a/The-Orm/AutoUpdaterInterface.h b/The-Orm/AutoUpdaterInterface.h new file mode 100644 index 00000000..5b5fdaec --- /dev/null +++ b/The-Orm/AutoUpdaterInterface.h @@ -0,0 +1,14 @@ +/* + Copyright (c) 2022 Christof Ruch. All rights reserved. + + Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase +*/ + +#pragma once + +class AutoUpdaterInterface +{ +public: + virtual ~AutoUpdaterInterface() = default; + virtual void checkForUpdates() = 0; +}; diff --git a/The-Orm/CMakeLists.txt b/The-Orm/CMakeLists.txt index 1c7186ed..843f0636 100644 --- a/The-Orm/CMakeLists.txt +++ b/The-Orm/CMakeLists.txt @@ -4,7 +4,7 @@ # Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase # -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.20) # Get the version from our sub cmakefile execute_process( @@ -26,6 +26,7 @@ if(PROJECT_DEV_TAG STREQUAL "-dev") add_definitions(-DPROJECT_DEV) endif() + # Export this variable to be use by the Azure # Append the version number to the github action environment file to be used by subsequent steps if(DEFINED ENV{GITHUB_ENV}) @@ -36,7 +37,6 @@ OPTION(CRASH_REPORTING "Turn on crash reporting via Internet/Sentry") OPTION(SENTRY_LOGGING "Turn on logging of sentry events into the log window") set(SENTRY_DSN "Sentry DSN URL" CACHE STRING "https://YYYYYYYYYYYYYYYYYY@ZZZZZ.ingest.sentry.io/XXXX") set(D_LOG_SENTRY "") -OPTION(SPARKLE_UPDATES "Turn on WinSparkle update service") configure_file("version.cpp.in" "version.cpp") @@ -49,9 +49,17 @@ IF(CRASH_REPORTING) ENDIF() IF(SPARKLE_UPDATES) - message("Sparkle and WinSparkle updates are turned on, the executable will be linked against WinSparkle") - juce_add_binary_data(CodeSigning SOURCES "${CMAKE_CURRENT_LIST_DIR}/../codesigning/dsa_pub.pem") +if(WIN32) + juce_add_binary_data(CodeSigning SOURCES "${CMAKE_CURRENT_LIST_DIR}/../codesigning/dsa_pub.pem") SET(WINSPARKLE_DISTRIBUTION_FILES "${WINSPARKLE_LIBDIR}/WinSparkle.dll") +elseif(APPLE) + set(SPARKLE_FRAMEWORK_PATH ${sparkleframework_SOURCE_DIR}) + message("Using Sparkle Framework from ${SPARKLE_FRAMEWORK_PATH}") + set(SPARKLE_LINK "-F${SPARKLE_FRAMEWORK_PATH}" "-framework Sparkle") +else() + message(FATAL_ERROR "SPARKLE_UPDATES is only supported on Windows and macOS") +endif() + message("Sparkle and WinSparkle updates are turned on") ENDIF() set(SOURCES @@ -102,11 +110,18 @@ set(SOURCES redist/agpl-3.0.txt ) -# Mac Icon Magic if(APPLE) -set(KnobKraftOrm_ICON ${CMAKE_CURRENT_SOURCE_DIR}/resources/icon_orm.icns) -set_source_files_properties(${KnobKraftOrm_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") - + IF(SPARKLE_UPDATES) + # Sparkle framework + message("Adding Mac Objective-C files for Sparkle") + set(SPARKLE_SOURCES MacSparkle.mm MacSparkle.h) + else() + set(SPARKLE_SOURCES "") + endif() + # Limit skip-build-rpath to macOS to match top-level install RPATH handling + set(CMAKE_SKIP_BUILD_RPATH TRUE) + set(KnobKraftOrm_ICON ${CMAKE_CURRENT_SOURCE_DIR}/resources/icon_orm.icns) + set_source_files_properties(${KnobKraftOrm_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") ENDIF() set(MIDIKRAFT_LIBRARIES @@ -126,8 +141,7 @@ set(MIDIKRAFT_LIBRARIES knobkraft-generic-adaptation pytschirp_embedded ) - -add_executable(KnobKraftOrm MACOSX_BUNDLE WIN32 ${KnobKraftOrm_ICON} ${SOURCES}) +add_executable(KnobKraftOrm MACOSX_BUNDLE WIN32 ${KnobKraftOrm_ICON} ${SOURCES} ${SPARKLE_SOURCES}) target_include_directories(KnobKraftOrm INTERFACE ${CMAKE_CURRENT_LIST_DIR}) if (CRASH_REPORTING) target_include_directories(KnobKraftOrm SYSTEM PRIVATE "${SENTRY_INSTALL_PATH}/include") @@ -171,6 +185,9 @@ IF(WIN32) ${SPARKLE_DEPENDENCY} ) ELSEIF(APPLE) + if(SPARKLE_UPDATES) + target_compile_options(KnobKraftOrm PRIVATE -F${SPARKLE_FRAMEWORK_PATH} -DUSE_SPARKLE) + endif() target_link_libraries(KnobKraftOrm PRIVATE ${JUCE_LIBRARIES} ICU::data ICU::uc @@ -180,13 +197,30 @@ ELSEIF(APPLE) gin spdlog::spdlog pybind11::embed + $<$:${SPARKLE_LINK}> ) - SET_TARGET_PROPERTIES(KnobKraftOrm PROPERTIES MACOSX_BUNDLE_BUNDLE_NAME "KnobKraft Orm ${KnobKraftOrm_VERSION}" + SET_TARGET_PROPERTIES(KnobKraftOrm PROPERTIES + MACOSX_BUNDLE_BUNDLE_NAME "KnobKraft Orm ${KnobKraftOrm_VERSION}" MACOSX_BUNDLE_ICON_FILE icon_orm.icns MACOSX_BUNDLE_BUNDLE_VERSION ${KnobKraftOrm_VERSION} MACOSX_BUNDLE_GUI_IDENTIFIER "com.knobkraft.orm" MACOSX_BUNDLE_IDENTIFIER "com.knobkraft.orm" - ) + MACOSX_BUNDLE_EXECUTABLE_NAME KnobKraftOrm + #MACOSX_BUNDLE_BUNDLE_VERSION ${KnobKraftOrm_VERSION} + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/Info.plist + #XCODE_EMBED_FRAMEWORKS Sparkle + #XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY TRUE + #XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY TRUE + ) + # https://stackoverflow.com/questions/68310342/how-to-get-cmake-to-embed-a-private-framework-into-a-macos-app-bundle + set(APP_BUNDLE_CONTENTS_DIR "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.app/Contents") + set(APP_BUNDLE_FRAMEWORKS_DIR "${APP_BUNDLE_CONTENTS_DIR}/Frameworks") + configure_file("Info.plist.in" "Info.plist") + + add_custom_command(TARGET ${PROJECT_NAME} + POST_BUILD COMMAND + install_name_tool -add_rpath "@executable_path/../Frameworks/" + "${APP_BUNDLE_CONTENTS_DIR}/MacOS/${PROJECT_NAME}") ELSEIF(UNIX) target_link_libraries(KnobKraftOrm PRIVATE ${JUCE_LIBRARIES} @@ -328,6 +362,11 @@ ENDIF() IF(APPLE) # This section is supposed to build a relocatable macOS DMG installer when you specify the # --target package + set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}) + IF(SPARKLE_UPDATES) + # Make sure the private frameworks are in out binary directory + file(COPY ${SPARKLE_FRAMEWORK_PATH}/Sparkle.framework DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/KnobKraftOrm.app/Contents/Frameworks) + ENDIF() IF(CODESIGN_CERTIFICATE_NAME) # We need to sign the files as a post build step. Doing this as install step doesn't help because diff --git a/The-Orm/Info.plist.in b/The-Orm/Info.plist.in new file mode 100644 index 00000000..dd5020b2 --- /dev/null +++ b/The-Orm/Info.plist.in @@ -0,0 +1,33 @@ + + + + + + SUFeedURL + https://raw.githubusercontent.com/christofmuc/appcasts/master/KnobKraft-Orm/appcast.xml + SUPublicEDKey + MCowBQYDK2VwAyEAWtvaPXtAM9WMjFoaE6i5ZWBTZE929CUMlCB3NiXTo0g= + CFBundleExecutable + KnobKraftOrm + 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} + + LSMinimumSystemVersion + 10.11 + CFBundleSupportedPlatforms + + MacOSX + + diff --git a/The-Orm/MacSparkle.h b/The-Orm/MacSparkle.h new file mode 100644 index 00000000..14c34708 --- /dev/null +++ b/The-Orm/MacSparkle.h @@ -0,0 +1,21 @@ +/* + Copyright (c) 2022 Christof Ruch. All rights reserved. + + Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase +*/ + +#pragma once + +#include "AutoUpdaterInterface.h" + +class SparkleAutoUpdate : public AutoUpdaterInterface +{ +public: + SparkleAutoUpdate (); + virtual ~SparkleAutoUpdate (); + virtual void checkForUpdates(); + +private: + class Impl; + Impl *d; +}; diff --git a/The-Orm/MacSparkle.mm b/The-Orm/MacSparkle.mm new file mode 100644 index 00000000..1c2e9de9 --- /dev/null +++ b/The-Orm/MacSparkle.mm @@ -0,0 +1,42 @@ +/* + Copyright (c) 2022 Christof Ruch. All rights reserved. + + Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase +*/ + +#include "MacSparkle.h" + +#import +#import + +// This does not follow the new procedure at https://sparkle-project.org/documentation/programmatic-setup/ yet + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + +class SparkleAutoUpdate::Impl +{ +public: + SUUpdater *updater; +}; + +SparkleAutoUpdate::SparkleAutoUpdate() +{ + d = new SparkleAutoUpdate::Impl; + d->updater = [[SUUpdater sharedUpdater] retain]; + [d->updater setAutomaticallyChecksForUpdates: YES]; + [d->updater setUpdateCheckInterval: 3600]; +} + +SparkleAutoUpdate::~SparkleAutoUpdate() +{ + [d->updater release]; + delete d; +} + +void SparkleAutoUpdate::checkForUpdates() +{ + [d->updater checkForUpdates : nil]; +} + +#pragma clang diagnostic pop diff --git a/The-Orm/MainComponent.cpp b/The-Orm/MainComponent.cpp index 68777791..a0be2966 100644 --- a/The-Orm/MainComponent.cpp +++ b/The-Orm/MainComponent.cpp @@ -3,6 +3,17 @@ Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase */ +#ifdef USE_SPARKLE +#ifdef WIN32 +#include "BinaryData.h" +#include +#endif +#ifdef __APPLE__ +#include "MacSparkle.h" + +static SparkleAutoUpdate sAutoUpdate; +#endif +#endif #include "MainComponent.h" @@ -38,13 +49,6 @@ #endif #endif -#ifdef USE_SPARKLE -#include "BinaryData.h" -#ifdef WIN32 -#include -#endif -#endif - // Some command name constants const std::string kRetrievePatches{ "retrieveActiveSynthPatches" }; const std::string kFetchEditBuffer{ "fetchEditBuffer" }; @@ -426,9 +430,12 @@ MainComponent::MainComponent(bool makeYourOwnSize) : #endif #endif #ifdef USE_SPARKLE - { "Check for updates...", { "Check for updates...", [this] { + { "Check for updates...", { "Check for updates...", [] { #ifdef WIN32 win_sparkle_check_update_with_ui(); +#endif +#ifdef __APPLE__ + sAutoUpdate.checkForUpdates(); #endif }}}, #endif @@ -619,6 +626,10 @@ void MainComponent::checkForUpdatesOnStartup() { win_sparkle_set_error_callback(logSparkleError); win_sparkle_set_shutdown_request_callback(sparkleInducedShutdown); win_sparkle_init(); +#else +#ifdef __APPLE__ + sAutoUpdate.checkForUpdates(); +#endif #endif #endif } diff --git a/cmake/codesign.cmake b/cmake/codesign.cmake index 49fe49d7..16895a3d 100644 --- a/cmake/codesign.cmake +++ b/cmake/codesign.cmake @@ -26,9 +26,11 @@ endforeach() # Now fixup our linking and executable paths include(BundleUtilities) set(BU_CHMOD_BUNDLE_ITEMS TRUE) +# Provide Sparkle in the search path so fixup_bundle can resolve @rpath references. # We need the IGNORE Python because of https://gitlab.kitware.com/cmake/cmake/-/issues/20165 # Patching the bundle utils could fix it: https://stackoverflow.com/questions/59415784/cmake-macos-bundleutilities-adds-python-interpreter-to-app-and-doesnt-do-fi -fixup_bundle("${SIGN_DIRECTORY}" "" "" IGNORE_ITEM "Python") +set(FIXUP_SEARCH_DIRS "${SIGN_DIRECTORY}/Contents/Frameworks") +fixup_bundle("${SIGN_DIRECTORY}" "" "${FIXUP_SEARCH_DIRS}" IGNORE_ITEM "Python") # Lastly, sign our executable message(STATUS "Signing with '${CODESIGN_CERTIFICATE_NAME}'") diff --git a/write_appcast.py b/write_appcast.py index e57c3688..6bdcac1d 100644 --- a/write_appcast.py +++ b/write_appcast.py @@ -1,26 +1,89 @@ +import argparse import base64 import os import subprocess import tempfile from datetime import datetime, timezone -import markdown as markdown +import markdown import requests from lxml import etree as ET +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +NSMAP = {"sparkle": SPARKLE_NS} + +ET.register_namespace("sparkle", SPARKLE_NS) + access_token = os.getenv("APPCAST_ACCESS_TOKEN") +DEFAULT_CONFIGS = { + "windows": { + "appcast_url": "https://raw.githubusercontent.com/christofmuc/appcasts/master/KnobKraft-Orm/appcast.xml", + "appcast_path": "KnobKraft-Orm/appcast.xml", + "download_url_template": "https://github.com/christofmuc/KnobKraft-orm/releases/download/{version}/knobkraft_orm_setup_{version}.exe", + "signature_attribute": f"{{{SPARKLE_NS}}}dsaSignature", + "signature_file": "update.sig", + "installer_arguments": "/SILENT /SP- /NOICONS /restartapplications", + "content_type": "application/octet-stream", + "length": "0", + "channel_title": "KnobKraft Orm Updates (Windows)", + "channel_description": "Release feed for KnobKraft Orm (Windows).", + "sparkle_os": None, + "minimum_system_version": None, + }, + "mac": { + "appcast_url": "https://raw.githubusercontent.com/christofmuc/appcasts/master/KnobKraft-Orm/appcast-macos.xml", + "appcast_path": "KnobKraft-Orm/appcast-macos.xml", + "download_url_template": "https://github.com/christofmuc/KnobKraft-orm/releases/download/{version}/KnobKraft_Orm-{version}-Darwin.dmg", + "signature_attribute": f"{{{SPARKLE_NS}}}edSignature", + "signature_file": "mac_update.sig", + "installer_arguments": None, + "content_type": "application/x-apple-diskimage", + "length": "0", + "channel_title": "KnobKraft Orm Updates (macOS)", + "channel_description": "Release feed for KnobKraft Orm (macOS).", + "sparkle_os": "macos", + "minimum_system_version": "10.11", + }, +} + def download_file(url, save_path): response = requests.get(url) response.raise_for_status() - with open(save_path, 'wb') as file: + with open(save_path, "wb") as file: file.write(response.content) +def create_empty_appcast(path, config): + rss = ET.Element("rss", nsmap=NSMAP) + rss.set("version", "2.0") + channel = ET.SubElement(rss, "channel") + ET.SubElement(channel, "title").text = config["channel_title"] + ET.SubElement(channel, "link").text = "https://github.com/christofmuc/KnobKraft-orm" + ET.SubElement(channel, "description").text = config["channel_description"] + tree = ET.ElementTree(rss) + tree.write(path, encoding="utf-8", xml_declaration=True, pretty_print=True) + + +def ensure_local_appcast(config, local_path): + try: + download_file(config["appcast_url"], local_path) + return True + except requests.HTTPError as exc: + if exc.response.status_code == 404: + create_empty_appcast(local_path, config) + return False + raise + + def get_latest_git_tag(): try: - git_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0']).decode().strip() + git_tag = ( + subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]) + .decode() + .strip() + ) return git_tag except subprocess.CalledProcessError: return None @@ -28,127 +91,199 @@ def get_latest_git_tag(): def get_current_time(): now = datetime.now(timezone.utc).astimezone() - formatted = now.strftime('%Y%m%d %H:%M:%S') + formatted = now.strftime("%Y%m%d %H:%M:%S") tz_offset = int(now.utcoffset().total_seconds() / 3600) - formatted += '%+d' % tz_offset + formatted += "%+d" % tz_offset return formatted -def add_release(filename, version, sparkle_signature): - # Load the XML file +def read_signature(signature_file): + with open(signature_file, "r", encoding="utf-8") as file: + signature = file.read().strip() + if ":" in signature: + signature = signature.split(":")[-1].strip() + return signature + + +def add_release(filename, version, sparkle_signature, config): tree = ET.parse(filename) root = tree.getroot() + channel = root.find("channel") + + release_notes_link = ( + f"https://christofmuc.github.io/appcasts/KnobKraft-Orm/{version}.html" + ) + download_url = config["download_url_template"].format(version=version) + + new_item = ET.Element("item") + channel.insert(0, new_item) + + ET.SubElement(new_item, "title").text = f"Version {version}" + ET.SubElement(new_item, f"{{{SPARKLE_NS}}}releaseNotesLink").text = release_notes_link + ET.SubElement(new_item, "pubDate").text = get_current_time() + + enclosure = ET.SubElement(new_item, "enclosure") + enclosure.set("url", download_url) + enclosure.set(f"{{{SPARKLE_NS}}}version", version) + enclosure.set(f"{{{SPARKLE_NS}}}shortVersionString", version) + enclosure.set(config["signature_attribute"], sparkle_signature) + if config.get("installer_arguments"): + enclosure.set( + f"{{{SPARKLE_NS}}}installerArguments", config["installer_arguments"] + ) + if config.get("minimum_system_version"): + enclosure.set( + f"{{{SPARKLE_NS}}}minimumSystemVersion", + config["minimum_system_version"], + ) + if config.get("sparkle_os"): + enclosure.set(f"{{{SPARKLE_NS}}}os", config["sparkle_os"]) + enclosure.set("length", config["length"]) + enclosure.set("type", config["content_type"]) - # Define the new item data - new_item_data = { - 'title': f'Version {version}', - 'releaseNotesLink': f'https://christofmuc.github.io/appcasts/KnobKraft-Orm/{version}.html', - 'pubDate': get_current_time(), - 'url': f'https://github.com/christofmuc/KnobKraft-orm/releases/download/{version}/knobkraft_orm_setup_{version}.exe', - 'version': f'{version}', - 'dsaSignature': sparkle_signature, - 'installerArguments': '/SILENT /SP- /NOICONS /restartapplications', - 'length': '0', - 'type': 'application/octet-stream' - } - - # Create a new item - channel = root.find('channel') - new_item = ET.Element('item') - channel.insert(0, new_item) # insert the new item at the beginning - - # Add sub-elements to the new item - ET.SubElement(new_item, 'title').text = new_item_data['title'] - ET.SubElement(new_item, '{http://www.andymatuschak.org/xml-namespaces/sparkle}releaseNotesLink').text = new_item_data['releaseNotesLink'] - ET.SubElement(new_item, 'pubDate').text = new_item_data['pubDate'] - - enclosure = ET.SubElement(new_item, 'enclosure') - enclosure.set('url', new_item_data['url']) - enclosure.set('{http://www.andymatuschak.org/xml-namespaces/sparkle}version', new_item_data['version']) - enclosure.set('{http://www.andymatuschak.org/xml-namespaces/sparkle}dsaSignature', new_item_data['dsaSignature']) - enclosure.set('{http://www.andymatuschak.org/xml-namespaces/sparkle}installerArguments', new_item_data['installerArguments']) - enclosure.set('length', new_item_data['length']) - enclosure.set('type', new_item_data['type']) - - # Pretty-print the entire tree ET.indent(tree, space=" ") - - # Write the updated XML back to the file - tree.write(filename, encoding='utf-8', xml_declaration=True, pretty_print=True) - return ET.tostring(tree, encoding='utf-8', xml_declaration=True) + tree.write(filename, encoding="utf-8", xml_declaration=True, pretty_print=True) + return ET.tostring(tree, encoding="utf-8", xml_declaration=True) def get_file_sha(repo_owner, repo_name, file_path, access_token): - # Create the API URL to get the file information api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/contents/{file_path}" - - # Prepare the headers for the API request headers = { "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github.v3+json" + "Accept": "application/vnd.github.v3+json", } - - # Send the API request to get the file information response = requests.get(api_url, headers=headers) + if response.status_code == 404: + return None response.raise_for_status() - - # Get the SHA of the file - file_info = response.json() - sha = file_info.get('sha') - - return sha + return response.json().get("sha") -def upload_to_github(updated_xml, repo_owner, repo_name, file_path, is_update: bool): - # Encode the XML content as base64 - base64_content = base64.b64encode(updated_xml).decode().strip() - - # Create the API URL to update the file +def upload_to_github(updated_content, repo_owner, repo_name, file_path, sha=None): + base64_content = base64.b64encode(updated_content).decode().strip() api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/contents/{file_path}" - - # Prepare the headers and data for the API request headers = { "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github.v3+json" + "Accept": "application/vnd.github.v3+json", } data = { "message": f"Update {file_path}", "content": base64_content, } - if is_update: - # The file already exists - data["sha"] = get_file_sha(repo_owner, repo_name, file_path, access_token) - - # Send the API request to update the file + if sha: + data["sha"] = sha response = requests.put(api_url, json=data, headers=headers) response.raise_for_status() def convert_markdown_to_html(markdown_file): - # Read the Markdown file - with open(markdown_file, 'r', encoding='utf-8') as file: + with open(markdown_file, "r", encoding="utf-8") as file: markdown_text = file.read() - - # Convert Markdown to HTML return markdown.markdown(markdown_text) -if __name__ == "__main__": +def build_config(args): + config = DEFAULT_CONFIGS[args.platform].copy() + if args.appcast_url: + config["appcast_url"] = args.appcast_url + if args.appcast_path: + config["appcast_path"] = args.appcast_path + if args.download_url_template: + config["download_url_template"] = args.download_url_template + if args.signature_attribute: + config["signature_attribute"] = args.signature_attribute + if args.signature_file: + config["signature_file"] = args.signature_file + if args.installer_arguments is not None: + config["installer_arguments"] = args.installer_arguments or None + if args.content_type: + config["content_type"] = args.content_type + if args.minimum_system_version is not None: + config["minimum_system_version"] = args.minimum_system_version or None + if args.sparkle_os is not None: + config["sparkle_os"] = args.sparkle_os or None + return config + + +def main(): + parser = argparse.ArgumentParser( + description="Update the Sparkle appcast feed with a tagged release." + ) + parser.add_argument( + "--platform", + choices=list(DEFAULT_CONFIGS.keys()), + default="windows", + help="Target platform whose defaults should be used.", + ) + parser.add_argument("--appcast-url", help="Override the appcast download URL.") + parser.add_argument("--appcast-path", help="Override the repository path to update.") + parser.add_argument( + "--download-url-template", + help="Template for the downloadable artifact URL. Use {version} placeholder.", + ) + parser.add_argument( + "--signature-attribute", + help="Fully qualified XML attribute for the Sparkle signature.", + ) + parser.add_argument( + "--signature-file", + help="Path to the signature file generated by the signing tool.", + ) + parser.add_argument( + "--installer-arguments", + help="Optional installer arguments to embed in the enclosure element.", + ) + parser.add_argument( + "--content-type", + help="MIME type of the downloadable artifact.", + ) + parser.add_argument( + "--minimum-system-version", + help="Minimum supported OS version reported to Sparkle.", + ) + parser.add_argument( + "--sparkle-os", + help="sparkle:os attribute value for the enclosure.", + ) + args = parser.parse_args() + new_version = get_latest_git_tag() print(f"Latest Git tag used as appcast version: {new_version}") if new_version is None: raise Exception("Can't create release without version tag!") - # Read signature file - with open("update.sig", 'r') as file: - sparkle_signature = file.read() + config = build_config(args) + signature_file = config["signature_file"] + sparkle_signature = read_signature(signature_file) print(f"Got sparkle signature as {sparkle_signature}") with tempfile.TemporaryDirectory() as tmpdir: - tmpfile = os.path.join(tmpdir, "appcast.xml") - download_file("https://raw.githubusercontent.com/christofmuc/appcasts/master/KnobKraft-Orm/appcast.xml", tmpfile) - new_file = add_release(tmpfile, new_version, sparkle_signature) - upload_to_github(new_file, "christofmuc", "appcasts", "KnobKraft-Orm/appcast.xml", True) - release_notes = os.path.join("release_notes", f"{new_version}.md") - release_notes_as_html = convert_markdown_to_html(release_notes) - upload_to_github(release_notes_as_html.encode('utf-8'), "christofmuc", "appcasts", f"KnobKraft-Orm/{new_version}.html", False) + tmpfile = os.path.join(tmpdir, os.path.basename(config["appcast_path"])) + existing = ensure_local_appcast(config, tmpfile) + new_file = add_release(tmpfile, new_version, sparkle_signature, config) + appcast_sha = None + if existing: + appcast_sha = get_file_sha( + "christofmuc", "appcasts", config["appcast_path"], access_token + ) + upload_to_github( + new_file, "christofmuc", "appcasts", config["appcast_path"], appcast_sha + ) + + release_notes_md = os.path.join("release_notes", f"{new_version}.md") + release_notes_as_html = convert_markdown_to_html(release_notes_md).encode("utf-8") + release_notes_path = f"KnobKraft-Orm/{new_version}.html" + release_notes_sha = get_file_sha( + "christofmuc", "appcasts", release_notes_path, access_token + ) + upload_to_github( + release_notes_as_html, + "christofmuc", + "appcasts", + release_notes_path, + release_notes_sha, + ) + + +if __name__ == "__main__": + main()