tbr-deal-finder 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/.github/workflows/build-release-dmg.yml +51 -20
  2. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/CHANGELOG.md +11 -0
  3. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/Makefile +1 -1
  4. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/PKG-INFO +1 -1
  5. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/pyproject.toml +10 -1
  6. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/__init__.py +1 -1
  7. tbr_deal_finder-0.3.3/tbr_deal_finder/desktop_updater.py +71 -0
  8. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/main.py +51 -27
  9. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/book_details.py +2 -3
  10. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/latest_deals.py +18 -20
  11. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/uv.lock +1 -1
  12. tbr_deal_finder-0.3.2/tbr_deal_finder/desktop_updater.py +0 -147
  13. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/.github/workflows/build-release-exe.yml +0 -0
  14. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/.github/workflows/publish-to-pypi.yaml +0 -0
  15. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/.gitignore +0 -0
  16. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/.python-version +0 -0
  17. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/DESIGN.md +0 -0
  18. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/LICENSE +0 -0
  19. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/README.md +0 -0
  20. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/assets/icon.ico +0 -0
  21. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/assets/icon.png +0 -0
  22. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/docs/desktop-app.md +0 -0
  23. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/docs/development.md +0 -0
  24. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/docs/python-cli.md +0 -0
  25. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/scripts/packaging/create_dmg.sh +0 -0
  26. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/__main__.py +0 -0
  27. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/book.py +0 -0
  28. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/cli.py +0 -0
  29. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/config.py +0 -0
  30. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/__init__.py +0 -0
  31. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/__init__.py +0 -0
  32. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/all_books.py +0 -0
  33. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/all_deals.py +0 -0
  34. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/base_book_page.py +0 -0
  35. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/gui/pages/settings.py +0 -0
  36. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/migrations.py +0 -0
  37. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/owned_books.py +0 -0
  38. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  39. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  40. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  41. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/queries/latest_unknown_book_sync.sql +0 -0
  42. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/__init__.py +0 -0
  43. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/amazon.py +0 -0
  44. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/amazon_custom_auth.py +0 -0
  45. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/audible.py +0 -0
  46. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/chirp.py +0 -0
  47. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/kindle.py +0 -0
  48. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/librofm.py +0 -0
  49. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer/models.py +0 -0
  50. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/retailer_deal.py +0 -0
  51. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/tracked_books.py +0 -0
  52. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/utils.py +0 -0
  53. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder/version_check.py +0 -0
  54. {tbr_deal_finder-0.3.2 → tbr_deal_finder-0.3.3}/tbr_deal_finder.py +0 -0
@@ -14,11 +14,11 @@ jobs:
14
14
  HAS_MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE != '' }}
15
15
  HAS_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD != '' }}
16
16
  VERSION: ${{ github.event.release.tag_name }}
17
-
17
+
18
18
  steps:
19
19
  - name: Checkout code
20
20
  uses: actions/checkout@v4
21
-
21
+
22
22
  - name: Set up Python
23
23
  uses: actions/setup-python@v5
24
24
  with:
@@ -26,7 +26,7 @@ jobs:
26
26
 
27
27
  - name: Install uv
28
28
  uses: astral-sh/setup-uv@v5
29
-
29
+
30
30
  - name: Install project dependencies
31
31
  run: |
32
32
  uv sync
@@ -35,7 +35,7 @@ jobs:
35
35
  uses: maxim-lobanov/setup-xcode@v1
36
36
  with:
37
37
  xcode-version: latest-stable
38
-
38
+
39
39
  - name: Set up CocoaPods
40
40
  uses: maxim-lobanov/setup-cocoapods@v1
41
41
  with:
@@ -44,44 +44,76 @@ jobs:
44
44
  - name: Import Code Signing Certificate (if available)
45
45
  if: ${{ env.HAS_MACOS_CERTIFICATE == 'true' && env.HAS_MACOS_CERTIFICATE_PASSWORD == 'true' }}
