From 5890b52da67ce1c9d33842b9eee88b451d290bdc Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 18:43:15 +0200 Subject: [PATCH] Bundle libsecret for systems without it; first launch keeps Flet's message off the client path Co-Authored-By: Claude Opus 5.5 --- Main.py | 18 ++++++++++++ Packaging/BuildAppImage.sh | 9 ++++++ Packaging/RestoreLibraryPath.py | 47 +++++++++++++++++++++++++++++++ Programs/FletIdentity.py | 49 ++++++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 Packaging/RestoreLibraryPath.py diff --git a/Main.py b/Main.py index 0045e3b..27106c0 100644 --- a/Main.py +++ b/Main.py @@ -38,6 +38,23 @@ else: _MenuLabel = "Start menu" if IsWindows else "app menu" +def _KeepFallbackLibraries() -> None: + """Linux AppImage: copy the bundled libsecret (and what it needs) to + ~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet + client of every installed program on systems without it.""" + Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs" + if IsWindows or not Bundled.is_dir(): + return + Kept = DataDir / "Libraries" + try: + Kept.mkdir(parents=True, exist_ok=True) + for Library in Bundled.iterdir(): + if not (Kept / Library.name).exists() or (Kept / Library.name).stat().st_size != Library.stat().st_size: + shutil.copy2(Library, Kept / Library.name) + except OSError: + pass # only a fallback + + def _LoadFletIdentity(): # Lives in Programs/ (bundled as data) because installed programs get a # copy of the same file next to them. @@ -324,6 +341,7 @@ def Main(Page: ft.Page) -> None: if __name__ == "__main__": + _KeepFallbackLibraries() # also before a command (an install from the command line) if len(sys.argv) > 1: if IsWindows and getattr(sys, "frozen", False): # The .exe is built windowed (no console of its own); write the diff --git a/Packaging/BuildAppImage.sh b/Packaging/BuildAppImage.sh index 48c68ea..4344de6 100755 --- a/Packaging/BuildAppImage.sh +++ b/Packaging/BuildAppImage.sh @@ -18,8 +18,17 @@ podman run --rm --security-opt label=disable -v "$PWD:/src" -w /src \ quay.io/pypa/manylinux_2_28_x86_64 bash -euo pipefail -c ' uv venv --quiet --allow-existing --managed-python --python 3.14 build/Container/venv uv pip install --quiet --python build/Container/venv -r requirements.txt flet-desktop pyinstaller + # The Flet window needs libsecret, which some systems lack: bundled as a + # fallback (RestoreLibraryPath.py uses only what the system lacks). + dnf -y -q install libsecret >/dev/null + mkdir -p build/FallbackLibs + for Library in libsecret-1.so.0 libgcrypt.so.20 libgpg-error.so.0; do cp -L /usr/lib64/$Library build/FallbackLibs/; done + # Each finds the others beside it, wherever they are put (a client lib folder). + patchelf --set-rpath "\$ORIGIN" build/FallbackLibs/libsecret-1.so.0 build/FallbackLibs/libgcrypt.so.20 build/Container/venv/bin/pyinstaller --noconfirm --clean --log-level WARN \ --onefile --windowed --name SaraSuite --collect-data flet \ + --runtime-hook "$PWD/Packaging/RestoreLibraryPath.py" \ + --add-data "$PWD/build/FallbackLibs:FallbackLibs" \ --add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \ --distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \ "$PWD/Main.py"' diff --git a/Packaging/RestoreLibraryPath.py b/Packaging/RestoreLibraryPath.py new file mode 100644 index 0000000..3be7522 --- /dev/null +++ b/Packaging/RestoreLibraryPath.py @@ -0,0 +1,47 @@ +# PyInstaller runtime hook. The frozen app runs with LD_LIBRARY_PATH pointing +# at its bundled libraries, and programs it starts inherit that: Flet's +# desktop client then loads the bundled (older) libstdc++ instead of the +# system's and won't start ("GLIBCXX_3.4.29 not found"). Give them the +# original value back; the app itself has already loaded what it needs. +# +# Flet's window also needs libsecret, which isn't part of GTK and some +# systems don't install ("libsecret-1.so.0: cannot open shared object file"). +# The build bundles it in FallbackLibs, with libgcrypt and libgpg-error, which +# it needs; the ones this system lacks (only those) are offered to what the +# app starts, after the original path. +import os +import subprocess +import sys + + +def SystemHas(Name): + """Whether the system itself has a library: asked of its linker cache + and standard folders, not dlopen, which would also find this bundle's + own copies (PyInstaller adds what bundled libraries need).""" + for Folder in ("/usr/lib64", "/usr/lib", "/lib64", "/lib", "/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"): + if os.path.exists(os.path.join(Folder, Name)): + return True + try: + Cache = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}).stdout + except (OSError, subprocess.SubprocessError): + return False + return any(Line.strip().startswith(Name + " ") for Line in Cache.splitlines()) + +Paths = [os.environ["LD_LIBRARY_PATH_ORIG"]] if os.environ.get("LD_LIBRARY_PATH_ORIG") else [] + +Bundle = getattr(sys, "_MEIPASS", "") +Fallback = os.path.join(Bundle, "FallbackLibs") +if sys.platform.startswith("linux") and os.path.isdir(Fallback): + InUse = os.path.join(Bundle, "FallbackLibsInUse") + os.makedirs(InUse, exist_ok=True) + for Name in ("libgpg-error.so.0", "libgcrypt.so.20", "libsecret-1.so.0"): + if not SystemHas(Name) and not os.path.lexists(os.path.join(InUse, Name)): + os.symlink(os.path.join(Fallback, Name), os.path.join(InUse, Name)) + if os.listdir(InUse): + Paths.append(InUse) + +if Paths: + os.environ["LD_LIBRARY_PATH"] = os.pathsep.join(Paths) +else: + os.environ.pop("LD_LIBRARY_PATH", None) diff --git a/Programs/FletIdentity.py b/Programs/FletIdentity.py index ac95711..4a27e3c 100644 --- a/Programs/FletIdentity.py +++ b/Programs/FletIdentity.py @@ -18,13 +18,47 @@ Used two ways: case Flet just uses its normal client. """ +import os +import shutil import sys from pathlib import Path OriginalId = b"com.appveyor.flet" +# Flet's window needs libsecret, which isn't part of GTK and some systems don't +# install. SaraSuite keeps copies of it and what it needs (from its AppImage) +# here; MissingLibraries puts the ones this system lacks beside the client. +LibrariesDir = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "SaraSuite" / "Libraries" +FallbackLibraries = ("libgpg-error.so.0", "libgcrypt.so.20", "libsecret-1.so.0") CopiesDir = Path.home() / ".cache" / "SaraSuite" / "FletClient" +def _SystemHas(Name: str) -> bool: + """Whether the system has a library (its standard folders and linker + cache; the same check as the AppImages' RestoreLibraryPath.py).""" + import subprocess + + for Folder in ("/usr/lib64", "/usr/lib", "/lib64", "/lib", "/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"): + if (Path(Folder) / Name).exists(): + return True + try: + Cache = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}).stdout + except (OSError, subprocess.SubprocessError): + return False + return any(Line.strip().startswith(Name + " ") for Line in Cache.splitlines()) + + +def AddMissingLibraries(ClientLib: Path) -> None: + """Copies SaraSuite's fallback libraries the system doesn't have into the + client's lib folder (it looks there first: its RUNPATH is $ORIGIN/lib).""" + if not sys.platform.startswith("linux") or not LibrariesDir.is_dir() or not ClientLib.is_dir(): + return + for Name in FallbackLibraries: + Source = LibrariesDir / Name + if Source.is_file() and not (ClientLib / Name).exists() and not _SystemHas(Name): + shutil.copy2(Source, ClientLib / Name) + + def ClientFor(AppId: str) -> str: """Folder of a client copy whose app ID is AppId ("" if not possible). AppId needs a dot (a GTK rule) and at most 17 characters, the length of @@ -36,6 +70,7 @@ def ClientFor(AppId: str) -> str: from flet_desktop import ensure_client_cached # the program's own Flet version Original = Path(ensure_client_cached()) / "flet" + AddMissingLibraries(Original / "lib") Binary = (Original / "flet").read_bytes() Copy = CopiesDir / f"{Original.parent.name}-{AppId}" / "flet" Patched = Binary.replace( @@ -64,4 +99,16 @@ def ClientFor(AppId: str) -> str: if __name__ == "__main__": - print(ClientFor(sys.argv[1]) if len(sys.argv) > 1 else "") + # Only the folder goes to stdout, which the launcher reads: Flet's own + # messages ("Preparing Flet ... for the first use", written straight to + # the file descriptor) go to stderr. + sys.stdout.flush() + Stdout = os.dup(1) + os.dup2(2, 1) + try: + Folder = ClientFor(sys.argv[1]) if len(sys.argv) > 1 else "" + finally: + sys.stdout.flush() + os.dup2(Stdout, 1) + os.close(Stdout) + print(Folder)