Skip to content

Commit b025b81

Browse files
committed
Build for x86_64 + ARM64 Linux, fix Update self-swap on Linux
- build.yml: Linux now builds on both a GitHub-hosted x86_64 runner and the self-hosted ARM64 Pi, instead of only the Pi. Matrix steps unified via shared fields (pyinstaller path, add-data separator, exe suffix) instead of duplicating Windows/Linux steps per target. - backend/paths.py: add release_asset_name(), which tags Linux release assets with the CPU architecture (e.g. "Text2Pen-linux-arm64") since a release now holds both architectures' binaries side by side. exe_name() is unchanged and still names the local installed file. - Update.py / Installer.py: use release_asset_name() for download URLs and hash lookups, so each machine fetches the binary matching its own architecture. - release.yml: renames each Linux binary to its arch-tagged asset name before publishing; now uploads 9 assets total (3 targets x 3 platform/arch combos) instead of 6. - Fix: Update.py staged its own replacement as "Update-newest" but only ever swapped it into place via Text2Pen.py's Windows-only handoff logic. On Linux nothing completed that swap, so Update would re-download "the newest" version every run without ever actually becoming current. Linux can safely replace its own running executable (unlike Windows), so Update.py now does that swap itself immediately when IS_LINUX.
1 parent 882243b commit b025b81

5 files changed

Lines changed: 132 additions & 85 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
name: Build executables
22

33
# Workflow 1 of 2: builds the three PyInstaller executables (Text2Pen, Update,
4-
# Installer) for Windows and Linux and uploads them as separate build
5-
# artifacts. It does not touch GitHub Releases — that's workflow 2, which will
6-
# take these artifacts and publish them as individual release assets, the
7-
# same way existing releases already do (so in-place updates via Update.exe
8-
# keep working).
4+
# Installer) for Windows and every supported Linux architecture, and uploads
5+
# them as separate build artifacts. It does not touch GitHub Releases —
6+
# that's workflow 2, which will take these artifacts and publish them as
7+
# individual release assets, the same way existing releases already do (so
8+
# in-place updates via Update.exe keep working).
99
#
1010
# `workflow_call` is included so a future release workflow can trigger a
1111
# fresh build directly instead of reusing old artifacts.
@@ -32,13 +32,27 @@ jobs:
3232
include:
3333
- os: windows-latest
3434
label: windows
35-
# Self-hosted (a Raspberry Pi) rather than a GitHub-hosted runner.
36-
# NOTE: this makes the Linux build an ARM binary, not x86_64 — it
37-
# will only run on ARM Linux (e.g. other Pis), not typical x86_64
38-
# desktops. If both are needed, add back a GitHub-hosted
39-
# `ubuntu-22.04` leg alongside this one.
40-
- os: [self-hosted, Linux]
41-
label: linux
35+
hosted: github
36+
pyinstaller: pyinstaller
37+
add_data_sep: ";"
38+
exe_suffix: ".exe"
39+
# GitHub-hosted, so a fresh x86_64 VM every run: the common case,
40+
# what almost every Linux desktop needs.
41+
- os: ubuntu-22.04
42+
label: linux-x86_64
43+
hosted: github
44+
pyinstaller: pyinstaller
45+
add_data_sep: ":"
46+
exe_suffix: ""
47+
# Self-hosted (a Raspberry Pi) — the only realistic way to get an
48+
# ARM64 build without cross-compiling. NOTE: this binary only runs
49+
# on ARM64 Linux (other Pis, ARM SBCs), never on x86_64 desktops.
50+
- os: [self-hosted, Linux, ARM64]
51+
label: linux-arm64
52+
hosted: self
53+
pyinstaller: venv/bin/pyinstaller
54+
add_data_sep: ":"
55+
exe_suffix: ""
4256
runs-on: ${{ matrix.os }}
4357
steps:
4458
- uses: actions/checkout@v4
@@ -48,13 +62,26 @@ jobs:
4862
# runner is a persistent machine with its own system Python already
4963
# set up (see the apt note below), unlike an ephemeral GitHub-hosted VM.
5064
- uses: actions/setup-python@v5
51-
if: matrix.label == 'windows'
65+
if: matrix.hosted == 'github'
5266
with:
5367
python-version: "3.12"
5468