46
46
  run: |
47
+ set -e # Exit on any error
48
+
47
49
  # Create temporary keychain
48
- security create-keychain -p "temp_keychain_password" temp.keychain
49
- security default-keychain -s temp.keychain
50
- security unlock-keychain -p "temp_keychain_password" temp.keychain
50
+ KEYCHAIN_PASSWORD="temp_keychain_password"
51
+ KEYCHAIN_NAME="temp.keychain-db"
52
+
53
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
54
+ security default-keychain -s "$KEYCHAIN_NAME"
55
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
56
+ security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME"
51
57
 
52
- # Import certificate
58
+ # Decode PKCS12 certificate
53
59
  echo "${{ secrets.MACOS_CERTIFICATE }}" | base64 --decode > certificate.p12
54
- security import certificate.p12 -k temp.keychain -P "${{ secrets.MACOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
60
+
61
+ # Verify file exists and has content
62
+ if [ ! -s certificate.p12 ]; then
63
+ echo "❌ Certificate file is empty or missing"
64
+ exit 1
65
+ fi
66
+
67
+ # Import PKCS12 to keychain (specifying format explicitly is required)
68
+ echo "Importing certificate..."
69
+ security import certificate.p12 \
70
+ -k "$KEYCHAIN_NAME" \
71
+ -P "${{ secrets.MACOS_CERTIFICATE_PASSWORD }}" \
72
+ -f pkcs12 \
73
+ -T /usr/bin/codesign
55
74
 
56
75
  # Allow codesign to access the certificate
57
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "temp_keychain_password" temp.keychain
76
+ security set-key-partition-list \
77
+ -S apple-tool:,apple:,codesign: \
78
+ -s \
79
+ -k "$KEYCHAIN_PASSWORD" \
80
+ "$KEYCHAIN_NAME" 2>/dev/null || true
58
81
 
59
82
  # Set code signing identity
60
83
  if [ -n "${{ secrets.CODESIGN_IDENTITY }}" ]; then
61
- # Use provided identity name
62
84
  echo "CODESIGN_IDENTITY=${{ secrets.CODESIGN_IDENTITY }}" >> $GITHUB_ENV
63
85
  echo "✅ Code signing certificate imported: ${{ secrets.CODESIGN_IDENTITY }}"
64
86
  else
65
87
  # Auto-detect identity from certificate
66
- CERT_IDENTITY=$(security find-identity -v -p codesigning temp.keychain | head -1 | grep -o '"[^"]*"' | tr -d '"')
88
+ CERT_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_NAME" | grep -v "0 valid identities" | head -1 | awk -F'"' '{print $2}')
89
+ if [ -z "$CERT_IDENTITY" ]; then
90
+ # Fallback to any identity if no codesigning specific one found
91
+ CERT_IDENTITY=$(security find-identity -v "$KEYCHAIN_NAME" | grep -v "0 valid identities" | head -1 | awk -F'"' '{print $2}')
92
+ fi
93
+
94
+ if [ -z "$CERT_IDENTITY" ]; then
95
+ echo "❌ No signing identity found"
96
+ exit 1
97
+ fi
98
+
67
99
  echo "CODESIGN_IDENTITY=$CERT_IDENTITY" >> $GITHUB_ENV
68
100
  echo "✅ Code signing certificate imported (auto-detected): $CERT_IDENTITY"
69
101
  fi
70
102
 
71
- # Clean up certificate file
103
+ # Clean up
72
104
  rm -f certificate.p12
73
-
105
+
74
106
  - name: Build DMG
75
107
  run: |
76
108
  make build-mac
77
-
109
+
78
110
  - name: Cleanup Code Signing
79
111
  if: ${{ env.HAS_MACOS_CERTIFICATE == 'true' && env.HAS_MACOS_CERTIFICATE_PASSWORD == 'true' }}
80
112
  run: |
