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

1"""新しい版のお知らせ; owned by ``MainWindow`` as ``window.update_notice``. 

2 

3起動したあと裏で GitHub Releases を確かめ(:func:`updater.check_for_update`)、 

4新しい版があればメニューバーの右端に目立つボタンを出す。ボタンから 

5ダウンロードとインストール、リリースノートの表示、この版を知らせない設定を 

6選べる。macOS のように画面上部の共通メニューバーを使う環境では、ボタンを 

7置けないので、同じ中身のメニューをメニューバーの最後に足す。 

8 

9問い合わせはネットワークを待つので別のスレッドで行い、結果だけを Qt の 

10シグナルで画面のスレッドへ戻す。失敗しても何も表示しない(ヘルプ › 

11更新を確認… ではこれまでどおりエラーを表示する)。起動したままのときも、 

12:data:`RECHECK_HOURS` ごとに確かめ直す。 

13 

14A collaborator rather than a mixin -- see ``main_window_export.py`` for why. 

15""" 

16from __future__ import annotations 

17 

18import threading 

19from typing import TYPE_CHECKING, Optional 

20 

21from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal 

22from PySide6.QtGui import QAction, QDesktopServices 

23from PySide6.QtWidgets import QMenu, QToolButton 

24 

25from . import constants, preferences, updater 

26from .i18n import tr 

27from .logging_setup import get_logger 

28 

29if TYPE_CHECKING: 

30 from .main_window import MainWindow 

31 

32log = get_logger(__name__) 

33 

34# 起動してから最初に確かめるまでの時間(ミリ秒)。起動直後の処理と重ねない。 

35FIRST_CHECK_DELAY_MS = 4000 

36# 起動したままのとき、確かめ直す間隔(時間)。GitHub の API は認証なしだと 

37# 1時間に60回までなので、十分に少ない。 

38RECHECK_HOURS = 6 

39 

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""" 

53 

54 

55class _ResultRelay(QObject): 

56 """別のスレッドの結果を、画面のスレッドへ渡す。""" 

57 

58 finished = Signal(object) 

59 

60 

61class UpdateNoticeController: 

62 """Owned by ``MainWindow`` as ``window.update_notice``.""" 

63 

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 

75 

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() 

88 

89 def check_in_background(self): 

90 if self._checking or not preferences.check_updates_automatically(): 

91 return 

92 self._checking = True 

93 

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) 

101 

102 threading.Thread(target=work, name="update-check", daemon=True).start() 

103 

104 def _on_result(self, result): 

105 self._checking = False 

106 self.apply_result(result) 

107 

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() 

120 

121 # ------------------------------------------------------------------ 

122 # メニューバーの表示 

123 # ------------------------------------------------------------------ 

124 def _title(self, latest): 

125 return tr("新しいバージョン v{latest} があります").format(latest=latest) 

126 

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 

137 

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 

159 

160 def _style_button(self, button): 

161 from . import theme 

162 from PySide6.QtGui import QColor 

163 

164 accent = QColor(str(theme.current_accent())) 

165 button.setStyleSheet(_BUTTON_STYLE.format( 

166 accent=accent.name(), hover=accent.darker(115).name() 

167 )) 

168 

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) 

173 

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 

186 

187 @property 

188 def visible(self): 

189 return self._button is not None or self._menu_action is not None 

190 

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", "")) 

203 

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))) 

208 

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() 

215 

216 

217__all__ = ["UpdateNoticeController"]