Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ folders and do not need the thumbnail grid immediately:
faststack --loupe "C:\path\to\photos"
```

### Updating

FastStack checks GitHub Releases for newer versions when update checks are
enabled in Settings. For source or virtualenv installs, open the release page
from FastStack and update from the checkout:

```bash
git pull
venv/Scripts/python.exe -m pip install -e .
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

On Linux or macOS, use the Python executable from the active virtualenv:

```bash
git pull
python -m pip install -e .
```

Automatic installation is intentionally disabled for source/virtualenv installs
because a running Python app cannot reliably replace its own environment across
Windows, Linux, and macOS.

### Command Line Options

```text
Expand Down
2 changes: 1 addition & 1 deletion faststack/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .app import cli
from faststack.app import cli

if __name__ == "__main__":
cli()
185 changes: 183 additions & 2 deletions faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import argparse
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Set
from datetime import date, datetime
from datetime import date, datetime, timezone
import os
import re
import shutil
Expand All @@ -30,7 +30,7 @@
import subprocess
from faststack.ui.provider import ImageProvider, UIState
import PySide6
from PySide6.QtGui import QDrag, QPixmap
from PySide6.QtGui import QDesktopServices, QDrag, QPixmap
from PySide6.QtCore import (
QUrl,
QTimer,
Expand Down Expand Up @@ -87,6 +87,7 @@
from faststack.imaging.mask_engine import inverse_transform
from faststack.imaging.metadata import get_exif_data
from faststack.resources import faststack_qml_dir, pyside_qml_dir
from faststack.updater import check_for_update, get_current_version
from faststack.thumbnail_view import (
DEFAULT_THUMBNAIL_CACHE_BYTES,
ThumbnailModel,
Expand Down Expand Up @@ -230,6 +231,7 @@ class ProgressReporter(QObject):
object
) # Signal for async delete completion (result dict from worker)
_exifBriefReady = Signal(object, str) # (cache_key, brief) from background thread
_updateCheckFinished = Signal(object) # Update check result from background thread

def __init__(
self,
Expand Down Expand Up @@ -291,6 +293,12 @@ def __init__(
self._editor_prewarm_executor = create_daemon_threadpool_executor(
max_workers=1, thread_name_prefix="EditPrewarm"
)
self._update_executor = create_daemon_threadpool_executor(
max_workers=1, thread_name_prefix="UpdateCheck"
)
self._updateCheckFinished.connect(self._on_update_check_finished)
self._update_check_token = 0
self._update_check_inflight = False
self._preview_inflight = False
self._preview_pending = False
self._preview_token = 0
Expand Down Expand Up @@ -6205,6 +6213,173 @@ def save_config(self):
)
config.save()

@Slot(result=str)
def get_current_version(self):
return get_current_version()

@Slot(result=bool)
def get_update_check_enabled(self):
return config.getboolean("updates", "check_for_updates", True)

@Slot(bool)
def set_update_check_enabled(self, enabled):
config.set("updates", "check_for_updates", "true" if enabled else "false")
config.save()

@Slot(result=bool)
def get_auto_update_enabled(self):
return config.getboolean("updates", "auto_update", False)

@Slot(bool)
def set_auto_update_enabled(self, enabled):
# Source/venv installs cannot be safely self-updated yet. Persist false
# so the setting exists without promising unavailable behavior.
config.set("updates", "auto_update", "false")
config.save()

def _last_update_check_time(self) -> Optional[datetime]:
raw_value = config.get("updates", "last_check_at", fallback="").strip()
if not raw_value:
return None
try:
parsed = datetime.fromisoformat(raw_value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed

@Slot()
def maybe_check_for_updates(self):
"""Run the automatic update check if enabled and outside the cooldown."""
if not self.get_update_check_enabled():
return

last_check = self._last_update_check_time()
if last_check is not None:
elapsed = datetime.now(timezone.utc) - last_check
if elapsed.total_seconds() < 24 * 60 * 60:
return

self.check_for_updates(False)

@Slot(bool)
def check_for_updates(self, manual=False):
"""Check GitHub Releases for a newer FastStack version."""
manual = bool(manual)
if self._shutting_down:
return
if self._update_check_inflight:
if manual:
self.update_status_message("Update check already running")
return
if not manual and not self.get_update_check_enabled():
return

self._update_check_inflight = True
self._update_check_token += 1
token = self._update_check_token
config.set(
"updates",
"last_check_at",
datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
config.save()

if manual:
self.update_status_message("Checking for updates...")

current_version = get_current_version()
try:
future = self._update_executor.submit(
check_for_update,
current_version=current_version,
)
except RuntimeError as e:
self._update_check_inflight = False
log.warning("Could not start update check: %s", e)
return

def _done(fut):
if self._shutting_down:
return
try:
payload = fut.result().to_qml_dict()
payload["error"] = ""
except Exception as e:
log.warning("Update check failed: %s", e)
payload = {
"currentVersion": current_version,
"latestVersion": "",
"releaseUrl": "",
"summary": "",
"isNewer": False,
"error": str(e),
}
payload["manual"] = manual
payload["token"] = token
if self._shutting_down:
return
self._updateCheckFinished.emit(payload)

future.add_done_callback(_done)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Slot(object)
def _on_update_check_finished(self, payload):
if self._shutting_down:
return
if int(payload.get("token", -1)) != self._update_check_token:
return

self._update_check_inflight = False
manual = bool(payload.get("manual", False))
error = str(payload.get("error") or "")
if error:
if manual:
self.update_status_message(
f"Could not check for updates: {error}", 6000
)
return

current_version = str(payload.get("currentVersion") or get_current_version())
latest_version = str(payload.get("latestVersion") or "")
if payload.get("isNewer"):
ignored_version = config.get(
"updates", "last_ignored_version", fallback=""
).strip()
if not manual and latest_version == ignored_version:
log.info("Update %s is available but skipped by user.", latest_version)
return

if self.main_window and hasattr(self.main_window, "openUpdateDialog"):
self.main_window.openUpdateDialog(payload)
else:
self.update_status_message(
f"FastStack {latest_version} is available", 8000
)
return

if manual:
self.update_status_message(f"FastStack {current_version} is up to date")

@Slot(str)
def skip_update_version(self, version):
version = str(version or "").strip()
if not version:
return
config.set("updates", "last_ignored_version", version)
config.save()
self.update_status_message(f"Skipped FastStack {version}")

@Slot(str)
def open_update_release(self, url):
url = str(url or "").strip()
if not url:
self.update_status_message("No update release URL available")
return
if not QDesktopServices.openUrl(QUrl(url)):
self.update_status_message("Could not open update release page", 5000)

@Slot(result=str)
def get_color_mode(self):
"""Returns current color management mode: 'none', 'saturation', or 'icc'."""
Expand Down Expand Up @@ -8554,6 +8729,11 @@ def shutdown_nonqt(self):
"editor prewarm",
wait=False,
)
self._safe_shutdown_executor(
getattr(self, "_update_executor", None),
"update",
wait=False,
)
# wait=True ensures pending saves/deletes complete to avoid data loss/corruption
self._safe_shutdown_executor(
self._save_executor, "save", wait=True, cancel_futures=False
Expand Down Expand Up @@ -12397,6 +12577,7 @@ def main(
0,
lambda: controller.load(skip_thumbnail_refresh=start_in_loupe),
)
QTimer.singleShot(2000, controller.maybe_check_for_updates)
if debug:
log.info(
"Startup: controller.load() deferred to event loop (%.3fs to window)",
Expand Down
6 changes: 6 additions & 0 deletions faststack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ def version_sort_key(path):
"source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos",
"mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom",
},
"updates": {
"check_for_updates": "true",
"auto_update": "false",
"last_check_at": "",
"last_ignored_version": "",
},
}


Expand Down
Loading
Loading