81
113
  # Remove temporary keychain
82
114
  security delete-keychain temp.keychain || true
83
115
  echo "🧹 Cleaned up code signing keychain"
84
-
116
+
85
117
  - name: Find and verify DMG was created
86
118
  id: find-dmg
87
119
  run: |
@@ -96,7 +128,7 @@ jobs:
96
128
  echo "✅ DMG file created successfully: $DMG_FILE"
97
129
  echo "dmg_path=$DMG_FILE" >> $GITHUB_OUTPUT
98
130
  echo "dmg_name=$(basename $DMG_FILE)" >> $GITHUB_OUTPUT
99
-
131
+
100
132
  - name: Get DMG info
101
133
  id: dmg-info
102
134
  run: |
@@ -104,7 +136,7 @@ jobs:
104
136
  echo "size=$DMG_SIZE" >> $GITHUB_OUTPUT
105
137
  echo "📦 DMG Size: $DMG_SIZE"
106
138
  echo "📦 DMG Name: ${{ steps.find-dmg.outputs.dmg_name }}"
107
-
139
+
108
140
  - name: Upload DMG to Release
109
141
  env:
110
142
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -112,5 +144,4 @@ jobs:
112
144
  gh release upload ${{ github.event.release.tag_name }} \
113
145
  "${{ steps.find-dmg.outputs.dmg_path }}" \
114
146
  --clobber \
115
- --repo ${{ github.repository }}
116
-
147
+ --repo ${{ github.repository }}
@@ -3,6 +3,17 @@
3
3
 
4
4
  ---
5
5
 
6
+ ## 0.3.3 (September 10, 2025)
7
+
8
+ Notes:
9
+ * Improved updater for Mac app
10
+
11
+ BUG FIXES:
12
+ * Fixed Mac app cert
13
+ * Fixed issue where pricing graph points were running off graph
14
+
15
+ ---
16
+
6
17
  ## 0.3.2 (September 8, 2025)
7
18
 
8
19
  Notes:
@@ -31,7 +31,7 @@ BUILD_SCRIPT := scripts/packaging/build_cross_platform.py
31
31
  # Build self-signed macOS DMG (recommended)
32
32
  build-mac:
33
33
  @echo "🍎 Building app"
34
- uv run flet build macos --output ${DIST_DIR}/app/
34
+ NONINTERACTIVE=1 NO_COLOR=1 TERM=dumb CI=true uv run flet build macos --output ${DIST_DIR}/app/
35
35
  @echo ""
36
36
  @echo "📦 Creating self-signed macOS DMG for app"
37
37
  bash scripts/packaging/create_dmg.sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Track price drops and find deals on books in your TBR list across audiobook and ebook formats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -30,6 +30,15 @@ product = "TBR Deal Finder"
30
30
  app.module = "tbr_deal_finder"
31
31
  app.path = "."
32
32
 
33
+ # Dependency locks
34
+ # Fix for https://github.com/flet-dev/flet/issues/5630
35
+ android.min_sdk_version = 24
36
+
37
+ [tool.flet.flutter.pubspec.dependency_overrides]
38
+ # Dependency locks
39
+ # Fix for https://github.com/flet-dev/flet/issues/5630
40
+ webview_flutter_android = "4.10.1"
41
+
33
42
  [tool.setuptools.package-data]