55-
# Deliberately no apt-get step for Linux here: the self-hosted runner
56-
# is a persistent machine, so system packages only need installing
57-
# once, by hand, not on every run:
69+
# pywebview renders through WebKitGTK on Linux, and the portal
70+
# screen-capture code (backend/portal_capture.py) needs GLib/D-Bus/
71+
# GStreamer bindings. None of this is pip-installable; it has to come
72+
# from apt, on the same interpreter pip installs into.
73+
- name: Install Linux system dependencies
74+
if: matrix.label == 'linux-x86_64'
75+
run: |
76+
sudo apt-get update
77+
sudo apt-get install -y \
78+
python3-gi gir1.2-webkit2-4.1 python3-tk \
79+
gir1.2-gst-plugins-base-1.0 gstreamer1.0-plugins-base \
80+
gstreamer1.0-plugins-good gstreamer1.0-pipewire \
81+
python3-dbus libgirepository1.0-dev
82+
83+
# The self-hosted runner is persistent, not an ephemeral VM: install
84+
# these once by hand instead, so every run doesn't re-apt-get them:
5885
# sudo apt-get install -y python3-gi gir1.2-webkit2-4.1 python3-tk \
5986
# gir1.2-gst-plugins-base-1.0 gstreamer1.0-plugins-base \
6087
# gstreamer1.0-plugins-good gstreamer1.0-pipewire \
@@ -63,64 +90,49 @@ jobs:
6390
# (the last three are for pip to build python-uinput/pynput from
6491
# source on ARM, where prebuilt wheels are less common.)
6592

66-
- name: Install Python dependencies (Windows)
67-
if: matrix.label == 'windows'
93+
- name: Install Python dependencies (GitHub-hosted)
94+
if: matrix.hosted == 'github'
6895
run: |
6996
python -m pip install --upgrade pip
7097
pip install -r requirements.txt pyinstaller
7198
72-
# --system-site-packages: pywebview's WebKitGTK bindings and the
73-
# portal-capture GLib/GStreamer bindings only exist as apt packages
74-
# (see above), not on PyPI, so the venv needs to see the system ones.
75-
- name: Install Python dependencies (Linux)
76-
if: matrix.label == 'linux'
99+
# --system-site-packages: the apt packages above (WebKitGTK/GStreamer
100+
# bindings) only exist system-wide, not on PyPI, so the venv needs to
101+
# see them. Recreated every run for a clean, reproducible build.
102+
- name: Install Python dependencies (self-hosted)
103+
if: matrix.hosted == 'self'
77104
run: |
78105
rm -rf venv
79106
python3 -m venv --system-site-packages venv
80107
venv/bin/pip install --upgrade pip
81108
venv/bin/pip install -r requirements.txt pyinstaller
82109
83-
- name: Build Text2Pen (Windows)
84-
if: matrix.label == 'windows'
85-
run: pyinstaller --onefile --noconsole --add-data "webui;webui" --name Text2Pen Text2Pen.py
86-
87-
- name: Build Text2Pen (Linux)
88-
if: matrix.label == 'linux'
89-
run: venv/bin/pyinstaller --onefile --noconsole --add-data "webui:webui" --name Text2Pen Text2Pen.py
90-
91-
- name: Build Update (Windows)
92-
if: matrix.label == 'windows'
93-
run: pyinstaller --onefile --noconsole --name Update Update.py
94-
95-
- name: Build Update (Linux)
96-
if: matrix.label == 'linux'
97-
run: venv/bin/pyinstaller --onefile --noconsole --name Update Update.py
110+
- name: Build Text2Pen
111+
run: ${{ matrix.pyinstaller }} --onefile --noconsole --add-data "webui${{ matrix.add_data_sep }}webui" --name Text2Pen Text2Pen.py
98112

99-
- name: Build Installer (Windows)
100-
if: matrix.label == 'windows'
101-
run: pyinstaller --onefile --noconsole --name Installer Installer.py
113+
- name: Build Update
114+
run: ${{ matrix.pyinstaller }} --onefile --noconsole --name Update Update.py
102115

103-
- name: Build Installer (Linux)
104-
if: matrix.label == 'linux'
105-
run: venv/bin/pyinstaller --onefile --noconsole --name Installer Installer.py
116+
- name: Build Installer
117+
run: ${{ matrix.pyinstaller }} --onefile --noconsole --name Installer Installer.py
106118

