From d86e33fd4a75e06d52e95f5ae382c4c2aed94faf Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Wed, 14 Oct 2026 13:18:19 +0200 Subject: [PATCH] Client: macOS support --- Client/Core.py | 64 +++++++++++++++++++++--- Client/LocalDiagrams.py | 14 +++++- Client/Main.py | 2 +- Client/Packaging/SaraSuite/Manifest.toml | 9 +++- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/Client/Core.py b/Client/Core.py index c8a78a4..edb9d15 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -45,14 +45,19 @@ BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme IsWindows = sys.platform == "win32" +IsMac = sys.platform == "darwin" # Each system's own per-user folders, like SaraSuite's: ~/.config, ~/.cache -# and ~/.local/share on Linux; %APPDATA% and %LOCALAPPDATA% on Windows. +# and ~/.local/share on Linux; %APPDATA% and %LOCALAPPDATA% on Windows; +# ~/Library/Application Support and ~/Library/Caches on macOS. Home = Path.home() if IsWindows: ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming") DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local") CacheHome = DataHome +elif IsMac: + ConfigHome = DataHome = Home / "Library" / "Application Support" + CacheHome = Home / "Library" / "Caches" else: ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") @@ -337,7 +342,7 @@ def OpenExternally(FilePath: Path) -> None: if IsWindows: os.startfile(str(FilePath)) return - subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True) + subprocess.Popen(["open" if IsMac else "xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True) def _WindowsFonts() -> set[str]: @@ -367,11 +372,58 @@ def _WindowsFonts() -> set[str]: return Families +def _MacFonts() -> set[str]: + """Font families on macOS (no fontconfig there), read from the font files' + name tables in the system, shared and personal Fonts folders. The + typographic family (name ID 16) where there is one, else ID 1.""" + import struct + + Families: set[str] = set() + for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library" / "Fonts"): + if not Folder.is_dir(): + continue + for FilePath in Folder.rglob("*"): + if FilePath.suffix.lower() not in (".ttf", ".otf", ".ttc"): + continue + try: + Data = FilePath.read_bytes() + except OSError: + continue + Offsets = [0] + if Data[:4] == b"ttcf": # a collection: every font in it + Count = struct.unpack_from(">I", Data, 8)[0] + Offsets = list(struct.unpack_from(f">{Count}I", Data, 12)) + for Offset in Offsets: + try: + for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]): + Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16) + if Tag != b"name": + continue + _Format, Count, Strings = struct.unpack_from(">HHH", Data, Start) + Found: dict[int, str] = {} + for Record in range(Count): + PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from( + ">6H", Data, Start + 6 + Record * 12) + if NameId not in (1, 16) or NameId in Found: + continue + Raw = Data[Start + Strings + At:Start + Strings + At + Length] + if PlatformId in (0, 3): + Found[NameId] = Raw.decode("utf-16-be", "replace") + elif PlatformId == 1 and Language == 0: + Found[NameId] = Raw.decode("mac-roman", "replace") + Name = (Found.get(16) or Found.get(1) or "").strip() + if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal + Families.add(Name) + except struct.error: + continue + return Families + + def InstalledFontFamilies() -> list[str]: - """Font families fontconfig (or, on Windows, the registry) knows about, - with the bundled font first.""" - if IsWindows: - Families = _WindowsFonts() + """Font families fontconfig (or, on Windows, the registry; on macOS, the + font files themselves) knows about, with the bundled font first.""" + if IsWindows or IsMac: + Families = _WindowsFonts() if IsWindows else _MacFonts() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) try: diff --git a/Client/LocalDiagrams.py b/Client/LocalDiagrams.py index a91d377..67475c0 100644 --- a/Client/LocalDiagrams.py +++ b/Client/LocalDiagrams.py @@ -31,7 +31,7 @@ from typing import Optional import httpx -from Core import AssetsDir, ChildEnvironment, DataHome, AppName, IsWindows +from Core import AssetsDir, ChildEnvironment, DataHome, AppName, Home, IsMac, IsWindows ToolsDir = DataHome / AppName / "DiagramTools" PythonDir = ToolsDir / "Python" @@ -53,6 +53,12 @@ WindowsBrowsers = [ r"Google\Chrome\Application\chrome.exe", r"BraveSoftware\Brave-Browser\Application\brave.exe", ] +MacBrowsers = [ # in /Applications or ~/Applications + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Brave Browser.app/Contents/MacOS/Brave Browser", + "Chromium.app/Contents/MacOS/Chromium", +] class DiagramError(Exception): @@ -169,6 +175,12 @@ def _InstalledBrowser() -> Optional[str]: if Base and (Path(Base) / Relative).is_file(): return str(Path(Base) / Relative) return None + if IsMac: + for Base in (Path("/Applications"), Home / "Applications"): + for Relative in MacBrowsers: + if (Base / Relative).is_file(): + return str(Base / Relative) + return None return next((Found for Name in BrowserNames if (Found := shutil.which(Name))), None) diff --git a/Client/Main.py b/Client/Main.py index 1397c07..576a3bb 100644 --- a/Client/Main.py +++ b/Client/Main.py @@ -23,7 +23,7 @@ from Settings import ShowSettingsDialog def _SetCursorThemeFallback() -> None: """GTK reads XCURSOR_THEME directly and doesn't always inherit it (shows as 'Unable to load ... from the cursor theme'). Same fix as SaraFem's.""" - if os.environ.get("XCURSOR_THEME") or sys.platform == "win32": # GTK/GNOME only + if os.environ.get("XCURSOR_THEME") or not sys.platform.startswith("linux"): # GTK/GNOME only return try: Result = subprocess.run(["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"], diff --git a/Client/Packaging/SaraSuite/Manifest.toml b/Client/Packaging/SaraSuite/Manifest.toml index 2053d9a..ae4fd76 100644 --- a/Client/Packaging/SaraSuite/Manifest.toml +++ b/Client/Packaging/SaraSuite/Manifest.toml @@ -1,6 +1,6 @@ # Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/. Name = "SaraView" -Platforms = ["Linux", "Windows"] +Platforms = ["Linux", "Windows", "macOS"] Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server." [Source] @@ -32,6 +32,13 @@ Name = "SaraView" Script = "Client/Main.py" Icon = "Client/assets/Icon.png" +# macOS: an app bundle, ~/Applications/SaraView.app (python Client/Main.py, from the app folder). +[[Shortcuts]] +Platform = "macOS" +Name = "SaraView" +Script = "Client/Main.py" +Icon = "Client/assets/Icon.png" + # Made by SaraView itself: Config.toml and Session.toml (your saved login). [[Leftovers]] Path = "{ConfigDir}"