34
43
  "tbr-deal-finder" = [
35
44
  "queries/**/*.sql",
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
2
 
3
- __VERSION__ = "0.3.2"
3
+ __VERSION__ = "0.3.3"
4
4
 
5
5
  QUERY_PATH = Path(__file__).parent.joinpath("queries")
@@ -0,0 +1,71 @@
1
+ """
2
+ Desktop application update checker and handler.
3
+ For packaged desktop applications (.dmg/.exe).
4
+ """
5
+ import json
6
+ import logging
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import tempfile
11
+ import webbrowser
12
+ from pathlib import Path
13
+ from typing import Optional, Dict, Any
14
+
15
+ import requests
16
+ from packaging import version
17
+
18
+ from tbr_deal_finder import __VERSION__
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ class DesktopUpdater:
23
+ """Handle updates for packaged desktop applications."""
24
+
25
+ def __init__(self, github_repo: str = "WillNye/tbr-deal-finder"):
26
+ self.github_repo = github_repo
27
+ self.current_version = __VERSION__
28
+ self.platform = platform.system().lower()
29
+
30
+ def check_for_updates(self) -> Optional[Dict[str, Any]]:
31
+ """
32
+ Check GitHub releases for newer versions.
33
+ Returns dict with update info or None if no update available.
34
+ """
35
+ try:
36
+ # Check GitHub releases API
37
+ response = requests.get(
38
+ f"https://api.github.com/repos/{self.github_repo}/releases/latest",
39
+ timeout=5
40
+ )
41
+ response.raise_for_status()
42
+
43
+ release_data = response.json()
44
+ latest_version = release_data["tag_name"].lstrip("v")
45
+ if version.parse(latest_version) > version.parse(self.current_version):
46
+ download_url = release_data["html_url"]
47
+ if self.platform == "darwin":
48
+ for asset in release_data["assets"]:
49
+ if asset["browser_download_url"].endswith(".dmg"):
50
+ download_url = asset["browser_download_url"]
51
+
52
+ return {
53
+ "version": latest_version,
54
+ "download_url": download_url,
55
+ "release_notes": release_data.get("body", ""),
56
+ }
57
+
58
+ return None
59
+
60
+ except Exception as e:
61
+ logger.error(f"Failed to check updates for {self.github_repo}: {e}")
62
+ return None
63
+
64
+
65
+ # Global instance
66
+ desktop_updater = DesktopUpdater()
67
+
68
+
69
+ def check_for_desktop_updates() -> Optional[Dict[str, Any]]:
70
+ """Convenience function to check for updates."""
71
+ return desktop_updater.check_for_updates()
@@ -1,6 +1,10 @@
1
1
  import asyncio
2
2
  import os
3
3
  import base64
4
+ import subprocess
5
+ import sys
6
+ from datetime import datetime
7
+ from pathlib import Path
4
8
 
5
9
  import flet as ft
6
10
 
@@ -17,6 +21,7 @@ from tbr_deal_finder.gui.pages.all_deals import AllDealsPage
17
21
  from tbr_deal_finder.gui.pages.latest_deals import LatestDealsPage
18
22
  from tbr_deal_finder.gui.pages.all_books import AllBooksPage
19
23
  from tbr_deal_finder.gui.pages.book_details import BookDetailsPage
24
+ from tbr_deal_finder.utils import get_duckdb_conn, get_latest_deal_last_ran
20
25
 
21
26
 
22
27
  class TBRDealFinderApp:
@@ -27,7 +32,8 @@ class TBRDealFinderApp:
27
32
  self.selected_book = None
28
33
  self.update_info = None # Store update information
29
34
  self.nav_disabled = False # Track navigation disabled state
30
-
35
+ self._last_run_time = None
36
+
31
37
  # Initialize pages
32
38
  self.settings_page = SettingsPage(self)
33
39
  self.all_deals_page = AllDealsPage(self)
@@ -295,10 +301,9 @@ class TBRDealFinderApp:
295
301
  self.all_books_page.refresh_page_state()
296
302
 
297
303
  def refresh_all_pages(self):
298
- """Refresh all pages by clearing their state and reloading data"""
304
+ """Refresh all pages except all_books_page by clearing their state and reloading data"""
299
305
  self.all_deals_page.refresh_page_state()
300
306
  self.latest_deals_page.refresh_page_state()
301
- self.all_books_page.refresh_page_state()
302
307
 
303
308
  def disable_navigation(self):
304
309
  """Disable navigation rail during background operations"""
@@ -621,10 +626,6 @@ class TBRDealFinderApp:
621
626
  def close_dialog(e):
622
627
  dialog.open = False
623
628
  self.page.update()
624
-
625
- def view_release_and_close(e):
626
- self.view_release_notes(e)
627
- close_dialog(e)
628
629
 
629
630
  def download_and_close(e):
630
631
  self.download_update(e)
@@ -638,10 +639,13 @@ class TBRDealFinderApp:
638
639
  ], spacing=10),
