From 5f5524518499ba5bfc0cfa211de736771d4c66b6 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sat, 5 Sep 2026 15:39:45 +0300 Subject: [PATCH] fix linux build --- .github/workflows/build.yml | 2 ++ linux.py | 40 ++++++++++++++++++++++++++ packaging/linux.spec | 56 ++++++++++++++++++++++++++----------- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 415c8a3..2b36f0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -395,6 +395,8 @@ jobs: python3-venv \ python3-dev \ python3-gi \ + python3-gi-cairo \ + gir1.2-appindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \ python3-tk diff --git a/linux.py b/linux.py index 0ee6129..d5149f7 100644 --- a/linux.py +++ b/linux.py @@ -310,7 +310,47 @@ def run_tray() -> None: log.info("Tray app exited") +def _smoke_test() -> None: + """Exercise the frozen GUI stack without starting the proxy or writing config.""" + import gi + + gi.require_version('AppIndicator3', '0.1') + gi.require_version('AyatanaAppIndicator3', '0.1') + from gi.repository import AppIndicator3, AyatanaAppIndicator3 + + # Namespace imports alone do not resolve app_indicator_new: call it too. + indicators = [ + namespace.Indicator.new( + 'tg-ws-proxy-smoke-test', '', namespace.IndicatorCategory.APPLICATION_STATUS, + ) + for namespace in (AppIndicator3, AyatanaAppIndicator3) + ] + icon = pystray.Icon('tg-ws-proxy-smoke-test', Image.new('RGB', (16, 16))) + root = ctk.CTk() + root.withdraw() + root.update_idletasks() + root.destroy() + + # A successful constructor on the build host can hide missing bundled + # libraries. Check where the dynamic loader actually obtained them. + bundle_dir = os.path.realpath(sys._MEIPASS) + os.sep + prefixes = ('libglib-', 'libgobject-', 'libgio-', 'libgtk-', + 'libappindicator', 'libayatana-', 'libdbusmenu-') + with open('/proc/self/maps', encoding='utf-8') as maps: + paths = {line.split(maxsplit=5)[-1].strip() for line in maps} + for path in sorted(paths): + if os.path.basename(path).startswith(prefixes): + if not os.path.realpath(path).startswith(bundle_dir): + raise RuntimeError('GUI library loaded outside the bundle: ' + path) + print(path) + assert icon is not None and all(indicators) + print('Linux bundle smoke test passed') + + def main() -> None: + if sys.argv[1:] == ['--smoke-test']: + _smoke_test() + return if not acquire_lock(): _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0])) return diff --git a/packaging/linux.spec b/packaging/linux.spec index 31a9b3f..e5237b7 100644 --- a/packaging/linux.spec +++ b/packaging/linux.spec @@ -1,10 +1,9 @@ # -*- mode: python ; coding: utf-8 -*- -import sys import os import glob -from PyInstaller.utils.hooks import collect_submodules, collect_data_files +from PyInstaller.utils.hooks import collect_data_files block_cipher = None @@ -15,21 +14,18 @@ certifi_datas = collect_data_files('certifi') _i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n') -# Collect gi (PyGObject) submodules and data so pystray._appindicator works -gi_hiddenimports = collect_submodules('gi') -gi_datas = collect_data_files('gi') - -# Collect GObject typelib files from the system -typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0') -typelib_datas = [] -for d in typelib_dirs: - typelib_datas.append((d, 'gi_typelibs')) +appindicator_binaries = [ + (path, '.') + for pattern in ('/usr/lib/*/libappindicator3.so.1', + '/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1') + for path in glob.glob(pattern) +] a = Analysis( [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], pathex=[], - binaries=[], - datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas, + binaries=appindicator_binaries, + datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas, hiddenimports=[ 'pystray._appindicator', 'PIL._tkinter_finder', @@ -39,15 +35,22 @@ a = Analysis( 'cryptography.hazmat.primitives.ciphers.modes', 'cryptography.hazmat.backends.openssl', 'gi', - '_gi', 'gi.repository.GLib', 'gi.repository.GObject', 'gi.repository.Gtk', 'gi.repository.Gdk', + 'gi.repository.DBus', + 'gi.repository.AppIndicator3', 'gi.repository.AyatanaAppIndicator3', - ] + gi_hiddenimports, + ], hookspath=[], - hooksconfig={}, + hooksconfig={ + 'gi': { + 'icons': [], + 'themes': [], + 'languages': ['en', 'ru'], + }, + }, runtime_hooks=[], excludes=[ 'PIL._avif', @@ -58,6 +61,27 @@ a = Analysis( cipher=block_cipher, ) +_required_libraries = { + 'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0', + 'libgtk-3.so.0', 'libappindicator3.so.1', + 'libayatana-appindicator3.so.1', +} +_required_typelibs = { + 'AppIndicator3-0.1.typelib', 'AyatanaAppIndicator3-0.1.typelib', 'DBus-1.0.typelib', +} +_bundled_libraries = { + os.path.basename(name) + for name, _, kind in a.binaries + a.datas + if kind in ('BINARY', 'SYMLINK') +} +_missing = ( + _required_libraries - _bundled_libraries +) | ( + _required_typelibs - {os.path.basename(name) for name, _, _ in a.datas} +) +if _missing: + raise RuntimeError('Incomplete Linux GI bundle: ' + ', '.join(sorted(_missing))) + _PIL_EXCLUDE_PYDS = { '_avif', '_webp', '_imagingtk', 'FpxImagePlugin', 'MicImagePlugin',