107119
- name: Upload Text2Pen artifact
108120
uses: actions/upload-artifact@v4
109121
with:
110122
name: Text2Pen-${{ matrix.label }}${{ inputs.version != '' && format('-{0}', inputs.version) || '' }}
111-
path: dist/Text2Pen${{ matrix.label == 'windows' && '.exe' || '' }}
123+
path: dist/Text2Pen${{ matrix.exe_suffix }}
112124
if-no-files-found: error
113125

114126
- name: Upload Update artifact
115127
uses: actions/upload-artifact@v4
116128
with:
117129
name: Update-${{ matrix.label }}${{ inputs.version != '' && format('-{0}', inputs.version) || '' }}
118-
path: dist/Update${{ matrix.label == 'windows' && '.exe' || '' }}
130+
path: dist/Update${{ matrix.exe_suffix }}
119131
if-no-files-found: error
120132

121133
- name: Upload Installer artifact
122134
uses: actions/upload-artifact@v4
123135
with:
124136
name: Installer-${{ matrix.label }}${{ inputs.version != '' && format('-{0}', inputs.version) || '' }}
125-
path: dist/Installer${{ matrix.label == 'windows' && '.exe' || '' }}
137+
path: dist/Installer${{ matrix.exe_suffix }}
126138
if-no-files-found: error

‎.github/workflows/release.yml‎

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
name: Create release
2-
#
32

