Coverage for paintmaskanimator/main_window_update_notice.py: 83%
132 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""新しい版のお知らせ; owned by ``MainWindow`` as ``window.update_notice``.
3起動したあと裏で GitHub Releases を確かめ(:func:`updater.check_for_update`)、
4新しい版があればメニューバーの右端に目立つボタンを出す。ボタンから
5ダウンロードとインストール、リリースノートの表示、この版を知らせない設定を
6選べる。macOS のように画面上部の共通メニューバーを使う環境では、ボタンを
7置けないので、同じ中身のメニューをメニューバーの最後に足す。
9問い合わせはネットワークを待つので別のスレッドで行い、結果だけを Qt の
10シグナルで画面のスレッドへ戻す。失敗しても何も表示しない(ヘルプ ›
11更新を確認… ではこれまでどおりエラーを表示する)。起動したままのときも、
12:data:`RECHECK_HOURS` ごとに確かめ直す。
14A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
15"""
16from __future__ import annotations
18import threading
19from typing import TYPE_CHECKING, Optional
21from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
22from PySide6.QtGui import QAction, QDesktopServices
23from PySide6.QtWidgets import QMenu, QToolButton
25from . import constants, preferences, updater
26from .i18n import tr
27from .logging_setup import get_logger
29if TYPE_CHECKING:
30 from .main_window import MainWindow
32log = get_logger(__name__)
34# 起動してから最初に確かめるまでの時間(ミリ秒)。起動直後の処理と重ねない。
35FIRST_CHECK_DELAY_MS = 4000
36# 起動したままのとき、確かめ直す間隔(時間)。GitHub の API は認証なしだと
37# 1時間に60回までなので、十分に少ない。
38RECHECK_HOURS = 6
40_BUTTON_STYLE = """
41QToolButton#updateNotice {{
42 background-color: {accent};
43 color: white;
44 border: none;
45 border-radius: 9px;
46 padding: 1px 10px;
47 margin: 2px 6px;
48 font-weight: bold;
49}}
50QToolButton#updateNotice:hover {{ background-color: {hover}; }}
51QToolButton#updateNotice::menu-indicator {{ image: none; width: 0; }}
52"""
55class _ResultRelay(QObject):
56 """別のスレッドの結果を、画面のスレッドへ渡す。"""
58 finished = Signal(object)
61class UpdateNoticeController:
62 """Owned by ``MainWindow`` as ``window.update_notice``."""
64 def __init__(self, window: "MainWindow"):
65 self.window = window
66 self._relay = _ResultRelay()
67 self._relay.finished.connect(self._on_result)
68 self._checking = False
69 self._timer: Optional[QTimer] = None
70 # 今お知らせしている結果(:func:`updater.check_for_update` の戻り値)。
71 self.result: Optional[dict] = None
72 self._menu: Optional[QMenu] = None
73 self._button: Optional[QToolButton] = None
74 self._menu_action: Optional[QAction] = None
76 # ------------------------------------------------------------------
77 # 確かめる
78 # ------------------------------------------------------------------
79 def start(self):
80 """起動後の自動確認を始める(環境設定で OFF なら何もしない)。"""
81 if not preferences.check_updates_automatically(): 81 ↛ 83line 81 didn't jump to line 83 because the condition on line 81 was always true
82 return
83 QTimer.singleShot(FIRST_CHECK_DELAY_MS, self.check_in_background)
84 self._timer = QTimer(self.window)
85 self._timer.setInterval(RECHECK_HOURS * 60 * 60 * 1000)
86 self._timer.timeout.connect(self.check_in_background)
87 self._timer.start()
89 def check_in_background(self):
90 if self._checking or not preferences.check_updates_automatically():
91 return
92 self._checking = True
94 def work():
95 try:
96 result = updater.check_for_update()
97 except Exception as error: # noqa: BLE001 - お知らせは失敗しても黙る
98 log.warning("background update check failed: %s", error, exc_info=True)
99 result = {"status": "error", "message": str(error)}
100 self._relay.finished.emit(result)
102 threading.Thread(target=work, name="update-check", daemon=True).start()
104 def _on_result(self, result):
105 self._checking = False
106 self.apply_result(result)
108 def apply_result(self, result):
109 """確認の結果に合わせて、お知らせを出したり消したりする。"""
110 status = (result or {}).get("status")
111 if status == "error":
112 return # 一時的な失敗でお知らせを消さない
113 latest = str((result or {}).get("latest") or "")
114 if status == "update_available" and latest != preferences.skipped_update_version():
115 self.result = result
116 self._show(latest)
117 else:
118 self.result = None
119 self._hide()
121 # ------------------------------------------------------------------
122 # メニューバーの表示
123 # ------------------------------------------------------------------
124 def _title(self, latest):
125 return tr("新しいバージョン v{latest} があります").format(latest=latest)
127 def _build_menu(self, latest):
128 menu = QMenu(self._title(latest), self.window)
129 install = menu.addAction(tr("ダウンロードしてインストール…"))
130 install.triggered.connect(self.install)
131 notes = menu.addAction(tr("リリースノートを見る"))
132 notes.triggered.connect(self.open_release_notes)
133 menu.addSeparator()
134 skip = menu.addAction(tr("この版は知らせない"))
135 skip.triggered.connect(self.skip_this_version)
136 return menu
138 def _show(self, latest):
139 self._hide()
140 self._menu = self._build_menu(latest)
141 bar = self.window.menuBar()
142 if bar.isNativeMenuBar(): 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was never true
143 # 画面上部の共通メニューバーにはボタンを置けないので、メニューを足す。
144 self._menu_action = bar.addMenu(self._menu)
145 return
146 button = QToolButton(bar)
147 button.setObjectName("updateNotice")
148 button.setText(self._title(latest))
149 button.setToolTip(tr("クリックして更新できます(現在: v{version})").format(
150 version=constants.APP_VERSION
151 ))
152 button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
153 button.setMenu(self._menu)
154 button.setCursor(Qt.CursorShape.PointingHandCursor)
155 self._style_button(button)
156 bar.setCornerWidget(button, Qt.Corner.TopRightCorner)
157 button.show()
158 self._button = button
160 def _style_button(self, button):
161 from . import theme
162 from PySide6.QtGui import QColor
164 accent = QColor(str(theme.current_accent()))
165 button.setStyleSheet(_BUTTON_STYLE.format(
166 accent=accent.name(), hover=accent.darker(115).name()
167 ))
169 def refresh_style(self):
170 """テーマやアクセントカラーを変えたら呼ぶ。"""
171 if self._button is not None: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 self._style_button(self._button)
174 def _hide(self):
175 bar = self.window.menuBar()
176 if self._button is not None:
177 bar.setCornerWidget(None, Qt.Corner.TopRightCorner) # pyright: ignore[reportArgumentType] # Qt accepts None to clear the corner
178 self._button.deleteLater()
179 self._button = None
180 if self._menu_action is not None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 bar.removeAction(self._menu_action)
182 self._menu_action = None
183 if self._menu is not None:
184 self._menu.deleteLater()
185 self._menu = None
187 @property
188 def visible(self):
189 return self._button is not None or self._menu_action is not None
191 # ------------------------------------------------------------------
192 # メニューの項目
193 # ------------------------------------------------------------------
194 def install(self):
195 result = self.result
196 if not result: 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 return
198 asset = result.get("asset")
199 if not asset: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 self.open_release_notes()
201 return
202 self.window._download_and_run_installer(asset, result.get("latest", ""))
204 def open_release_notes(self):
205 release = (self.result or {}).get("release") or {}
206 url = release.get("html_url") or constants.RELEASES_URL
207 QDesktopServices.openUrl(QUrl(str(url)))
209 def skip_this_version(self):
210 if not self.result: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 return
212 preferences.set_skipped_update_version(str(self.result.get("latest") or ""))
213 self.result = None
214 self._hide()
217__all__ = ["UpdateNoticeController"]