setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__py3-none-any.whl
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.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
|
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QMenu, QToolButton
|
|
|
12
12
|
|
|
13
13
|
from PyQt6.QtCore import QElapsedTimer
|
|
14
14
|
|
|
15
|
+
import sys
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
pass
|
|
@@ -30,10 +31,10 @@ from setiastro.saspro.resources import (
|
|
|
30
31
|
stacking_path, pedestal_icon_path, starspike_path, astrospike_path,
|
|
31
32
|
signature_icon_path, livestacking_path, convoicon_path, spcc_icon_path,
|
|
32
33
|
exoicon_path, peeker_icon, dse_icon_path, isophote_path, statstretch_path,
|
|
33
|
-
starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path,
|
|
34
|
+
starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path, narrowbandnormalization_path,
|
|
34
35
|
nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
|
|
35
36
|
satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
|
|
36
|
-
debayer_path, aberration_path, functionbundles_path, viewbundles_path,
|
|
37
|
+
debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
|
|
37
38
|
selectivecolor_path, rgbalign_path,
|
|
38
39
|
)
|
|
39
40
|
|
|
@@ -255,6 +256,7 @@ class ToolbarMixin:
|
|
|
255
256
|
tb_tl.addAction(self.act_blink) # Tools start here; Blink shows with QIcon(blink_path)
|
|
256
257
|
tb_tl.addAction(self.act_ppp) # Perfect Palette Picker
|
|
257
258
|
tb_tl.addAction(self.act_nbtorgb)
|
|
259
|
+
tb_tl.addAction(self.act_narrowband_normalization)
|
|
258
260
|
tb_tl.addAction(self.act_selective_color)
|
|
259
261
|
tb_tl.addAction(self.act_freqsep)
|
|
260
262
|
tb_tl.addAction(self.act_multiscale_decomp)
|
|
@@ -301,6 +303,7 @@ class ToolbarMixin:
|
|
|
301
303
|
tb_star.addAction(self.act_psf_viewer)
|
|
302
304
|
tb_star.addAction(self.act_stacking_suite)
|
|
303
305
|
tb_star.addAction(self.act_live_stacking)
|
|
306
|
+
tb_star.addAction(self.act_planetary_stacker)
|
|
304
307
|
tb_star.addAction(self.act_plate_solve)
|
|
305
308
|
tb_star.addAction(self.act_star_align)
|
|
306
309
|
tb_star.addAction(self.act_star_register)
|
|
@@ -366,6 +369,7 @@ class ToolbarMixin:
|
|
|
366
369
|
tb_hidden.setObjectName("Hidden")
|
|
367
370
|
tb_hidden.setSettingsKey("Toolbar/Hidden")
|
|
368
371
|
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_hidden)
|
|
372
|
+
#tb_hidden.addAction(self.act_narrowband_normalization)
|
|
369
373
|
tb_hidden.setVisible(False) # <- always hidden
|
|
370
374
|
|
|
371
375
|
# This can move actions between toolbars, so do it after each toolbar has its base order restored.
|
|
@@ -594,7 +598,16 @@ class ToolbarMixin:
|
|
|
594
598
|
fit_menu.addAction(self.act_auto_fit_resize) # use the real action
|
|
595
599
|
btn_fit.setMenu(fit_menu)
|
|
596
600
|
btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
|
|
601
|
+
tb = self._toolbar_containing_action(self.act_autostretch)
|
|
602
|
+
if tb:
|
|
603
|
+
btn = tb.widgetForAction(self.act_autostretch)
|
|
604
|
+
if isinstance(btn, QToolButton):
|
|
605
|
+
# ... build menu ...
|
|
606
|
+
btn.setMenu(menu)
|
|
607
|
+
btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
|
|
597
608
|
|
|
609
|
+
# IMPORTANT: re-apply style after action moves / rebind
|
|
610
|
+
self._style_toggle_toolbutton(btn)
|
|
598
611
|
|
|
599
612
|
def _bind_view_toolbar_menus(self, tb: DraggableToolBar):
|
|
600
613
|
# --- Display-Stretch menu ---
|
|
@@ -650,6 +663,12 @@ class ToolbarMixin:
|
|
|
650
663
|
btn_fit.setMenu(fit_menu)
|
|
651
664
|
btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
|
|
652
665
|
|
|
666
|
+
def _linux_force_text_action(self, act: QAction, text: str) -> None:
|
|
667
|
+
"""On Linux, show text-only for this action (no theme icon)."""
|
|
668
|
+
if not sys.platform.startswith("linux"):
|
|
669
|
+
return
|
|
670
|
+
act.setIcon(QIcon()) # remove whatever theme icon got assigned
|
|
671
|
+
act.setText(text) # show the glyph/text
|
|
653
672
|
|
|
654
673
|
def _create_actions(self):
|
|
655
674
|
# File actions
|
|
@@ -771,30 +790,30 @@ class ToolbarMixin:
|
|
|
771
790
|
self.act_bake_display_stretch.triggered.connect(self._bake_display_stretch)
|
|
772
791
|
|
|
773
792
|
# --- Zoom controls ---
|
|
774
|
-
# --- Zoom controls (themed icons) ---
|
|
775
793
|
self.act_zoom_out = QAction(QIcon.fromTheme("zoom-out"), self.tr("Zoom Out"), self)
|
|
776
794
|
self.act_zoom_out.setStatusTip(self.tr("Zoom out"))
|
|
777
795
|
self.act_zoom_out.setShortcuts([QKeySequence("Ctrl+-")])
|
|
778
796
|
self.act_zoom_out.triggered.connect(lambda: self._zoom_step_active(-1))
|
|
797
|
+
self._linux_force_text_action(self.act_zoom_out, "−") # true minus
|
|
779
798
|
|
|
780
799
|
self.act_zoom_in = QAction(QIcon.fromTheme("zoom-in"), self.tr("Zoom In"), self)
|
|
781
800
|
self.act_zoom_in.setStatusTip(self.tr("Zoom in"))
|
|
782
|
-
self.act_zoom_in.setShortcuts([
|
|
783
|
-
QKeySequence("Ctrl++"), # Ctrl + (Shift + = on many keyboards)
|
|
784
|
-
QKeySequence("Ctrl+="), # fallback
|
|
785
|
-
])
|
|
801
|
+
self.act_zoom_in.setShortcuts([QKeySequence("Ctrl++"), QKeySequence("Ctrl+=")])
|
|
786
802
|
self.act_zoom_in.triggered.connect(lambda: self._zoom_step_active(+1))
|
|
803
|
+
self._linux_force_text_action(self.act_zoom_in, "+")
|
|
787
804
|
|
|
788
805
|
self.act_zoom_1_1 = QAction(QIcon.fromTheme("zoom-original"), self.tr("1:1"), self)
|
|
789
806
|
self.act_zoom_1_1.setStatusTip(self.tr("Zoom to 100% (pixel-for-pixel)"))
|
|
790
807
|
self.act_zoom_1_1.setShortcut(QKeySequence("Ctrl+1"))
|
|
791
808
|
self.act_zoom_1_1.triggered.connect(self._zoom_active_1_1)
|
|
809
|
+
self._linux_force_text_action(self.act_zoom_1_1, "1:1")
|
|
792
810
|
|
|
793
811
|
self.act_zoom_fit = QAction(QIcon.fromTheme("zoom-fit-best"), self.tr("Fit"), self)
|
|
794
812
|
self.act_zoom_fit.setStatusTip(self.tr("Fit image to current window"))
|
|
795
813
|
self.act_zoom_fit.setShortcut(QKeySequence("Ctrl+0"))
|
|
796
814
|
self.act_zoom_fit.triggered.connect(self._zoom_active_fit)
|
|
797
815
|
self.act_zoom_fit.setCheckable(True)
|
|
816
|
+
self._linux_force_text_action(self.act_zoom_fit, "Fit")
|
|
798
817
|
|
|
799
818
|
self.act_auto_fit_resize = QAction(self.tr("Auto-fit on Resize"), self)
|
|
800
819
|
self.act_auto_fit_resize.setCheckable(True)
|
|
@@ -813,6 +832,10 @@ class ToolbarMixin:
|
|
|
813
832
|
self.act_paste_view.setShortcut("Ctrl+Shift+V")
|
|
814
833
|
self.act_copy_view.triggered.connect(self._copy_active_view)
|
|
815
834
|
self.act_paste_view.triggered.connect(self._paste_active_view)
|
|
835
|
+
# --- Edit: Mono -> RGB (triplicate channels) ---
|
|
836
|
+
self.act_mono_to_rgb = QAction(self.tr("Convert Mono to RGB"), self)
|
|
837
|
+
self.act_mono_to_rgb.setStatusTip(self.tr("Convert a mono image to RGB by duplicating the channel"))
|
|
838
|
+
self.act_mono_to_rgb.triggered.connect(self._convert_mono_to_rgb_active)
|
|
816
839
|
|
|
817
840
|
# Functions
|
|
818
841
|
self.act_crop = QAction(QIcon(cropicon_path), self.tr("Crop..."), self)
|
|
@@ -877,7 +900,7 @@ class ToolbarMixin:
|
|
|
877
900
|
self.act_linear_fit.setShortcut("Ctrl+L")
|
|
878
901
|
self.act_linear_fit.triggered.connect(self._open_linear_fit)
|
|
879
902
|
|
|
880
|
-
self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green..."), self)
|
|
903
|
+
self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green (SCNR)..."), self)
|
|
881
904
|
self.act_remove_green.setToolTip(self.tr("SCNR-style green channel removal."))
|
|
882
905
|
self.act_remove_green.setIconVisibleInMenu(True)
|
|
883
906
|
self.act_remove_green.triggered.connect(self._open_remove_green)
|
|
@@ -1102,6 +1125,16 @@ class ToolbarMixin:
|
|
|
1102
1125
|
self.act_ppp.setStatusTip(self.tr("Pick the perfect palette for your image"))
|
|
1103
1126
|
self.act_ppp.triggered.connect(self._open_ppp_tool)
|
|
1104
1127
|
|
|
1128
|
+
self.act_narrowband_normalization = QAction(QIcon(narrowbandnormalization_path), self.tr("Narrowband Normalization..."), self)
|
|
1129
|
+
self.act_narrowband_normalization.setStatusTip(
|
|
1130
|
+
self.tr("Normalize HOO/SHO/HSO/HOS (PixelMath port by Bill Blanshan and Mike Cranfield)")
|
|
1131
|
+
)
|
|
1132
|
+
self.act_narrowband_normalization.setIconVisibleInMenu(False)
|
|
1133
|
+
self.act_narrowband_normalization.setShortcut(QKeySequence("Ctrl+Alt+Shift+N"))
|
|
1134
|
+
self.act_narrowband_normalization.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
|
1135
|
+
self.addAction(self.act_narrowband_normalization)
|
|
1136
|
+
self.act_narrowband_normalization.triggered.connect(self._open_narrowband_normalization_tool)
|
|
1137
|
+
|
|
1105
1138
|
self.act_nbtorgb = QAction(QIcon(nbtorgb_path), self.tr("NB->RGB Stars..."), self)
|
|
1106
1139
|
self.act_nbtorgb.setStatusTip(self.tr("Combine narrowband to RGB with optional OSC stars"))
|
|
1107
1140
|
self.act_nbtorgb.setIconVisibleInMenu(True)
|
|
@@ -1149,6 +1182,11 @@ class ToolbarMixin:
|
|
|
1149
1182
|
self.act_live_stacking.setStatusTip(self.tr("Live monitor and stack incoming frames"))
|
|
1150
1183
|
self.act_live_stacking.triggered.connect(self._open_live_stacking)
|
|
1151
1184
|
|
|
1185
|
+
self.act_planetary_stacker = QAction(QIcon(planetarystacker_path), self.tr("Planetary Stacker..."), self)
|
|
1186
|
+
self.act_planetary_stacker.setIconVisibleInMenu(True)
|
|
1187
|
+
self.act_planetary_stacker.setStatusTip(self.tr("Stack SER videos (planetary/solar/lunar)"))
|
|
1188
|
+
self.act_planetary_stacker.triggered.connect(self._open_planetary_stacker)
|
|
1189
|
+
|
|
1152
1190
|
self.act_plate_solve = QAction(QIcon(platesolve_path), self.tr("Plate Solver..."), self)
|
|
1153
1191
|
self.act_plate_solve.setIconVisibleInMenu(True)
|
|
1154
1192
|
self.act_plate_solve.setStatusTip(self.tr("Solve WCS/SIP for the active image or a file"))
|
|
@@ -1345,6 +1383,7 @@ class ToolbarMixin:
|
|
|
1345
1383
|
reg("nbtorgb", self.act_nbtorgb)
|
|
1346
1384
|
reg("freqsep", self.act_freqsep)
|
|
1347
1385
|
reg("selective_color", self.act_selective_color)
|
|
1386
|
+
reg("narrowband_normalization", self.act_narrowband_normalization)
|
|
1348
1387
|
reg("contsub", self.act_contsub)
|
|
1349
1388
|
reg("abe", self.act_abe)
|
|
1350
1389
|
reg("create_mask", self.act_create_mask)
|
|
@@ -1369,7 +1408,7 @@ class ToolbarMixin:
|
|
|
1369
1408
|
reg("pixel_math", self.act_pixelmath)
|
|
1370
1409
|
reg("signature_insert", self.act_signature)
|
|
1371
1410
|
reg("halo_b_gon", self.act_halobgon)
|
|
1372
|
-
|
|
1411
|
+
reg("planetary_stacker", self.act_planetary_stacker)
|
|
1373
1412
|
reg("multiscale_decomp", self.act_multiscale_decomp)
|
|
1374
1413
|
reg("geom_invert", self.act_geom_invert)
|
|
1375
1414
|
reg("geom_flip_horizontal", self.act_geom_flip_h)
|
|
@@ -1407,6 +1446,83 @@ class ToolbarMixin:
|
|
|
1407
1446
|
reg("view_bundles", self.act_view_bundles)
|
|
1408
1447
|
reg("function_bundles", self.act_function_bundles)
|
|
1409
1448
|
|
|
1449
|
+
def _reset_all_toolbars_to_factory(self):
|
|
1450
|
+
"""
|
|
1451
|
+
Clears all persisted toolbar membership/order/hidden state so the UI
|
|
1452
|
+
returns to the factory layout defined in code.
|
|
1453
|
+
"""
|
|
1454
|
+
if not hasattr(self, "settings"):
|
|
1455
|
+
return
|
|
1456
|
+
|
|
1457
|
+
# 1) Clear global toolbar persistence
|
|
1458
|
+
keys_to_clear = [
|
|
1459
|
+
"Toolbar/Assignments",
|
|
1460
|
+
"Toolbar/HiddenPrev",
|
|
1461
|
+
"Toolbar/HiddenMigrationDone",
|
|
1462
|
+
|
|
1463
|
+
# Per-toolbar order lists
|
|
1464
|
+
"Toolbar/View",
|
|
1465
|
+
"Toolbar/Functions",
|
|
1466
|
+
"Toolbar/Cosmic",
|
|
1467
|
+
"Toolbar/Tools",
|
|
1468
|
+
"Toolbar/Geometry",
|
|
1469
|
+
"Toolbar/StarStuff",
|
|
1470
|
+
"Toolbar/Masks",
|
|
1471
|
+
"Toolbar/WhatsInMy",
|
|
1472
|
+
"Toolbar/Bundles",
|
|
1473
|
+
"Toolbar/Hidden",
|
|
1474
|
+
]
|
|
1475
|
+
|
|
1476
|
+
for k in keys_to_clear:
|
|
1477
|
+
try:
|
|
1478
|
+
self.settings.remove(k)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
pass
|
|
1481
|
+
|
|
1482
|
+
# 2) Clear legacy hidden lists (old system) if they exist
|
|
1483
|
+
# (These are the ones like "Toolbar/Tools/Hidden")
|
|
1484
|
+
try:
|
|
1485
|
+
# brute-force remove known ones
|
|
1486
|
+
legacy_hidden = [
|
|
1487
|
+
"Toolbar/View/Hidden",
|
|
1488
|
+
"Toolbar/Functions/Hidden",
|
|
1489
|
+
"Toolbar/Cosmic/Hidden",
|
|
1490
|
+
"Toolbar/Tools/Hidden",
|
|
1491
|
+
"Toolbar/Geometry/Hidden",
|
|
1492
|
+
"Toolbar/StarStuff/Hidden",
|
|
1493
|
+
"Toolbar/Masks/Hidden",
|
|
1494
|
+
"Toolbar/WhatsInMy/Hidden",
|
|
1495
|
+
"Toolbar/Bundles/Hidden",
|
|
1496
|
+
]
|
|
1497
|
+
for k in legacy_hidden:
|
|
1498
|
+
self.settings.remove(k)
|
|
1499
|
+
except Exception:
|
|
1500
|
+
pass
|
|
1501
|
+
|
|
1502
|
+
try:
|
|
1503
|
+
self.settings.sync()
|
|
1504
|
+
except Exception:
|
|
1505
|
+
pass
|
|
1506
|
+
|
|
1507
|
+
# 3) Rebuild toolbars from code defaults
|
|
1508
|
+
# Safest approach: remove existing toolbars and rebuild.
|
|
1509
|
+
from setiastro.saspro.shortcuts import DraggableToolBar
|
|
1510
|
+
for tb in self.findChildren(DraggableToolBar):
|
|
1511
|
+
try:
|
|
1512
|
+
self.removeToolBar(tb)
|
|
1513
|
+
tb.deleteLater()
|
|
1514
|
+
except Exception:
|
|
1515
|
+
pass
|
|
1516
|
+
|
|
1517
|
+
# Build fresh
|
|
1518
|
+
self._init_toolbar()
|
|
1519
|
+
|
|
1520
|
+
# Optional: ensure Hidden stays hidden
|
|
1521
|
+
tb_hidden = self._hidden_toolbar()
|
|
1522
|
+
if tb_hidden:
|
|
1523
|
+
tb_hidden.setVisible(False)
|
|
1524
|
+
|
|
1525
|
+
|
|
1410
1526
|
def _restore_toolbar_order(self, tb, settings_key: str):
|
|
1411
1527
|
"""
|
|
1412
1528
|
Restore toolbar action order from QSettings, using command_id/objectName.
|
|
@@ -1821,4 +1937,10 @@ class ToolbarMixin:
|
|
|
1821
1937
|
if hasattr(self, "act_hide_mask"):
|
|
1822
1938
|
self.act_hide_mask.setEnabled(has_mask and overlay_on)
|
|
1823
1939
|
|
|
1824
|
-
|
|
1940
|
+
def _style_toggle_toolbutton(self, btn: QToolButton):
|
|
1941
|
+
# Make sure the action visually shows "on" state
|
|
1942
|
+
btn.setCheckable(True) # safe even if already
|
|
1943
|
+
btn.setStyleSheet("""
|
|
1944
|
+
QToolButton { color: #dcdcdc; }
|
|
1945
|
+
QToolButton:checked { color: #DAA520; font-weight: 600; }
|
|
1946
|
+
""")
|
|
@@ -10,7 +10,7 @@ import json
|
|
|
10
10
|
import sys
|
|
11
11
|
import webbrowser
|
|
12
12
|
from typing import TYPE_CHECKING
|
|
13
|
-
|
|
13
|
+
import re
|
|
14
14
|
from PyQt6.QtCore import QUrl
|
|
15
15
|
from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply
|
|
16
16
|
from PyQt6.QtWidgets import QMessageBox, QApplication
|
|
@@ -58,21 +58,20 @@ class UpdateMixin:
|
|
|
58
58
|
self._ensure_network_manager()
|
|
59
59
|
url_str = self.settings.value("updates/url", self._updates_url, type=str) or self._updates_url
|
|
60
60
|
|
|
61
|
-
# ---- TLS availability guard (prevents crash on OpenSSL mismatch) ----
|
|
62
61
|
if url_str.lower().startswith("https://"):
|
|
63
62
|
try:
|
|
64
63
|
if not QSslSocket.supportsSsl():
|
|
65
|
-
msg = "TLS unavailable in Qt (QSslSocket.supportsSsl()=False). Skipping update check."
|
|
66
64
|
if self.statusBar():
|
|
67
|
-
self.statusBar().showMessage(self.tr("Update check unavailable (TLS missing)."),
|
|
65
|
+
self.statusBar().showMessage(self.tr("Update check unavailable (TLS missing)."), 8000)
|
|
68
66
|
if interactive:
|
|
69
|
-
QMessageBox.information(
|
|
70
|
-
|
|
67
|
+
QMessageBox.information(
|
|
68
|
+
self, self.tr("Update Check"),
|
|
69
|
+
self.tr("Update check is unavailable because TLS is not available on this system.")
|
|
70
|
+
)
|
|
71
71
|
else:
|
|
72
|
-
print(
|
|
72
|
+
print("[updates] TLS unavailable in Qt; skipping update check.")
|
|
73
73
|
return
|
|
74
74
|
except Exception as e:
|
|
75
|
-
# If QtNetwork is in a weird state, fail safe (do not attempt TLS)
|
|
76
75
|
print(f"[updates] TLS probe failed ({e}); skipping update check.")
|
|
77
76
|
return
|
|
78
77
|
|
|
@@ -111,20 +110,22 @@ class UpdateMixin:
|
|
|
111
110
|
def _on_update_reply(self, reply: QNetworkReply):
|
|
112
111
|
"""Handle network reply from update check or download."""
|
|
113
112
|
interactive = bool(reply.property("interactive"))
|
|
114
|
-
|
|
113
|
+
|
|
115
114
|
# Was this the second request (the actual installer download)?
|
|
116
115
|
if bool(reply.property("is_update_download")):
|
|
117
116
|
self._on_windows_update_download_finished(reply)
|
|
118
117
|
return
|
|
119
|
-
|
|
118
|
+
|
|
120
119
|
try:
|
|
121
120
|
if reply.error() != QNetworkReply.NetworkError.NoError:
|
|
122
121
|
err = reply.errorString()
|
|
123
122
|
if self.statusBar():
|
|
124
|
-
self.statusBar().showMessage("Update check failed.", 5000)
|
|
123
|
+
self.statusBar().showMessage(self.tr("Update check failed."), 5000)
|
|
125
124
|
if interactive:
|
|
126
|
-
QMessageBox.warning(
|
|
127
|
-
|
|
125
|
+
QMessageBox.warning(
|
|
126
|
+
self, self.tr("Update Check Failed"),
|
|
127
|
+
self.tr("Unable to check for updates.\n\n{0}").format(err)
|
|
128
|
+
)
|
|
128
129
|
else:
|
|
129
130
|
print(f"[updates] check failed: {err}")
|
|
130
131
|
return
|
|
@@ -134,12 +135,14 @@ class UpdateMixin:
|
|
|
134
135
|
data = json.loads(raw.decode("utf-8"))
|
|
135
136
|
except Exception as je:
|
|
136
137
|
if self.statusBar():
|
|
137
|
-
self.statusBar().showMessage("Update check failed (bad JSON).", 5000)
|
|
138
|
+
self.statusBar().showMessage(self.tr("Update check failed (bad JSON)."), 5000)
|
|
138
139
|
if interactive:
|
|
139
|
-
QMessageBox.warning(
|
|
140
|
-
|
|
140
|
+
QMessageBox.warning(
|
|
141
|
+
self, self.tr("Update Check Failed"),
|
|
142
|
+
self.tr("Update JSON is invalid.\n\n{0}").format(str(je))
|
|
143
|
+
)
|
|
141
144
|
else:
|
|
142
|
-
print(f"[updates] bad JSON: {je}")
|
|
145
|
+
print(f"[updates] bad JSON: {je!r}")
|
|
143
146
|
return
|
|
144
147
|
|
|
145
148
|
latest_str = str(data.get("version", "")).strip()
|
|
@@ -148,25 +151,53 @@ class UpdateMixin:
|
|
|
148
151
|
|
|
149
152
|
if not latest_str:
|
|
150
153
|
if self.statusBar():
|
|
151
|
-
self.statusBar().showMessage("Update check failed (no
|
|
154
|
+
self.statusBar().showMessage(self.tr("Update check failed (no version)."), 5000)
|
|
152
155
|
if interactive:
|
|
153
|
-
QMessageBox.warning(
|
|
154
|
-
|
|
156
|
+
QMessageBox.warning(
|
|
157
|
+
self, self.tr("Update Check Failed"),
|
|
158
|
+
self.tr("Update JSON missing the 'version' field.")
|
|
159
|
+
)
|
|
155
160
|
else:
|
|
156
161
|
print("[updates] JSON missing 'version'")
|
|
157
162
|
return
|
|
158
163
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
# ---- PEP 440 version compare ----
|
|
165
|
+
try:
|
|
166
|
+
from packaging.version import Version
|
|
167
|
+
cur_v = Version(str(self._current_version_str).strip())
|
|
168
|
+
latest_v = Version(latest_str)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
if self.statusBar():
|
|
171
|
+
self.statusBar().showMessage(self.tr("Update check failed (version parse)."), 5000)
|
|
172
|
+
if interactive:
|
|
173
|
+
QMessageBox.warning(
|
|
174
|
+
self, self.tr("Update Check Failed"),
|
|
175
|
+
self.tr("Could not compare versions.\n\nCurrent: {0}\nLatest: {1}\n\n{2}")
|
|
176
|
+
.format(self._current_version_str, latest_str, str(e))
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
print(f"[updates] version parse failed: cur={self._current_version_str!r} latest={latest_str!r} err={e!r}")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
available = latest_v > cur_v
|
|
162
183
|
|
|
163
184
|
if available:
|
|
164
185
|
if self.statusBar():
|
|
165
186
|
self.statusBar().showMessage(self.tr("Update available: {0}").format(latest_str), 5000)
|
|
187
|
+
|
|
166
188
|
msg_box = QMessageBox(self)
|
|
167
189
|
msg_box.setIcon(QMessageBox.Icon.Information)
|
|
168
190
|
msg_box.setWindowTitle(self.tr("Update Available"))
|
|
169
|
-
|
|
191
|
+
installed_norm = str(cur_v)
|
|
192
|
+
reported_norm = str(latest_v)
|
|
193
|
+
|
|
194
|
+
msg_box.setText(
|
|
195
|
+
self.tr(
|
|
196
|
+
"An update is available!\n\n"
|
|
197
|
+
"Installed version: {0}\n"
|
|
198
|
+
"Available version: {1}"
|
|
199
|
+
).format(installed_norm, reported_norm)
|
|
200
|
+
)
|
|
170
201
|
if notes:
|
|
171
202
|
msg_box.setInformativeText(self.tr("Release Notes:\n{0}").format(notes))
|
|
172
203
|
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
@@ -178,27 +209,48 @@ class UpdateMixin:
|
|
|
178
209
|
|
|
179
210
|
if msg_box.exec() == QMessageBox.StandardButton.Yes:
|
|
180
211
|
plat = sys.platform
|
|
181
|
-
|
|
212
|
+
key = (
|
|
182
213
|
"Windows" if plat.startswith("win") else
|
|
183
|
-
"macOS"
|
|
184
|
-
"Linux"
|
|
214
|
+
"macOS" if plat.startswith("darwin") else
|
|
215
|
+
"Linux" if plat.startswith("linux") else
|
|
216
|
+
""
|
|
185
217
|
)
|
|
218
|
+
link = downloads.get(key, "")
|
|
186
219
|
if not link:
|
|
187
|
-
QMessageBox.warning(self, self.tr("Download"),
|
|
220
|
+
QMessageBox.warning(self, self.tr("Download"),
|
|
221
|
+
self.tr("No download link available for this platform."))
|
|
188
222
|
return
|
|
189
223
|
|
|
190
224
|
if plat.startswith("win"):
|
|
191
|
-
# Use in-app updater for Windows
|
|
192
225
|
self._start_windows_update_download(link)
|
|
193
226
|
else:
|
|
194
|
-
# Open browser for other platforms
|
|
195
227
|
webbrowser.open(link)
|
|
196
228
|
else:
|
|
197
229
|
if self.statusBar():
|
|
198
|
-
self.statusBar().showMessage("You're up to date.", 3000)
|
|
230
|
+
self.statusBar().showMessage(self.tr("You're up to date."), 3000)
|
|
231
|
+
|
|
199
232
|
if interactive:
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
# Use the same parsed versions you already computed
|
|
234
|
+
installed_str = str(self._current_version_str).strip()
|
|
235
|
+
reported_str = str(latest_str).strip()
|
|
236
|
+
|
|
237
|
+
# If you have cur_v/latest_v (packaging.Version), use their string forms too
|
|
238
|
+
try:
|
|
239
|
+
installed_norm = str(cur_v) # normalized PEP440 (e.g. 1.6.6.post3)
|
|
240
|
+
reported_norm = str(latest_v)
|
|
241
|
+
except Exception:
|
|
242
|
+
installed_norm = installed_str
|
|
243
|
+
reported_norm = reported_str
|
|
244
|
+
|
|
245
|
+
QMessageBox.information(
|
|
246
|
+
self,
|
|
247
|
+
self.tr("Up to Date"),
|
|
248
|
+
self.tr(
|
|
249
|
+
"You're already running the latest version.\n\n"
|
|
250
|
+
"Installed version: {0}\n"
|
|
251
|
+
"Update source reports: {1}"
|
|
252
|
+
).format(installed_norm, reported_norm)
|
|
253
|
+
)
|
|
202
254
|
finally:
|
|
203
255
|
reply.deleteLater()
|
|
204
256
|
|
|
@@ -321,3 +373,39 @@ class UpdateMixin:
|
|
|
321
373
|
|
|
322
374
|
# Close app so the installer can overwrite files
|
|
323
375
|
QApplication.instance().quit()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _normalize_version_str(self, v: str) -> str:
|
|
379
|
+
v = (v or "").strip()
|
|
380
|
+
# common cases: "v1.2.3", "Version 1.2.3", "1.2.3 (build xyz)"
|
|
381
|
+
v = re.sub(r'^[^\d]*', '', v) # strip leading non-digits
|
|
382
|
+
v = re.split(r'[\s\(\[]', v, 1)[0].strip() # stop at whitespace/( or [
|
|
383
|
+
return v
|
|
384
|
+
|
|
385
|
+
def _parse_version(self, v: str):
|
|
386
|
+
v = self._normalize_version_str(v)
|
|
387
|
+
if not v:
|
|
388
|
+
return None
|
|
389
|
+
# Prefer packaging if present
|
|
390
|
+
try:
|
|
391
|
+
from packaging.version import Version
|
|
392
|
+
return Version(v)
|
|
393
|
+
except Exception:
|
|
394
|
+
# Fallback: compare numeric dot parts only
|
|
395
|
+
parts = re.findall(r'\d+', v)
|
|
396
|
+
if not parts:
|
|
397
|
+
return None
|
|
398
|
+
# normalize length to 3+ (so 1.2 == 1.2.0)
|
|
399
|
+
nums = [int(x) for x in parts[:6]]
|
|
400
|
+
while len(nums) < 3:
|
|
401
|
+
nums.append(0)
|
|
402
|
+
return tuple(nums)
|
|
403
|
+
|
|
404
|
+
def _is_update_available(self, latest_str: str) -> bool:
|
|
405
|
+
cur = self._parse_version(self._current_version_str)
|
|
406
|
+
latest = self._parse_version(latest_str)
|
|
407
|
+
if cur is None or latest is None:
|
|
408
|
+
# If we cannot compare, do NOT claim "up to date".
|
|
409
|
+
# Treat as "unknown" and show a failure message in interactive mode.
|
|
410
|
+
return False
|
|
411
|
+
return latest > cur
|