43
# Workflow 2 of 2: runs build.yml for a fresh set of executables, renames
54
# them to their final release filenames, and publishes a new GitHub Release
@@ -62,32 +61,37 @@ jobs:
6261
# find, not a hardcoded nested path: upload-artifact's exact internal
6362
# layout for a single-file `path:` has shifted across versions, so this
6463
# locates each binary by name inside its artifact folder instead of
65-
# assuming where under it it landed.
64+
# assuming where under it it landed. The Linux binaries also get
65+
# renamed here: inside the artifact they're just "Text2Pen" etc. (see
66+
# build.yml), but a release holds x86_64 and arm64 side by side, so
67+
# the uploaded asset name has to disambiguate — matches
68+
# backend/paths.py's release_asset_name(), which is what Update.py and
69+
# Installer.py use to pick the right one back up.
6670
- name: Lay out release assets with their final names
6771
run: |
6872
set -euo pipefail
6973
mkdir -p release
7074
7175
copy_asset() {
7276
local artifact_dir="artifacts/$1"
73-
local filename="$2"
77+
local src_filename="$2"
78+
local dest_filename="$3"
7479
local found
75-
found=$(find "$artifact_dir" -type f -name "$filename" | head -n1)
80+
found=$(find "$artifact_dir" -type f -name "$src_filename" | head -n1)
7681
if [ -z "$found" ]; then
77-
echo "::error::Could not find $filename in $artifact_dir"
82+
echo "::error::Could not find $src_filename in $artifact_dir"
7883
exit 1
7984
fi
80-
cp "$found" "release/$filename"
85+
cp "$found" "release/$dest_filename"
8186
}
8287
83-
copy_asset "Text2Pen-windows-${{ inputs.version }}" "Text2Pen.exe"
84-
copy_asset "Update-windows-${{ inputs.version }}" "Update.exe"
85-
copy_asset "Installer-windows-${{ inputs.version }}" "Installer.exe"
86-
copy_asset "Text2Pen-linux-${{ inputs.version }}" "Text2Pen"
87-
copy_asset "Update-linux-${{ inputs.version }}" "Update"
88-
copy_asset "Installer-linux-${{ inputs.version }}" "Installer"
88+
for name in Text2Pen Update Installer; do
89+
copy_asset "$name-windows-${{ inputs.version }}" "$name.exe" "$name.exe"
90+
copy_asset "$name-linux-x86_64-${{ inputs.version }}" "$name" "$name-linux-x86_64"
91+
copy_asset "$name-linux-arm64-${{ inputs.version }}" "$name" "$name-linux-arm64"
92+
done
8993
90-
chmod +x release/Text2Pen release/Update release/Installer
94+
chmod +x release/*-linux-x86_64 release/*-linux-arm64
9195
ls -la release/
9296
9397
- name: Create GitHub release
@@ -97,10 +101,4 @@ jobs:
97101
name: ${{ inputs.version }}
98102
body: ${{ inputs.release_notes }}
99103
prerelease: ${{ inputs.prerelease }}
100-
files: |
101-
release/Text2Pen.exe
102-
release/Update.exe
103-
release/Installer.exe
104-
release/Text2Pen
105-
release/Update
106-
release/Installer
104+
files: release/*

‎Installer.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,26 @@
55

66
import requests
77

8-
from backend.paths import APP_DATA_DIR, IS_LINUX, IS_WINDOWS, ensure_app_data_dir, exe_name
8+
from backend.paths import (APP_DATA_DIR, IS_LINUX, IS_WINDOWS, ensure_app_data_dir,
9+
exe_name, release_asset_name)
910

1011
if IS_WINDOWS:
1112
import win32com.client
1213

1314
APP_NAME = "Text2Pen"
1415

16+
# Local filenames — no CPU-architecture tag; that only matters for picking
17+
# the right download, below.
1518
INSTALL_DIR = APP_DATA_DIR
1619
EXE_NAME = exe_name("Text2Pen")
1720
UPDATE_NAME = exe_name("Update")
1821
EXE_PATH = os.path.join(INSTALL_DIR, EXE_NAME)
1922
UPDATE_PATH = os.path.join(INSTALL_DIR, UPDATE_NAME)
2023

21-
DOWNLOAD_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{EXE_NAME}"
22-
UPDATE_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{UPDATE_NAME}"
24+
# Release asset names are architecture-specific on Linux (a release holds an
25+
# x86_64 and an arm64 binary side by side).
26+
DOWNLOAD_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{release_asset_name('Text2Pen')}"
27+
UPDATE_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{release_asset_name('Update')}"
2328

2429
if IS_WINDOWS:
2530
STARTUP_DIR = os.path.join(

‎Update.py‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import io
99
import hashlib
1010

11-
from backend.paths import APP_DATA_DIR, IS_LINUX, exe_name
11+
from backend.paths import APP_DATA_DIR, IS_LINUX, exe_name, release_asset_name
1212

1313
APP_NAME = "Text2Pen"
1414

15-
# Installation paths
15+
# Installation paths (local filenames — no CPU-architecture tag; that only
16+
# matters for picking the right download, below)
1617
INSTALL_DIR = APP_DATA_DIR
1718
TEXT2PEN_NAME = exe_name("Text2Pen")
1819
UPDATE_NAME = exe_name("Update")
@@ -22,9 +23,12 @@
2223
#Temp paths
2324
UPDATE_TEMP = UPDATE_PATH + "-newest"
2425

25-
# URLs
26-
TEXT2PEN_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{TEXT2PEN_NAME}"
27-
UPDATE_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{UPDATE_NAME}"
26+
# Release asset names (architecture-specific on Linux — a release holds an
27+
# x86_64 and an arm64 binary side by side) and the URLs/hash keys built from them
28+
TEXT2PEN_ASSET = release_asset_name("Text2Pen")
29+
UPDATE_ASSET = release_asset_name("Update")
30+
TEXT2PEN_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{TEXT2PEN_ASSET}"
31+
UPDATE_URL = f"https://github.com/pythonIsFast/Text2Pen/releases/latest/download/{UPDATE_ASSET}"
2832

2933
class UpdateApp():
3034
def __init__(self, root):
@@ -50,7 +54,7 @@ def main(self):
5054
print("Checking GitHub release...")
5155
hashes = self.get_release_hashes()
5256

53-
github_hash = hashes[TEXT2PEN_NAME]
57+
github_hash = hashes[TEXT2PEN_ASSET]
5458

5559
if os.path.exists(TEXT2PEN_PATH):
5660
local_hash = self.sha256_file(TEXT2PEN_PATH)
@@ -65,14 +69,14 @@ def main(self):
6569
new_hash = self.sha256_bytes(new_data)
6670

6771
if new_hash != github_hash:
68-
raise ValueError(f"Downloaded {TEXT2PEN_NAME} failed SHA256 verification")
72+
raise ValueError(f"Downloaded {TEXT2PEN_ASSET} failed SHA256 verification")
6973
else:
7074
self.status_label.config(text="Downloading Text2Pen...")
7175
new_data = self.download_file_ram(TEXT2PEN_URL)
7276
new_hash = self.sha256_bytes(new_data)
7377

7478
if new_hash != github_hash:
75-
raise ValueError(f"Downloaded {TEXT2PEN_NAME} failed SHA256 verification")
79+
raise ValueError(f"Downloaded {TEXT2PEN_ASSET} failed SHA256 verification")
7680

7781
if os.path.exists(TEXT2PEN_PATH):
7882
if new_data is not None:
@@ -85,7 +89,7 @@ def main(self):
8589
self.write_file_once(temp_path, new_data)
8690
self.replace_file(temp_path, TEXT2PEN_PATH)
8791

88-
github_updater_hash = hashes[UPDATE_NAME]
92+
github_updater_hash = hashes[UPDATE_ASSET]
8993

9094
if os.path.exists(UPDATE_PATH):
9195
local_updater_hash = self.sha256_file(UPDATE_PATH)
@@ -99,21 +103,31 @@ def main(self):
99103
new_updater_hash = self.sha256_bytes(updater_data)
100104

101105
if new_updater_hash != github_updater_hash:
102-
raise ValueError(f"Downloaded {UPDATE_NAME} failed SHA256 verification")
106+
raise ValueError(f"Downloaded {UPDATE_ASSET} failed SHA256 verification")
103107
else:
104108
self.status_label.config(text="Downloading Updater...")
105109
updater_data = self.download_file_ram(UPDATE_URL)
106110

107111
new_updater_hash = self.sha256_bytes(updater_data)
108112

109113
if new_updater_hash != github_updater_hash:
110-
raise ValueError(f"Downloaded {UPDATE_NAME} failed SHA256 verification")
114+
raise ValueError(f"Downloaded {UPDATE_ASSET} failed SHA256 verification")
111115

112116
if os.path.exists(UPDATE_PATH):
113117
if updater_data is not None:
114118
self.write_file_once(UPDATE_TEMP, updater_data)
119+
# Windows locks a running .exe, so the swap has to wait
120+
# for Text2Pen.py's handoff logic on next launch. Linux
121+
# has no such lock — a process can replace its own
122+
# file while running — so finish the swap here directly;
123+
# otherwise Update would re-download "the newest" every
124+
# run forever without ever actually becoming current.
125+
if IS_LINUX:
126+
self.replace_file(UPDATE_TEMP, UPDATE_PATH)
115127
else:
116128
self.write_file_once(UPDATE_TEMP, updater_data)
129+
if IS_LINUX:
130+
self.replace_file(UPDATE_TEMP, UPDATE_PATH)
117131

118132
self.status_label.config(text="Update finished!")
119133
self.progress_var.set(100)

‎backend/paths.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,31 @@ def get_app_data_dir():
3636

3737

3838
def exe_name(base):
39-
"""`base` with the platform-appropriate executable suffix: Installer.py,
40-
Update.py and Text2Pen.py all ship as "<name>.exe" on Windows and a
41-
plain, extension-less "<name>" on Linux (matches the release asset
42-
names uploaded by .github/workflows/build.yml)."""
39+
"""`base` with the platform-appropriate executable suffix, for the LOCAL
40+
on-disk filename: Installer.py, Update.py and Text2Pen.py all install as
41+
"<name>.exe" on Windows and a plain, extension-less "<name>" on Linux,
42+
regardless of CPU architecture. For the GitHub release asset name (which
43+
does vary by architecture, since one release holds every arch's binary
44+
side by side), see release_asset_name()."""
4345
return f"{base}.exe" if IS_WINDOWS else base
4446

4547

48+
_LINUX_ARCH_TAGS = {"x86_64": "x86_64", "aarch64": "arm64", "arm64": "arm64"}
49+
50+
51+
def release_asset_name(base):
52+
"""`base`'s filename as uploaded to a GitHub Release. Matches the
53+
`label` values in .github/workflows/build.yml's matrix, e.g.
54+
"Text2Pen-linux-x86_64" or "Text2Pen-linux-arm64" — used to build
55+
download URLs and to look up the right SHA256 from the release's asset
56+
list (see Update.py, Installer.py)."""
57+
if IS_WINDOWS:
58+
return f"{base}.exe"
59+
arch = platform.machine()
60+
arch_tag = _LINUX_ARCH_TAGS.get(arch, arch)
61+
return f"{base}-linux-{arch_tag}"
62+
63+
4664
APP_DATA_DIR = get_app_data_dir()
4765

4866
LETTER_DB_FILE = os.path.join(APP_DATA_DIR, "letter_db.json")

0 commit comments

Comments
 (0)