639
640
  content=ft.Column([
640
641
  ft.Text(f"Version {self.update_info['version']} is now available!"),
641
- ft.Text("Would you like to download the update?", color=ft.Colors.GREY_600)
642
- ], spacing=10, tight=True),
642
+ ft.Divider(),
643
+ ft.Text(
644
+ self.update_info.get('release_notes', 'No release notes available.'),
645
+ selectable=True
646
+ ),
647
+ ], scroll=ft.ScrollMode.AUTO, spacing=10, tight=True),
643
648
  actions=[
644
- ft.TextButton("View Release Notes", on_click=view_release_and_close),
645
649
  ft.ElevatedButton("Download Update", on_click=download_and_close),
646
650
  ft.TextButton("Later", on_click=close_dialog),
647
651
  ],
@@ -675,24 +679,36 @@ class TBRDealFinderApp:
675
679
  dialog.open = True
676
680
  self.page.update()
677
681
 
678
- def view_release_notes(self, e):
679
- """Open release notes in browser."""
680
- if self.update_info:
681
- import webbrowser
682
- webbrowser.open(self.update_info['release_url'])
683
-
684
682
  def download_update(self, e):
685
683
  """Handle update download."""
686
684
  if not self.update_info or not self.update_info.get('download_url'):
687
685
  return
688
-
689
- # For now, open download URL in browser
690
- # In a more advanced implementation, you could download in-app
691
- import webbrowser
692
- webbrowser.open(self.update_info['download_url'])
693
-
694
- # Show download instructions
695
- self.show_download_instructions()
686
+
687
+ if sys.platform == "darwin":
688
+ dmg_path = Path(
689
+ f"~/Downloads/TBR-Deal-Finder-{self.update_info['version']}-mac.dmg"
690
+ ).expanduser()
691
+
692
+ # Show download instructions
693
+ self.show_download_instructions()
694
+
695
+ if not dmg_path.exists():
696
+ # Using curl or urllib to download to prevent Mac warning
697
+ subprocess.run([
698
+ "curl", "-L",
699
+ self.update_info['download_url'],
700
+ "-o", dmg_path
701
+ ])
702
+
703
+ subprocess.run(["open", dmg_path])
704
+ else:
705
+ # For now, open download URL in browser
706
+ # In a more advanced implementation, you could download in-app
707
+ import webbrowser
708
+ webbrowser.open(self.update_info['download_url'])
709
+
710
+ # Show download instructions
711
+ self.show_download_instructions()
696
712
 
697
713
  def show_download_instructions(self):
698
714
  """Show instructions for installing the downloaded update."""
@@ -701,7 +717,7 @@ class TBRDealFinderApp:
701
717
  self.page.update()
702
718
 
703
719
  instructions = {
704
- "darwin": "1. Download will start in your browser\n2. Open the downloaded .dmg file\n3. Drag the app to Applications folder\n4. Restart the application",
720
+ "darwin": "1. Wait for the update to download (can take a minute or 2)\n2. Close TBR Deal Finder\n3. Once the installer opens, drag the app to Applications folder\n3. When prompted, select Replace\n4. Restart the application",
705
721
  "windows": "1. Download will start in your browser\n2. Run the downloaded .exe installer\n3. Follow the installation wizard\n4. Restart the application",
706
722
  }
707
723
 
@@ -732,12 +748,20 @@ class TBRDealFinderApp:
732
748
  dialog.open = True
