Coverage for graphica/gui/updater.py: 97.7%
73 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""アプリの中からの更新。配布の形を見分け、Windows のインストーラー版はインストーラーを落として確かめ、実行する。
3ほかの形(zip・macOS・pip・ソース)は、それぞれに合った案内だけにする(署名していない .app を自分で差し替えると壊れやすい)。
4"""
5import hashlib
6import json
7import os
8import subprocess
9import sys
10import tempfile
11import urllib.request
13# アプリの中からの更新でインストーラーに渡す引数。/RELAUNCH=1 のときだけ、入れ終わったら起動し直す(installer/graphica.iss)。
14# 閉じるのは Graphica 自身が先に済ませる(未保存の確認のため)。/CLOSEAPPLICATIONS は終わりきる前に始まったときの保険
15INSTALLER_UPDATE_ARGS = ('/SILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/RELAUNCH=1')
16_CHUNK_BYTES = 1 << 16
17_DOWNLOAD_TIMEOUT_SEC = 30
20class DownloadCancelled(Exception):
21 pass
24def install_kind():
25 """'installer'(Windows のインストーラー版)/ 'windows_zip' / 'macos' / 'pip' / 'source'。"""
26 if getattr(sys, 'frozen', False):
27 if sys.platform == 'darwin':
28 return 'macos'
29 # インストーラーで入れたフォルダには、Inno Setup のアンインストーラーがある
30 app_dir = os.path.dirname(sys.executable)
31 return 'installer' if os.path.exists(os.path.join(app_dir, 'unins000.exe')) else 'windows_zip'
32 return 'source' if _is_editable_install() else 'pip'
35def _is_editable_install():
36 """入れていない(ソースのまま)か、pip install -e で入れたなら True。PyPI から入れたものには direct_url.json が無い。"""
37 from importlib.metadata import PackageNotFoundError, distribution
38 try:
39 direct_url = distribution('graphica-plot').read_text('direct_url.json') or ''
40 except PackageNotFoundError:
41 return True
42 try:
43 info = json.loads(direct_url) if direct_url else {}
44 except ValueError:
45 return False
46 return bool((info.get('dir_info') or {}).get('editable'))
49def update_download_dir():
50 path = os.path.join(tempfile.gettempdir(), 'Graphica-update')
51 os.makedirs(path, exist_ok=True)
52 return path
55def download_file(url, dest_path, expected_size=0, expected_sha256='', report_progress=None, is_cancelled=None):
56 """url を dest_path に落とす。大きさと SHA-256 が分かっていれば確かめ、合わなければ消して例外。TaskRunner 用。
58 途中の内容は .part に書き、確かめ終えてから名前を変える(壊れたインストーラーを実行しないように)。
59 キャンセルされたら None。進み具合は KB 単位(Qt の int に収める)。
60 """
61 partial_path = dest_path + '.part'
62 digest = hashlib.sha256()
63 received = 0
64 request = urllib.request.Request(url, headers={'User-Agent': 'Graphica-updater'})
65 try:
66 with urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT_SEC) as response, \
67 open(partial_path, 'wb') as out:
68 total = int(response.headers.get('Content-Length') or expected_size or 0)
69 while True:
70 if is_cancelled is not None and is_cancelled():
71 raise DownloadCancelled()
72 block = response.read(_CHUNK_BYTES)
73 if not block:
74 break
75 out.write(block)
76 digest.update(block)
77 received += len(block)
78 if report_progress is not None:
79 report_progress(received // 1024, total // 1024)
80 if expected_size and received != expected_size:
81 raise ValueError(f"ダウンロードした大きさ({received:,} バイト)が、公開されている大きさ({expected_size:,} バイト)と違います。")
82 if expected_sha256 and digest.hexdigest() != expected_sha256.lower():
83 raise ValueError("ダウンロードしたファイルの SHA-256 が、公開されている値と一致しません。")
84 os.replace(partial_path, dest_path)
85 return dest_path
86 except DownloadCancelled:
87 return None
88 finally:
89 # 名前を変えたあとは .part は無い。失敗・キャンセルで残った途中のファイルだけ消える
90 _remove_quietly(partial_path)
93def _remove_quietly(path):
94 try:
95 os.remove(path)
96 except OSError:
97 pass
100def launch_installer(installer_path):
101 """インストーラーを Graphica と切り離して起動する。ログは同じフォルダに残す(更新に失敗したときの調べ用)。"""
102 log_path = os.path.join(os.path.dirname(installer_path), 'Graphica-update.log')
103 args = [installer_path, *INSTALLER_UPDATE_ARGS, f'/LOG={log_path}']
104 flags = getattr(subprocess, 'DETACHED_PROCESS', 0) | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
105 subprocess.Popen(args, creationflags=flags, close_fds=True)
106 return args