From 7dcf411a8b23793aeab1ebdffd8de853ec480881 Mon Sep 17 00:00:00 2001 From: Pieter van der Meulen Date: Sat, 2 Aug 2025 20:55:24 +0200 Subject: [PATCH 1/2] Create pyinstaller .spec file for OSX onedir build - Dependencies are in the _internal directory. - Assets and other files required by Writingway must be in the application root, used a custom copy action to accomplish this as this seems impossible with standard pyinstaller spec configuraton. - Project files and settings are in the application root. This is not what a user would expect from a packaged application. --- .gitignore | 4 ++ .python-version | 1 + pyinstaller/Writingway | 21 ++++++ pyinstaller/Writingway_osx.spec | 122 ++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 .python-version create mode 100755 pyinstaller/Writingway create mode 100644 pyinstaller/Writingway_osx.spec diff --git a/.gitignore b/.gitignore index 6ef363a..a62b8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ Projects/ .DS_Store Thumbs.db *.bak" + +# Ignore pyinstaller directories +build +dist \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..74a091c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.x diff --git a/pyinstaller/Writingway b/pyinstaller/Writingway new file mode 100755 index 0000000..c5d0a84 --- /dev/null +++ b/pyinstaller/Writingway @@ -0,0 +1,21 @@ +#!/bin/bash + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Set additional required environment variables +export KMP_DUPLICATE_LIB_OK=TRUE + +echo "Starting Writingway from $SCRIPT_DIR. This can take a while..." +echo "" +echo "Notes:" +echo "- If you close this window Writingway will exit immediately" +echo "- Your projects are saved in $SCRIPT_DIR/Projects" +echo "" + +# Set the working directory so assets and configuration can be found. +cd "$SCRIPT_DIR" || exit 1 + +# Start the Writingway application +./main + diff --git a/pyinstaller/Writingway_osx.spec b/pyinstaller/Writingway_osx.spec new file mode 100644 index 0000000..852c6e8 --- /dev/null +++ b/pyinstaller/Writingway_osx.spec @@ -0,0 +1,122 @@ +# .spec file for use with pyinstaller. +# This creates a standalone distribution of Writingway with python and all dependencies included +# +# Use: +# 1. Install Writingway first (e.g. run setup_writingway.sh) +# 2. Activate the venv +# 3. Install pyinstaller: pip install pyinstaller +# 4. Run pyinstaller: pyinstaller Writingway.spec +# 5. The output is in the dist folder. +import shutil + +from PyInstaller.building.api import COLLECT +from PyInstaller.building.datastruct import Tree +from pathlib import Path +from PyInstaller.utils.hooks import collect_all +import os + +ApplicationName = 'Writingway' + +# Additional files to add to the _internal directory +datas = [ +] + +binaries = [] +hiddenimports = ['tiktoken_ext.openai_public', 'tiktoken_ext', 'pyaudio'] +tmp_ret = collect_all('cmudict') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['../main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +# Get the target_arch from the environment. For OSX valid options are "arm64", "x86_64" and "universal2" +target_arch=os.environ.get('TARGET_ARCH') +if target_arch is not None: + print(f"TARGET_ARCH={target_arch}") + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='main', # Name of the executable + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + codesign_identity=None, + entitlements_file=None, + # If the TARGET_ARCH environment variable is set, add a target_arch to this call to EXE() + + target_arch=target_arch + # contents_directory='_internal' +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=ApplicationName, +) + +# Files to add to the root (i.e. where the executable is) instead of the _internal directory +root_files = [ +# Project +'MyFirstProject_structure.json', +'project_settings.json', +'prompts_MyFirstProject.json', + +# App version +'version.json', + +# Documentation +'README.md', +'Writingway_Introduction.docx', +'Writingway_TLDR.docx', +] + +# Create a root_tree array of the files to add to the collection: [ (dest, source, DATA), ... ] +# Add the Writingway assets directory +root_tree = Tree('assets', 'assets') +# Add the startup script +root_tree += [('Writingway', 'pyinstaller/Writingway', 'DATA')] +# Add files from the Writingway root that we want to ship. We're in a subdirectory, so we need to add '../' +for f in root_files: + root_tree += [(f, f, 'DATA')] + +# I Can't find a way to let pyinstaller handle the copying, so copy the files manually. +dist_path = Path(DISTPATH) / ApplicationName + +for dest_name, source_path, _ in root_tree: + src_path = Path(source_path) + dst_path = dist_path / dest_name + + if src_path.exists(): + # Create destination directory if needed + dst_path.parent.mkdir(parents=True, exist_ok=True) + + if src_path.is_file(): + shutil.copy2(src_path, dst_path) + + print(f"Copied {src_path} to {dst_path}") + else: + print(f"Warning: File not found: {src_path}") From 262da2793d699a2ce3553ff383dc72d4e5fe33d6 Mon Sep 17 00:00:00 2001 From: Pieter van der Meulen Date: Sat, 2 Aug 2025 21:03:20 +0200 Subject: [PATCH 2/2] Add a github action to create an arm64 build and create a github release - Run manually for a snapshot build - Set a tag to trigger a release - Uses macports to provide the portaudio dependency for pyaudio - Adds installation instructions - Uses the softprops/action-gh-release. The action is configured so that when rerun with the same tag new assets are added to the release so packes for other platform can be added to the same release. --- .github/assets/release-instructions.md | 21 +++ .github/parameters/setup-macports-arm64.yaml | 8 + .github/workflows/build-osx-arm64.yml | 149 +++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 .github/assets/release-instructions.md create mode 100644 .github/parameters/setup-macports-arm64.yaml create mode 100644 .github/workflows/build-osx-arm64.yml diff --git a/.github/assets/release-instructions.md b/.github/assets/release-instructions.md new file mode 100644 index 0000000..16b90ab --- /dev/null +++ b/.github/assets/release-instructions.md @@ -0,0 +1,21 @@ +## macOS Installation Instructions + +**Important:** macOS will prevent this app from running due to quarantine restrictions. +Follow these steps after downloading: + +1. Open Terminal (found in Applications → Utilities) +2. Navigate to your Downloads folder: + ```bash + cd ~/Downloads + ``` +3. Remove the quarantine flag from the downloaded zip: + ```bash + xattr -d com.apple.quarantine macos-arm64.zip + ``` +4. Now you can unzip `macos-arm64.zip` in the Finder and move the `Writingway` folder where your want. +5. Start the application with the `Writingway` script in the `Writingway` folder. + +**Notes:** +- Removing the quarantine flag is required because the application is not signed. + Only proceed if you trust the site where you downloaded this file. +- Writeingway stores your data (Projects) and setting in the application's folder: `Writingway`. diff --git a/.github/parameters/setup-macports-arm64.yaml b/.github/parameters/setup-macports-arm64.yaml new file mode 100644 index 0000000..4f735cc --- /dev/null +++ b/.github/parameters/setup-macports-arm64.yaml @@ -0,0 +1,8 @@ +prefix: '/opt/local' +variants: + select: + # - universal +ports: + - name: portaudio + # select: [ universal ] + # deselect: [ cxx ] # Do not build C++ bindings diff --git a/.github/workflows/build-osx-arm64.yml b/.github/workflows/build-osx-arm64.yml new file mode 100644 index 0000000..5fec0a0 --- /dev/null +++ b/.github/workflows/build-osx-arm64.yml @@ -0,0 +1,149 @@ +# Build a onedir OSX release using pyinstaller + +name: Create OSX arm64 release + +on: + workflow_dispatch: + push: + tags: + - '*' + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Install macports to get portaudio + - name: Setup MacPorts (macOS) + id: setup-macports + uses: melusina-org/setup-macports@v1 + with: + # Specify packages to install + parameters: '.github/parameters/setup-macports-arm64.yaml' + # Allow the workflow to continue even if this fails + continue-on-error: true + + - name: Display MacPorts build log on failure + if: steps.setup-macports.outcome == 'failure' + run: | + echo "=== MacPorts build log ===" + cat $(port logfile portaudio) + exit 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: '.python-version' + + - name: Install Python dependencies using pip + run: | + # Install requirements using pip + # Add macports includes + export CPPFLAGS="-I/opt/local/include" + export LDFLAGS="-L/opt/local/lib" + + # universal2 build of portaudio is broken + # See: https://trac.macports.org/ticket/71481 + # See: https://github.com/PortAudio/portaudio/issues/994 + # export ARCHFLAGS="-arch arm64 -arch x86_64" # Enable universal builds + export ARCHFLAGS="-arch arm64" + + pip install -r requirements.txt + working-directory: ./ + + - name: Install spaCy English model + run: | + python -m spacy download en_core_web_sm + working-directory: ./ + + - name: install beautifulsoup4 + run: | + python -m pip install beautifulsoup4 + working-directory: ./ + + - name: Install PyInstaller + run: | + pip install pyinstaller + working-directory: ./ + + - name: Build executable using pyinstaller + run: | + TARGET_ARCH=arm64 pyinstaller pyinstaller/Writingway_osx.spec + working-directory: ./ + + # Create Artifact associated with this run that can be downloaded later + #- name: Create Artifact + # uses: actions/upload-artifact@v4 + # with: + # name: macos-arm64 + # path: dist/ + + # Determine release information + - name: Set release info + id: release_info + run: | + # Release instructions to add the body of the github release + RELEASE_INSTRUCTIONS=$(cat .github/assets/release-instructions.md) + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + # Tag-triggered release + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "release_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "prerelease=false" >> $GITHUB_OUTPUT + + # Use multiline format for body output + { + echo "body<> $GITHUB_OUTPUT + + else + # Manual/snapshot release + BRANCH_NAME=${GITHUB_REF#refs/heads/} + DATE=$(date +'%Y%m%d-%H%M%S') + SHORT_SHA=${GITHUB_SHA:0:7} + SNAPSHOT_TAG="snapshot-${BRANCH_NAME}-${DATE}-${SHORT_SHA}" + + echo "release_name=Snapshot ${BRANCH_NAME} (${DATE})" >> $GITHUB_OUTPUT + echo "tag_name=${SNAPSHOT_TAG}" >> $GITHUB_OUTPUT + echo "prerelease=true" >> $GITHUB_OUTPUT + + # Use multiline format for body output + { + echo "body<> $GITHUB_OUTPUT + fi + + # Create ZIP archive of the build + - name: Create release archive + run: | + cd dist + zip -r ../macos-arm64.zip . + cd .. + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.release_info.outputs.tag_name }} + name: ${{ steps.release_info.outputs.release_name }} + body: ${{ steps.release_info.outputs.body }} + prerelease: ${{ steps.release_info.outputs.prerelease }} + files: | + macos-arm64.zip + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: List files in dist folder + run: ls -R ./dist/ \ No newline at end of file