733
749
  self.page.update()
734
750
 
735
-
736
-
737
751
  def check_for_updates_button(self):
738
752
  """Check for updates when button is clicked."""
739
753
  self.check_for_updates_manual()
740
754
 
755
+ def update_last_run_time(self):
756
+ db_conn = get_duckdb_conn()
757
+ self._last_run_time = get_latest_deal_last_ran(db_conn)
758
+
759
+ def get_last_run_time(self) -> datetime:
760
+ if not self._last_run_time:
761
+ self.update_last_run_time()
762
+
763
+ return self._last_run_time
764
+
741
765
 
742
766
  def main():
743
767
  """Main entry point for the GUI application"""
@@ -11,7 +11,7 @@ from tbr_deal_finder.utils import get_duckdb_conn, execute_query, float_to_curre
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- def build_book_price_section(historical_data: list[dict]) -> ft.Column:
14
+ def build_book_price_section(max_dt: datetime, historical_data: list[dict]) -> ft.Column:
15
15
  retailer_data = dict()
16
16
  available_colors = [
17
17
  ft.Colors.AMBER,
@@ -27,7 +27,6 @@ def build_book_price_section(historical_data: list[dict]) -> ft.Column:
27
27
  min_price = None
28
28
  max_price = None
29
29
  min_time = None
30
- max_dt = datetime.now()
31
30
  max_time = max_dt.timestamp()
32
31
 
33
32
  for record in historical_data:
@@ -461,7 +460,7 @@ class BookDetailsPage:
461
460
  )
462
461
 
463
462
  # Create the chart
464
- chart_fig = build_book_price_section(self.historical_data)
463
+ chart_fig = build_book_price_section(self.app.get_last_run_time(), self.historical_data)
465
464
 
