diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a76d15b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## 1.7.4 (unreleased) + +### Fixed + +- **macOS crash on macOS 26**: Fixed SIGSEGV in `PyObjCClass_NewMetaClass` caused by + pyobjc-core 7.1 being incompatible with macOS 26's Objective-C runtime under Rosetta. + Upgraded pyobjc-core from 7.1 to 12.1. +- **Python 3.14 compatibility**: Fixed `ImportError` for `traceback._some_str` (private + API removed in 3.14), `AttributeError` for read-only `TracebackException.exc_type` + property, and `TypeError` from float-to-int conversion in `QSize`/`QPoint`/`setPointSize` + (implicit conversion removed in 3.14). +- **Slow GoTo quicksearch (Cmd+P)**: Cached `CoreServices.framework` loading which took + ~800ms per Spotlight query with pyobjc-core 12.1. Pre-warms the framework at startup. +- **Frozen app: osxtrash not found**: Moved osxtrash `.so` to `Contents/Frameworks` + (where PyInstaller 6.x sets `sys._MEIPASS`) instead of `Contents/MacOS`. +- **Escape sequence warning** in `tutorial.py` docstring (`\F` is invalid in Python 3.14+). + +### Changed + +- **Upgraded all dependencies** for Python 3.9+ / 3.14 compatibility: + - PyInstaller: 4.4 -> 6.19.0 + - pyobjc-core: 7.1 -> 12.1 (macOS) + - rsa: 3.4.2 -> 4.9 + - boto3: 1.17.26 -> 1.35.99 + - requests: 2.25.1 -> 2.32.3 + - Send2Trash: 1.4.2/1.5.0 -> 1.8.3 (Windows/Linux) + - distro: 1.0.4 -> 1.9.0 (Linux) + - pywinpty: 0.5.7 -> 2.0.14 (Windows) + - pywin32: 300 -> 308 (Windows) + - PyQt5: 5.15.4 -> 5.15.11 (Windows, aligned with other platforms) +- **Updated fbs dependency syntax** from egg fragment to PEP 440 Direct URL format + for compatibility with modern pip. +- **Removed Python 3.5/3.6 compatibility workarounds**: Removed unnecessary + `try/except TypeError` around `Path.resolve(strict=True)` and updated + version-specific comments. +- **Reduced macOS app bundle size** from ~110MB to ~77MB by stripping unused + Qt frameworks (QtQml, QtQuick, QtWebSockets), unused Qt plugins, and + build-only dependencies (boto3/botocore) from the frozen bundle. diff --git a/README.md b/README.md index d0d171ca..ee2fd216 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ A cross-platform dual-pane file manager. ## Development instructions -You currently need Python 3.9. +Python 3.9 or later is required (tested up to Python 3.14). Install the requirements for your operating system. For example: - pip install -Ur requirements/ubuntu.txt + pip install -Ur requirements/mac.txt # macOS + pip install -Ur requirements/ubuntu.txt # Ubuntu/Debian + pip install -Ur requirements/arch.txt # Arch Linux + pip install -Ur requirements/fedora.txt # Fedora + pip install -Ur requirements/windows.txt # Windows Then you can use `python build.py` to run, compile etc. fman. For example: @@ -17,3 +21,13 @@ Then you can use `python build.py` to run, compile etc. fman. For example: Call `python build.py` without arguments to see a list of available commands. This uses [fman build system](https://build-system.fman.io/). +## Key dependencies + +| Package | Version | Notes | +|---------|---------|-------| +| PyQt5 | 5.15.11 | GUI framework | +| PyInstaller | 6.19.0 | App freezing/compilation | +| pyobjc-core | 12.1 | macOS Objective-C bridge | +| fbs | 0.9.4 | Build system | + +See `requirements/` for the full list per platform. diff --git a/requirements/base.txt b/requirements/base.txt index 03ffc86e..a2bddc2e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,7 +1,7 @@ -http://build-system.fman.io/pro/b5aab865-bd29-4f23-992c-0eb4f3a24f33/0.9.4#egg=fbs[sentry] +fbs[sentry] @ http://build-system.fman.io/pro/b5aab865-bd29-4f23-992c-0eb4f3a24f33/0.9.4 PyQt5==5.15.11 -PyInstaller==4.4 -rsa==3.4.2 +PyInstaller==6.19.0 +rsa==4.9 tinycss==0.4 -boto3==1.17.26 -requests==2.25.1 \ No newline at end of file +boto3==1.35.99 +requests==2.32.3 \ No newline at end of file diff --git a/requirements/linux.txt b/requirements/linux.txt index 1cdd27e6..7ee521d2 100644 --- a/requirements/linux.txt +++ b/requirements/linux.txt @@ -1,3 +1,3 @@ -r base.txt -Send2Trash==1.5.0 -distro==1.0.4 \ No newline at end of file +Send2Trash==1.8.3 +distro==1.9.0 \ No newline at end of file diff --git a/requirements/mac.txt b/requirements/mac.txt index c879ef83..7ce5dbda 100644 --- a/requirements/mac.txt +++ b/requirements/mac.txt @@ -1,3 +1,3 @@ -r base.txt osxtrash==1.6 -pyobjc-core==7.1 \ No newline at end of file +pyobjc-core==12.1 \ No newline at end of file diff --git a/requirements/windows.txt b/requirements/windows.txt index d63bc13c..e6dfd14d 100644 --- a/requirements/windows.txt +++ b/requirements/windows.txt @@ -1,7 +1,7 @@ -r base.txt -PyQt5==5.15.4 +PyQt5==5.15.11 # Note: Send2Trash 1.5.0 has no effect on Windows! -Send2Trash==1.4.2 +Send2Trash==1.8.3 adodbapi==2.6.0.7 -https://download.lfd.uci.edu/pythonlibs/w4tscw6k/pywinpty-0.5.7-cp39-cp39-win_amd64.whl -pywin32==300 \ No newline at end of file +pywinpty==2.0.14 +pywin32==308 \ No newline at end of file diff --git a/src/build/python/build_impl/mac.py b/src/build/python/build_impl/mac.py index abeba510..d8925b23 100644 --- a/src/build/python/build_impl/mac.py +++ b/src/build/python/build_impl/mac.py @@ -11,6 +11,7 @@ from time import sleep import json +import os import plistlib import requests @@ -31,6 +32,7 @@ def freeze(): remove(path('${core_plugin_in_freeze_dir}/Open Sans.ttf')) # Similarly for Roboto Bold.ttf. It is only used on Windows: remove(path('${core_plugin_in_freeze_dir}/Roboto Bold.ttf')) + _strip_unused_from_bundle() copy_framework( path('lib/mac/Sparkle-1.22.0/Sparkle.framework'), path('${freeze_dir}/Contents/Frameworks/Sparkle.framework') @@ -38,17 +40,50 @@ def freeze(): copy_python_library('osxtrash', path('${core_plugin_in_freeze_dir}')) import osxtrash so_name = basename(osxtrash.__file__) - # Move the .so file to the correct location according to macOS's app bundle - # structure, so it is codesigned: + # Move the .so to Frameworks (where PyInstaller 6.x sets sys._MEIPASS), + # so it's both importable and codesigned: move( path('${core_plugin_in_freeze_dir}/' + so_name), - path('${freeze_dir}/Contents/MacOS') + path('${freeze_dir}/Contents/Frameworks') ) move( path('${core_plugin_in_freeze_dir}/bin/mac/7za'), path('${freeze_dir}/Contents/MacOS') ) +def _strip_unused_from_bundle(): + frameworks = path('${freeze_dir}/Contents/Frameworks') + resources = path('${freeze_dir}/Contents/Resources') + # boto3/botocore are build-system-only deps, not used at runtime (~40MB): + for dir_name in ('boto3', 'botocore', 's3transfer'): + for base in (frameworks, resources): + dir_path = join(base, dir_name) + if os.path.islink(dir_path): + os.unlink(dir_path) + elif os.path.isdir(dir_path): + rmtree(dir_path) + # Remove unused Qt frameworks (fman only uses Core, Gui, Widgets, + # MacExtras, PrintSupport, Svg): + qt_lib = join(frameworks, 'PyQt5', 'Qt5', 'lib') + for unused_fw in ( + 'QtQml', 'QtQmlModels', 'QtQuick', 'QtWebSockets' + ): + fw_path = join(qt_lib, unused_fw + '.framework') + if os.path.isdir(fw_path): + rmtree(fw_path) + # Remove unused Qt platform plugins: + qt_plugins = join(frameworks, 'PyQt5', 'Qt5', 'plugins') + for unused_plugin in ( + 'platforms/libqwebgl.dylib', 'platforms/libqminimal.dylib', + 'platforms/libqoffscreen.dylib', 'bearer', 'generic', + 'platformthemes' + ): + p = join(qt_plugins, unused_plugin) + if os.path.isdir(p): + rmtree(p) + elif os.path.isfile(p): + remove(p) + @command def sign(): app_dir = path('${freeze_dir}') diff --git a/src/main/python/fman/impl/application_context.py b/src/main/python/fman/impl/application_context.py index b3372366..8744eaad 100644 --- a/src/main/python/fman/impl/application_context.py +++ b/src/main/python/fman/impl/application_context.py @@ -89,6 +89,8 @@ def _load_plugins(self): def fman_version(self): return self.build_settings['version'] def on_main_window_shown(self): + if is_mac(): + self._preload_core_services() if self.updater: self.updater.start() if self.is_licensed: @@ -104,6 +106,18 @@ def on_main_window_shown(self): self.tour_controller.start(tutorial) else: self.splash_screen.exec() + def _preload_core_services(self): + from threading import Thread + def _load(): + try: + from objc import loadBundle + loadBundle( + 'CoreServices.framework', {}, + bundle_identifier='com.apple.CoreServices' + ) + except Exception: + pass + Thread(target=_load, daemon=True).start() def on_main_window_close(self): self.session_manager.on_close(self.main_window) def on_quit(self): diff --git a/src/main/python/fman/impl/onboarding/tutorial.py b/src/main/python/fman/impl/onboarding/tutorial.py index 0dfb6c53..73d3b628 100644 --- a/src/main/python/fman/impl/onboarding/tutorial.py +++ b/src/main/python/fman/impl/onboarding/tutorial.py @@ -537,7 +537,7 @@ def continue_from(src_url, showing_hidden_files=showing_hidden_files): return [] def _upper_server(unc_path): - """ + r""" \\server\Folder -> \\SERVER\Folder """ assert unc_path.startswith(r'\\'), unc_path diff --git a/src/main/python/fman/impl/plugins/error.py b/src/main/python/fman/impl/plugins/error.py index 3228ad46..8b19d965 100644 --- a/src/main/python/fman/impl/plugins/error.py +++ b/src/main/python/fman/impl/plugins/error.py @@ -3,7 +3,7 @@ from fman.impl.theme import ThemeError from fman.impl.util import is_below_dir from os.path import dirname, basename -from traceback import StackSummary, _some_str, extract_tb, TracebackException, \ +from traceback import StackSummary, extract_tb, TracebackException, \ print_exception import fman @@ -66,7 +66,7 @@ def tb_filter(tb): class TracebackExceptionWithTbFilter(TracebackException): """ - Copied and adapted from Python 3.5.3's `TracebackException`. Adds one + Copied and adapted from Python's `TracebackException`. Adds one additional constructor arg: `tb_filter`, a boolean predicate that determines which traceback entries should be included. """ @@ -80,11 +80,18 @@ def __init__( ): if _seen is None: _seen = set() - _seen.add(exc_value) + _seen.add(id(exc_value)) + # Let super handle all standard attributes (exc_type, _str, + # exceptions, etc.) — some became read-only properties in Python 3.14. + super().__init__( + exc_type, exc_value, exc_traceback, + limit=limit, lookup_lines=False, + capture_locals=capture_locals + ) + # Override cause/context with tb_filter-aware versions if (exc_value and exc_value.__cause__ is not None - and exc_value.__cause__ not in _seen): - # This differs from Python 3.5.3's implementation: - cause = TracebackExceptionWithTbFilter( + and id(exc_value.__cause__) not in _seen): + self.__cause__ = TracebackExceptionWithTbFilter( type(exc_value.__cause__), exc_value.__cause__, exc_value.__cause__.__traceback__, @@ -94,12 +101,9 @@ def __init__( _seen=_seen, tb_filter=tb_filter ) - else: - cause = None if (exc_value and exc_value.__context__ is not None - and exc_value.__context__ not in _seen): - # This differs from Python 3.5.3's implementation: - context = TracebackExceptionWithTbFilter( + and id(exc_value.__context__) not in _seen): + self.__context__ = TracebackExceptionWithTbFilter( type(exc_value.__context__), exc_value.__context__, exc_value.__context__.__traceback__, @@ -109,31 +113,16 @@ def __init__( _seen=_seen, tb_filter=tb_filter ) - else: - context = None - self.exc_traceback = exc_traceback - self.__cause__ = cause - self.__context__ = context - # This differs from Python 3.5.3's implementation: + # Override stack with filtered version self.stack = StackSummary.extract( walk_tb_with_filtering(exc_traceback, tb_filter), limit=limit, lookup_lines=lookup_lines, capture_locals=capture_locals ) - # This differs from Python 3.5.3's implementation: if exc_value: + context = self.__context__ # Hide context when all its frames are hidden: self.__suppress_context__ = exc_value.__suppress_context__ or \ (context and not context.stack.format()) - else: - self.__suppress_context__ = False - self.exc_type = exc_type - self._str = _some_str(exc_value) - if exc_type and issubclass(exc_type, SyntaxError): - self.filename = exc_value.filename - self.lineno = str(exc_value.lineno) - self.text = exc_value.text - self.offset = exc_value.offset - self.msg = exc_value.msg if lookup_lines: self._load_lines() diff --git a/src/main/python/fman/impl/quicksearch.py b/src/main/python/fman/impl/quicksearch.py index cba8f3a3..79314844 100644 --- a/src/main/python/fman/impl/quicksearch.py +++ b/src/main/python/fman/impl/quicksearch.py @@ -216,7 +216,7 @@ def sizeHint(self): width = max(width, w) height += h width += self._padding_left + self._padding_right - return QSize(width, height) + return QSize(int(width), int(height)) def _draw_background(self, painter): self._proxy.drawPrimitive( QStyle.PE_PanelItemViewItem, self._option, painter, self._widget @@ -226,13 +226,13 @@ def _draw_title(self, painter): highlight_formats = self._get_highlight_formats() painter.setPen(self._css['title']['color']) pos = self._option.rect.topLeft() \ - + QPoint(self._padding_left, self._padding_top) + + QPoint(int(self._padding_left), int(self._padding_top)) layout.draw(painter, pos, highlight_formats) def _draw_hint(self, painter): if not self._hint: return font = QFont(self._option.font) - font.setPointSize(self._css['hint']['font-size_pts']) + font.setPointSize(int(self._css['hint']['font-size_pts'])) painter.setFont(font) painter.setPen(self._css['hint']['color']) rect = self._get_title_rect() @@ -246,11 +246,11 @@ def _draw_description(self, painter): layout.draw(painter, QPointF(title_rect.left(), title_rect.bottom())) def _layout_title(self): font = QFont(self._option.font) - font.setPointSize(self._css['title']['font-size_pts']) + font.setPointSize(int(self._css['title']['font-size_pts'])) return self._layout_text(self._title, font) def _layout_description(self): font = QFont(self._option.font) - font.setPointSize(self._css['description']['font-size_pts']) + font.setPointSize(int(self._css['description']['font-size_pts'])) return self._layout_text(self._description, font) def _get_title_rect(self): x = self._option.rect.x() + self._padding_left diff --git a/src/main/resources/base/Plugins/Core/core/__init__.py b/src/main/resources/base/Plugins/Core/core/__init__.py index 17ab484d..478b81e4 100644 --- a/src/main/resources/base/Plugins/Core/core/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/__init__.py @@ -99,7 +99,7 @@ def get_str(self, url): try: timestamp = mtime.timestamp() except OSError: - # This can occur in at least Python 3.6 on Windows. To reproduce: + # This can occur on Windows. To reproduce: # datetime.min.timestamp() # This raises `OSError: [Errno 22] Invalid argument`. return '' diff --git a/src/main/resources/base/Plugins/Core/core/commands/goto.py b/src/main/resources/base/Plugins/Core/core/commands/goto.py index db242c3c..94fd8ad3 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/goto.py +++ b/src/main/resources/base/Plugins/Core/core/commands/goto.py @@ -225,14 +225,20 @@ def resolve(self, path): return str(Path(path).resolve()) def samefile(self, f1, f2): return os.path.samefile(f1, f2) - def find_folders_starting_with(self, pattern, timeout_secs=0.02): - if PLATFORM == 'Mac': + _core_services_ns = None + def _get_core_services_ns(self): + if self.__class__._core_services_ns is None: from objc import loadBundle ns = {} loadBundle( 'CoreServices.framework', ns, bundle_identifier='com.apple.CoreServices' ) + self.__class__._core_services_ns = ns + return self.__class__._core_services_ns + def find_folders_starting_with(self, pattern, timeout_secs=0.02): + if PLATFORM == 'Mac': + ns = self._get_core_services_ns() pred = ns['NSPredicate'].predicateWithFormat_argumentArray_( "kMDItemContentType == 'public.folder' && " "kMDItemFSName BEGINSWITH[c] %@", [pattern] diff --git a/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py b/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py index 108ed2a3..b3fbf022 100644 --- a/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py @@ -220,13 +220,7 @@ def resolve(self, path): return 'network://' + path[2:] p = Path(path) try: - try: - path = p.resolve(strict=True) - except TypeError: - # fman's "production Python version" is 3.6 but we want to be - # able to develop using 3.5 as well. So add this workaround for - # Python < 3.6: - path = p.resolve() + path = p.resolve(strict=True) except FileNotFoundError: if not p.exists(): raise