466
465
  return ft.Container(
467
466
  content=ft.Column([
@@ -10,7 +10,6 @@ from tbr_deal_finder.gui.pages.base_book_page import BaseBookPage
10
10
  class LatestDealsPage(BaseBookPage):
11
11
  def __init__(self, app):
12
12
  super().__init__(app, 4)
13
- self.last_run_time = None
14
13
 
15
14
  def get_page_title(self) -> str:
16
15
  return "Latest Deals"
@@ -27,7 +26,7 @@ class LatestDealsPage(BaseBookPage):
27
26
 
28
27
  def build(self):
29
28
  """Build the latest deals page with custom header"""
30
- self.check_last_run()
29
+ self.app.update_last_run_time()
31
30
 
32
31
  # Custom header with run button
33
32
  header = self.build_header()
@@ -73,15 +72,16 @@ class LatestDealsPage(BaseBookPage):
73
72
  def build_header(self):
74
73
  """Build the header with run button and status"""
75
74
  can_run = self.can_run_latest_deals()
76
-
77
- if not can_run and self.last_run_time:
78
- next_run_time = self.last_run_time + timedelta(hours=8)
75
+ last_run_time = self.app.get_last_run_time()
76
+
77
+ if not can_run and last_run_time:
78
+ next_run_time = last_run_time + timedelta(hours=8)
79
79
  time_remaining = next_run_time - datetime.now()
80
80
  hours_remaining = max(0, int(time_remaining.total_seconds() / 3600))
81
81
  status_text = f"Next run available in {hours_remaining} hours"
82
82
  status_color = ft.Colors.ORANGE
83
- elif self.last_run_time:
84
- status_text = f"Last run: {self.last_run_time.strftime('%Y-%m-%d %H:%M')}"
83
+ elif last_run_time:
84
+ status_text = f"Last run: {last_run_time.strftime('%Y-%m-%d %H:%M')}"
85
85
  status_color = ft.Colors.GREEN
86
86
  else:
87
87
  status_text = "No previous runs"
@@ -194,11 +194,13 @@ class LatestDealsPage(BaseBookPage):
194
194
 
195
195
  def load_items(self):
196
196
  """Load deals found at the last run time"""
197
- if self.last_run_time:
197
+ last_run_time = self.app.get_last_run_time()
198
+
199
+ if last_run_time:
198
200
  try:
199
201
  self.items = [
200
202
  book
201
- for book in get_deals_found_at(self.last_run_time)
203
+ for book in get_deals_found_at(last_run_time)
202
204
  if is_qualifying_deal(self.app.config, book)
203
205
  ]
204
206
  self.apply_filters()
@@ -237,19 +239,15 @@ class LatestDealsPage(BaseBookPage):
237
239
  )
238
240
  )
239
241
 
240
- def check_last_run(self):
241
- """Check when deals were last run"""
242
- if not self.last_run_time:
243
- db_conn = get_duckdb_conn()
244
- self.last_run_time = get_latest_deal_last_ran(db_conn)
245
-
246
242
  def can_run_latest_deals(self) -> bool:
247
243
  """Check if latest deals can be run (8 hour cooldown)"""
248
- if not self.last_run_time:
244
+ last_run_time = self.app.get_last_run_time()
245
+
246
+ if not last_run_time:
249
247
  return True
250
248
 
251
249
  min_age = datetime.now() - timedelta(hours=8)
252
- return self.last_run_time < min_age
250
+ return last_run_time < min_age
253
251
 
254
252
  def run_latest_deals(self, e):
255
253
  """Run the latest deals check"""
@@ -286,7 +284,7 @@ class LatestDealsPage(BaseBookPage):
286
284
 
287
285
  if success:
288
286
  # Update the run time and load new deals
289
- self.last_run_time = self.app.config.run_time
287
+ self.app.update_last_run_time()
290
288
  self.load_items()
291
289
  self.show_success(f"Found {len(self.items)} new deals!")
292
290
  else:
@@ -300,7 +298,7 @@ class LatestDealsPage(BaseBookPage):
300
298
  self.is_loading = False
301
299
  self.progress_container.visible = False
302
300
  self.run_button.disabled = False
303
- self.check_last_run() # Refresh the status
301
+ self.app.update_last_run_time() # Refresh the status
304
302
 
305
303
  # Re-enable navigation after the operation
306
304
  self.app.enable_navigation()
@@ -372,5 +370,5 @@ class LatestDealsPage(BaseBookPage):
372
370
  self.format_dropdown.value = "All"
373
371
 
374
372
  # Check last run and reload data
375
- self.check_last_run()
373
+ self.app.update_last_run_time()
376
374
  self.load_items()
@@ -1181,7 +1181,7 @@ wheels = [
1181
1181
 
1182
1182
  [[package]]
1183
1183
  name = "tbr-deal-finder"
1184
- version = "0.3.2"
1184
+ version = "0.3.3"
1185
1185
  source = { editable = "." }
1186
1186
  dependencies = [
1187
1187
  { name = "aiohttp" },
@@ -1,147 +0,0 @@
1
- """
2
- Desktop application update checker and handler.
3
- For packaged desktop applications (.dmg/.exe).
4
- """
5
- import json
6
- import logging
7
- import os
8
- import platform
9
- import subprocess
10
- import tempfile
11
- import webbrowser
12
- from pathlib import Path
13
- from typing import Optional, Dict, Any
14
-
15
- import requests
16
- from packaging import version
17
-
18
- from tbr_deal_finder import __VERSION__
19
-
20
- logger = logging.getLogger(__name__)
21
-
22
- class DesktopUpdater:
23
- """Handle updates for packaged desktop applications."""
24
-
25
- def __init__(self, github_repo: str = "WillNye/tbr-deal-finder"):
26
- self.github_repo = github_repo
27
- self.current_version = __VERSION__
28
- self.platform = platform.system().lower()
29
-
30
- def check_for_updates(self) -> Optional[Dict[str, Any]]:
31
- """
32
- Check GitHub releases for newer versions.
33
- Returns dict with update info or None if no update available.
34
- """
35
- try:
36
- # Check GitHub releases API
37
- response = requests.get(
38
- f"https://api.github.com/repos/{self.github_repo}/releases/latest",
39
- timeout=5
40
- )
41
- response.raise_for_status()
42
-
43
- release_data = response.json()
44
- latest_version = release_data["tag_name"].lstrip("v")
45
- if version.parse(latest_version) > version.parse(self.current_version):
46
- release_url = release_data["html_url"]
47
- return {
48
- "version": latest_version,
49
- "download_url": release_url,
50
- "release_notes": release_data.get("body", ""),
51
- "release_url": release_url
52
- }
53
-
54
- return None
55
-
56
- except Exception as e:
57
- logger.error(f"Failed to check updates for {self.github_repo}: {e}")
58
- return None
59
-
60
- def download_update(self, download_url: str, progress_callback=None) -> Optional[Path]:
61
- """
62
- Download the update file.
63
- Returns path to downloaded file or None if failed.
64
- """
65
- try:
66
- response = requests.get(download_url, stream=True, timeout=30)
67
- response.raise_for_status()
68
-
69
- # Determine file extension
70
- filename = download_url.split("/")[-1]
71
- temp_file = Path(tempfile.gettempdir()) / filename
72
-
73
- total_size = int(response.headers.get('content-length', 0))
74
- downloaded = 0
75
-
76
- with open(temp_file, 'wb') as f:
77
- for chunk in response.iter_content(chunk_size=8192):
78
- if chunk:
79
- f.write(chunk)
80
- downloaded += len(chunk)
81
-
82
- if progress_callback and total_size > 0:
83
- progress = (downloaded / total_size) * 100
84
- progress_callback(progress)
85
-
86
- return temp_file
87
-
88
- except Exception as e:
89
- logger.error(f"Failed to download update: {e}")
90
- return None
91
-
92
- def install_update(self, update_file: Path) -> bool:
93
- """
94
- Install the downloaded update.
95
- Platform-specific installation logic.
96
- """
97
- if self.platform == "darwin":
98
- return self._install_macos_update(update_file)
99
- elif self.platform == "windows":
100
- return self._install_windows_update(update_file)
101
- elif self.platform == "linux":
102
- return self._install_linux_update(update_file)
103
- else:
104
- return False
105
-
106
- def _install_macos_update(self, dmg_file: Path) -> bool:
107
- """Install .dmg update on macOS."""
108
- try:
109
- # Open the DMG file - user will need to drag to Applications
110
- subprocess.run(["open", str(dmg_file)], check=True)
111
- return True
112
- except subprocess.CalledProcessError:
113
- return False
114
-
115
- def _install_windows_update(self, exe_file: Path) -> bool:
116
- """Install .exe update on Windows."""
117
- try:
118
- # Run the installer
119
- subprocess.run([str(exe_file)], check=True)
120
- return True
121
- except subprocess.CalledProcessError:
122
- return False
123
-
124
- def _install_linux_update(self, appimage_file: Path) -> bool:
125
- """Install AppImage update on Linux."""
126
- try:
127
- # Make executable and offer to replace current installation
128
- os.chmod(appimage_file, 0o755)
129
-
130
- # For AppImage, we'd typically replace the current file
131
- # This is more complex and might require user permission
132
- return True
133
- except Exception:
134
- return False
135
-
136
- def open_download_page(self, release_url: str):
137
- """Open the GitHub release page in browser."""
138
- webbrowser.open(release_url)
139
-
140
-
141
- # Global instance
142
- desktop_updater = DesktopUpdater()
143
-
144
-
145
- def check_for_desktop_updates() -> Optional[Dict[str, Any]]:
146
- """Convenience function to check for updates."""
147
- return desktop_updater.check_for_updates()
File without changes