pyappify 1.0.2__tar.gz → 1.0.4__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.
- {pyappify-1.0.2 → pyappify-1.0.4}/PKG-INFO +1 -1
- pyappify-1.0.4/pyappify/__init__.py +279 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/PKG-INFO +1 -1
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/SOURCES.txt +3 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyproject.toml +2 -2
- pyappify-1.0.4/tests/TestUpdateEnv.py +94 -0
- pyappify-1.0.4/tests/TestUpgrade.py +110 -0
- pyappify-1.0.4/tests/TestUpgradeOnline.py +21 -0
- pyappify-1.0.2/pyappify/__init__.py +0 -179
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify/main.py +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/dependency_links.txt +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/entry_points.txt +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/requires.txt +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/pyappify.egg-info/top_level.txt +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/setup.cfg +0 -0
- {pyappify-1.0.2 → pyappify-1.0.4}/tests/TestVersion.py +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# pyappify/__init__.py
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import urllib.request
|
|
9
|
+
import zipfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
app_version = os.environ.get("PYAPPIFY_APP_VERSION")
|
|
14
|
+
app_starting_version = os.environ.get("PYAPPIFY_APP_STARTING_VERSION") or app_version
|
|
15
|
+
update_note = os.environ.get("PYAPPIFY_UPDATE_NOTE")
|
|
16
|
+
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
17
|
+
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
18
|
+
pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
|
|
19
|
+
|
|
20
|
+
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
21
|
+
logger = None
|
|
22
|
+
_console_logger = None
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
26
|
+
except (ValueError, TypeError):
|
|
27
|
+
pid = None
|
|
28
|
+
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import ctypes
|
|
33
|
+
except ImportError:
|
|
34
|
+
ctypes = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_logger():
|
|
38
|
+
global _console_logger
|
|
39
|
+
|
|
40
|
+
if logger is not None:
|
|
41
|
+
return logger
|
|
42
|
+
if _console_logger is None:
|
|
43
|
+
_console_logger = logging.getLogger("pyappify")
|
|
44
|
+
if not _console_logger.handlers:
|
|
45
|
+
handler = logging.StreamHandler()
|
|
46
|
+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
47
|
+
_console_logger.addHandler(handler)
|
|
48
|
+
_console_logger.setLevel(logging.INFO)
|
|
49
|
+
_console_logger.propagate = False
|
|
50
|
+
return _console_logger
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def minimize_window_by_pid(pid):
|
|
54
|
+
if not ctypes or sys.platform != "win32":
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
found_hwnd = []
|
|
58
|
+
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int))
|
|
59
|
+
|
|
60
|
+
def enum_windows_callback(hwnd, lParam):
|
|
61
|
+
owner_pid = ctypes.c_ulong()
|
|
62
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
63
|
+
if owner_pid.value == pid and ctypes.windll.user32.IsWindowVisible(hwnd):
|
|
64
|
+
found_hwnd.append(hwnd)
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
|
|
69
|
+
|
|
70
|
+
if found_hwnd:
|
|
71
|
+
ctypes.windll.user32.ShowWindow(found_hwnd[0], 6)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def kill_pyappify():
|
|
77
|
+
if pid:
|
|
78
|
+
log = _get_logger()
|
|
79
|
+
log.info(f"Attempting to terminate process with PID: {pid}")
|
|
80
|
+
try:
|
|
81
|
+
os.kill(pid, signal.SIGTERM)
|
|
82
|
+
if not _wait_for_process_exit(pid):
|
|
83
|
+
log.warning(f"Timed out waiting for process with PID {pid} to exit.")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log.error(f"Failed to terminate process with PID {pid}: {e}")
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _wait_for_process_exit(process_pid, timeout=30):
|
|
90
|
+
if sys.platform == "win32" and ctypes:
|
|
91
|
+
synchronize = 0x00100000
|
|
92
|
+
wait_timeout = 0x00000102
|
|
93
|
+
wait_failed = 0xFFFFFFFF
|
|
94
|
+
ctypes.windll.kernel32.OpenProcess.argtypes = (
|
|
95
|
+
ctypes.c_uint,
|
|
96
|
+
ctypes.c_bool,
|
|
97
|
+
ctypes.c_ulong,
|
|
98
|
+
)
|
|
99
|
+
ctypes.windll.kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
100
|
+
ctypes.windll.kernel32.WaitForSingleObject.argtypes = (
|
|
101
|
+
ctypes.c_void_p,
|
|
102
|
+
ctypes.c_uint,
|
|
103
|
+
)
|
|
104
|
+
ctypes.windll.kernel32.WaitForSingleObject.restype = ctypes.c_uint
|
|
105
|
+
ctypes.windll.kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
|
|
106
|
+
ctypes.windll.kernel32.CloseHandle.restype = ctypes.c_bool
|
|
107
|
+
handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_pid)
|
|
108
|
+
if handle:
|
|
109
|
+
try:
|
|
110
|
+
result = ctypes.windll.kernel32.WaitForSingleObject(
|
|
111
|
+
handle, int(timeout * 1000)
|
|
112
|
+
)
|
|
113
|
+
if result == wait_failed:
|
|
114
|
+
return False
|
|
115
|
+
return result != wait_timeout
|
|
116
|
+
finally:
|
|
117
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
118
|
+
|
|
119
|
+
deadline = time.monotonic() + timeout
|
|
120
|
+
while time.monotonic() < deadline:
|
|
121
|
+
try:
|
|
122
|
+
os.kill(process_pid, 0)
|
|
123
|
+
except OSError:
|
|
124
|
+
return True
|
|
125
|
+
time.sleep(0.1)
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _replace_executable(source_path, target_path, timeout=30):
|
|
130
|
+
deadline = time.monotonic() + timeout
|
|
131
|
+
last_error = None
|
|
132
|
+
while True:
|
|
133
|
+
try:
|
|
134
|
+
shutil.move(source_path, target_path)
|
|
135
|
+
return
|
|
136
|
+
except PermissionError as e:
|
|
137
|
+
last_error = e
|
|
138
|
+
if time.monotonic() >= deadline:
|
|
139
|
+
raise last_error
|
|
140
|
+
time.sleep(0.25)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def hide_pyappify():
|
|
144
|
+
if pid:
|
|
145
|
+
log = _get_logger()
|
|
146
|
+
log.info(f"Attempting to minimize window for process with PID: {pid}")
|
|
147
|
+
try:
|
|
148
|
+
minimize_window_by_pid(pid)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
log.error(f"Failed to minimize window for process with PID {pid}: {e}")
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
|
|
154
|
+
log = _get_logger()
|
|
155
|
+
if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
|
|
156
|
+
log.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
157
|
+
return
|
|
158
|
+
log.info(
|
|
159
|
+
f"pyappify start to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
160
|
+
def _do_upgrade():
|
|
161
|
+
tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
|
|
162
|
+
try:
|
|
163
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
164
|
+
downloaded_zip_path = None
|
|
165
|
+
for url in executable_zip_urls:
|
|
166
|
+
try:
|
|
167
|
+
log.info(
|
|
168
|
+
f"pyappify start to download {url}")
|
|
169
|
+
local_zip_path = os.path.join(tmp_dir, os.path.basename(url))
|
|
170
|
+
with urllib.request.urlopen(url) as response, open(local_zip_path, 'wb') as out_file:
|
|
171
|
+
while True:
|
|
172
|
+
if stop_event and stop_event.is_set():
|
|
173
|
+
log.info("pyappify Upgrade download cancelled by stop event.")
|
|
174
|
+
return
|
|
175
|
+
chunk = response.read(8192)
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
out_file.write(chunk)
|
|
179
|
+
downloaded_zip_path = local_zip_path
|
|
180
|
+
log.info(
|
|
181
|
+
f"pyappify download success {url}")
|
|
182
|
+
break
|
|
183
|
+
except Exception as e:
|
|
184
|
+
log.warning(f"pyappify Failed to download from {url}: {e}")
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if not downloaded_zip_path:
|
|
188
|
+
log.error("pyappify Failed to download upgrade.")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
192
|
+
zip_ref.extractall(tmp_dir)
|
|
193
|
+
|
|
194
|
+
new_executable_name = os.path.basename(pyappify_executable)
|
|
195
|
+
found_executable_path = None
|
|
196
|
+
for root, _, files in os.walk(tmp_dir):
|
|
197
|
+
if new_executable_name in files:
|
|
198
|
+
found_executable_path = os.path.join(root, new_executable_name)
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
if not found_executable_path:
|
|
202
|
+
log.error("pyappify Executable not found in zip.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
sha256_hash = hashlib.sha256()
|
|
206
|
+
with open(found_executable_path, "rb") as f:
|
|
207
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
208
|
+
sha256_hash.update(byte_block)
|
|
209
|
+
|
|
210
|
+
if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
|
|
211
|
+
log.error("pyappify SHA256 checksum mismatch.")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
kill_pyappify()
|
|
215
|
+
_replace_executable(found_executable_path, pyappify_executable)
|
|
216
|
+
log.info(f"pyappify Upgrade success")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
log.error(f"pyappify Upgrade failed: {e}")
|
|
219
|
+
finally:
|
|
220
|
+
if os.path.exists(tmp_dir):
|
|
221
|
+
shutil.rmtree(tmp_dir)
|
|
222
|
+
|
|
223
|
+
thread = threading.Thread(target=_do_upgrade)
|
|
224
|
+
thread.daemon = True
|
|
225
|
+
thread.start()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_app_updated():
|
|
229
|
+
return is_greater_version(app_version, app_starting_version)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def is_app_downgraded():
|
|
233
|
+
return is_greater_version(app_starting_version, app_version)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def is_updated():
|
|
237
|
+
return is_app_updated()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_downgrade():
|
|
241
|
+
return is_app_downgraded()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_update_notes():
|
|
245
|
+
if not update_note:
|
|
246
|
+
return []
|
|
247
|
+
try:
|
|
248
|
+
notes = json.loads(update_note)
|
|
249
|
+
except (TypeError, ValueError):
|
|
250
|
+
return []
|
|
251
|
+
if isinstance(notes, list):
|
|
252
|
+
return [str(note) for note in notes]
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_update_note():
|
|
257
|
+
return get_update_notes()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_greater_version(version1, version2):
|
|
261
|
+
"""
|
|
262
|
+
Compares two semantic version strings.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
version1 (str): The first version string.
|
|
266
|
+
version2 (str): The second version string.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
bool: True if version1 is strictly greater than version2,
|
|
270
|
+
False otherwise or if parsing fails.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
version1 = version1.lstrip('v')
|
|
274
|
+
version2 = version2.lstrip('v')
|
|
275
|
+
v1_parts = [int(p) for p in version1.split('.')]
|
|
276
|
+
v2_parts = [int(p) for p in version2.split('.')]
|
|
277
|
+
return v1_parts > v2_parts
|
|
278
|
+
except (ValueError, AttributeError):
|
|
279
|
+
return False
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyappify"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "My awesome Python application"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
@@ -22,4 +22,4 @@ dev = ["twine", "build"]
|
|
|
22
22
|
|
|
23
23
|
[tool.setuptools.packages.find]
|
|
24
24
|
where = ["."]
|
|
25
|
-
include = ["pyappify"]
|
|
25
|
+
include = ["pyappify"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import pyappify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ENV_KEYS = (
|
|
9
|
+
"PYAPPIFY_APP_VERSION",
|
|
10
|
+
"PYAPPIFY_APP_STARTING_VERSION",
|
|
11
|
+
"PYAPPIFY_UPDATE_NOTE",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestUpdateEnv(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self._saved_env = {key: os.environ.get(key) for key in ENV_KEYS}
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
for key, value in self._saved_env.items():
|
|
21
|
+
if value is None:
|
|
22
|
+
os.environ.pop(key, None)
|
|
23
|
+
else:
|
|
24
|
+
os.environ[key] = value
|
|
25
|
+
importlib.reload(pyappify)
|
|
26
|
+
|
|
27
|
+
def _reload_with_env(self, **env):
|
|
28
|
+
for key in ENV_KEYS:
|
|
29
|
+
os.environ.pop(key, None)
|
|
30
|
+
for key, value in env.items():
|
|
31
|
+
os.environ[key] = value
|
|
32
|
+
return importlib.reload(pyappify)
|
|
33
|
+
|
|
34
|
+
def test_starting_version_falls_back_to_app_version(self):
|
|
35
|
+
module = self._reload_with_env(PYAPPIFY_APP_VERSION="1.2.3")
|
|
36
|
+
|
|
37
|
+
self.assertEqual("1.2.3", module.app_version)
|
|
38
|
+
self.assertEqual("1.2.3", module.app_starting_version)
|
|
39
|
+
self.assertFalse(module.is_app_updated())
|
|
40
|
+
self.assertFalse(module.is_updated())
|
|
41
|
+
self.assertFalse(module.is_app_downgraded())
|
|
42
|
+
self.assertFalse(module.is_downgrade())
|
|
43
|
+
|
|
44
|
+
def test_missing_versions_are_not_update_or_downgrade(self):
|
|
45
|
+
module = self._reload_with_env()
|
|
46
|
+
|
|
47
|
+
self.assertIsNone(module.app_version)
|
|
48
|
+
self.assertIsNone(module.app_starting_version)
|
|
49
|
+
self.assertFalse(module.is_app_updated())
|
|
50
|
+
self.assertFalse(module.is_app_downgraded())
|
|
51
|
+
|
|
52
|
+
def test_detects_update_and_downgrade(self):
|
|
53
|
+
module = self._reload_with_env(
|
|
54
|
+
PYAPPIFY_APP_VERSION="2.0.0",
|
|
55
|
+
PYAPPIFY_APP_STARTING_VERSION="1.0.0",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self.assertTrue(module.is_app_updated())
|
|
59
|
+
self.assertTrue(module.is_updated())
|
|
60
|
+
self.assertFalse(module.is_app_downgraded())
|
|
61
|
+
self.assertFalse(module.is_downgrade())
|
|
62
|
+
|
|
63
|
+
module = self._reload_with_env(
|
|
64
|
+
PYAPPIFY_APP_VERSION="1.0.0",
|
|
65
|
+
PYAPPIFY_APP_STARTING_VERSION="2.0.0",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.assertFalse(module.is_app_updated())
|
|
69
|
+
self.assertFalse(module.is_updated())
|
|
70
|
+
self.assertTrue(module.is_app_downgraded())
|
|
71
|
+
self.assertTrue(module.is_downgrade())
|
|
72
|
+
|
|
73
|
+
def test_returns_update_notes_from_json_array(self):
|
|
74
|
+
module = self._reload_with_env(
|
|
75
|
+
PYAPPIFY_UPDATE_NOTE='["first change", "second change"]'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.assertEqual(["first change", "second change"], module.get_update_notes())
|
|
79
|
+
self.assertEqual(["first change", "second change"], module.get_update_note())
|
|
80
|
+
|
|
81
|
+
def test_invalid_or_missing_update_notes_return_empty_list(self):
|
|
82
|
+
self.assertEqual([], self._reload_with_env().get_update_notes())
|
|
83
|
+
self.assertEqual(
|
|
84
|
+
[],
|
|
85
|
+
self._reload_with_env(PYAPPIFY_UPDATE_NOTE="not json").get_update_notes(),
|
|
86
|
+
)
|
|
87
|
+
self.assertEqual(
|
|
88
|
+
[],
|
|
89
|
+
self._reload_with_env(PYAPPIFY_UPDATE_NOTE='"single note"').get_update_notes(),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pyappify
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
EXECUTABLE_SHA256 = "50ef2557c193ca1f97c1e699bab3dad35b3966c9e8e8b860fb609bd5fb3861d1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sha256(path):
|
|
18
|
+
digest = hashlib.sha256()
|
|
19
|
+
with open(path, "rb") as file:
|
|
20
|
+
for block in iter(lambda: file.read(1024 * 1024), b""):
|
|
21
|
+
digest.update(block)
|
|
22
|
+
return digest.hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def assert_upgrade_kills_running_executable_and_replaces_it(
|
|
26
|
+
test_case, zip_url, timeout_seconds
|
|
27
|
+
):
|
|
28
|
+
source_executable = Path(os.environ["WINDIR"]) / "System32" / "PING.EXE"
|
|
29
|
+
test_case.assertTrue(source_executable.exists(), "Windows PING.EXE is required")
|
|
30
|
+
|
|
31
|
+
old_cwd = os.getcwd()
|
|
32
|
+
old_upgradeable = pyappify.pyappify_upgradeable
|
|
33
|
+
old_version = pyappify.pyappify_version
|
|
34
|
+
old_executable = pyappify.pyappify_executable
|
|
35
|
+
old_pid = pyappify.pid
|
|
36
|
+
old_logger = pyappify.logger
|
|
37
|
+
|
|
38
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
39
|
+
executable = Path(temporary_directory) / "ok-ww.exe"
|
|
40
|
+
upgrade_temporary_directory = Path(temporary_directory) / "pyappify_tmp"
|
|
41
|
+
shutil.copy2(source_executable, executable)
|
|
42
|
+
test_case.assertNotEqual(_sha256(executable), EXECUTABLE_SHA256)
|
|
43
|
+
|
|
44
|
+
process = subprocess.Popen(
|
|
45
|
+
[str(executable), "-t", "127.0.0.1"],
|
|
46
|
+
cwd=temporary_directory,
|
|
47
|
+
stdout=subprocess.DEVNULL,
|
|
48
|
+
stderr=subprocess.DEVNULL,
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
time.sleep(1)
|
|
52
|
+
test_case.assertIsNone(
|
|
53
|
+
process.poll(), "the current executable exited before upgrade began"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
pyappify.pyappify_upgradeable = True
|
|
57
|
+
pyappify.pyappify_version = "v3.3.45"
|
|
58
|
+
pyappify.pyappify_executable = str(executable)
|
|
59
|
+
pyappify.pid = process.pid
|
|
60
|
+
pyappify.logger = logging.getLogger(__name__)
|
|
61
|
+
os.chdir(temporary_directory)
|
|
62
|
+
|
|
63
|
+
pyappify.upgrade("v3.3.46", EXECUTABLE_SHA256, [zip_url])
|
|
64
|
+
|
|
65
|
+
deadline = time.monotonic() + timeout_seconds
|
|
66
|
+
while time.monotonic() < deadline:
|
|
67
|
+
process_stopped = process.poll() is not None
|
|
68
|
+
upgraded = (
|
|
69
|
+
executable.exists()
|
|
70
|
+
and _sha256(executable) == EXECUTABLE_SHA256
|
|
71
|
+
)
|
|
72
|
+
update_finished = not upgrade_temporary_directory.exists()
|
|
73
|
+
if process_stopped and upgraded and update_finished:
|
|
74
|
+
break
|
|
75
|
+
time.sleep(0.1)
|
|
76
|
+
|
|
77
|
+
test_case.assertIsNotNone(
|
|
78
|
+
process.poll(), "upgrade did not stop the old process"
|
|
79
|
+
)
|
|
80
|
+
test_case.assertEqual(EXECUTABLE_SHA256, _sha256(executable))
|
|
81
|
+
finally:
|
|
82
|
+
os.chdir(old_cwd)
|
|
83
|
+
pyappify.pyappify_upgradeable = old_upgradeable
|
|
84
|
+
pyappify.pyappify_version = old_version
|
|
85
|
+
pyappify.pyappify_executable = old_executable
|
|
86
|
+
pyappify.pid = old_pid
|
|
87
|
+
pyappify.logger = old_logger
|
|
88
|
+
if process.poll() is None:
|
|
89
|
+
process.terminate()
|
|
90
|
+
try:
|
|
91
|
+
process.wait(timeout=5)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
process.kill()
|
|
94
|
+
process.wait(timeout=5)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestUpgrade(unittest.TestCase):
|
|
98
|
+
LOCAL_ZIP = Path(__file__).with_name("ok-ww-win32.zip")
|
|
99
|
+
|
|
100
|
+
@unittest.skipUnless(os.name == "nt", "executable replacement test is Windows-only")
|
|
101
|
+
def test_upgrade_from_local_zip_kills_running_executable_and_replaces_it(self):
|
|
102
|
+
self.assertTrue(self.LOCAL_ZIP.exists(), "tests/ok-ww-win32.zip is required")
|
|
103
|
+
assert_upgrade_kills_running_executable_and_replaces_it(
|
|
104
|
+
self,
|
|
105
|
+
self.LOCAL_ZIP.as_uri(), timeout_seconds=30
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
unittest.main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from tests.TestUpgrade import assert_upgrade_kills_running_executable_and_replaces_it
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestUpgradeOnline(unittest.TestCase):
|
|
8
|
+
ZIP_URL = (
|
|
9
|
+
"https://github.com/ok-oldking/ok-wuthering-waves/"
|
|
10
|
+
"releases/download/v3.3.46/ok-ww-win32.zip"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
@unittest.skipUnless(os.name == "nt", "executable replacement test is Windows-only")
|
|
14
|
+
def test_upgrade_from_online_zip_kills_running_executable_and_replaces_it(self):
|
|
15
|
+
assert_upgrade_kills_running_executable_and_replaces_it(
|
|
16
|
+
self, self.ZIP_URL, timeout_seconds=600
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
unittest.main()
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
# pyappify/__init__.py
|
|
2
|
-
import os
|
|
3
|
-
import signal
|
|
4
|
-
import hashlib
|
|
5
|
-
import shutil
|
|
6
|
-
import urllib.request
|
|
7
|
-
import zipfile
|
|
8
|
-
import threading
|
|
9
|
-
|
|
10
|
-
app_version = os.environ.get("PYAPPIFY_APP_VERSION")
|
|
11
|
-
app_profile = os.environ.get("PYAPPIFY_APP_PROFILE")
|
|
12
|
-
pyappify_version = os.environ.get("PYAPPIFY_VERSION")
|
|
13
|
-
pyappify_executable = os.environ.get("PYAPPIFY_EXECUTABLE")
|
|
14
|
-
|
|
15
|
-
pyappify_upgradeable = os.environ.get("PYAPPIFY_UPGRADEABLE") == '1'
|
|
16
|
-
logger = None
|
|
17
|
-
|
|
18
|
-
try:
|
|
19
|
-
pid = int(os.environ.get("PYAPPIFY_PID"))
|
|
20
|
-
except (ValueError, TypeError):
|
|
21
|
-
pid = None
|
|
22
|
-
|
|
23
|
-
import sys
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
import ctypes
|
|
27
|
-
except ImportError:
|
|
28
|
-
ctypes = None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def minimize_window_by_pid(pid):
|
|
32
|
-
if not ctypes or sys.platform != "win32":
|
|
33
|
-
return False
|
|
34
|
-
|
|
35
|
-
found_hwnd = []
|
|
36
|
-
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int))
|
|
37
|
-
|
|
38
|
-
def enum_windows_callback(hwnd, lParam):
|
|
39
|
-
owner_pid = ctypes.c_ulong()
|
|
40
|
-
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner_pid))
|
|
41
|
-
if owner_pid.value == pid and ctypes.windll.user32.IsWindowVisible(hwnd):
|
|
42
|
-
found_hwnd.append(hwnd)
|
|
43
|
-
return False
|
|
44
|
-
return True
|
|
45
|
-
|
|
46
|
-
ctypes.windll.user32.EnumWindows(EnumWindowsProc(enum_windows_callback), 0)
|
|
47
|
-
|
|
48
|
-
if found_hwnd:
|
|
49
|
-
ctypes.windll.user32.ShowWindow(found_hwnd[0], 6)
|
|
50
|
-
return True
|
|
51
|
-
|
|
52
|
-
return False
|
|
53
|
-
|
|
54
|
-
def kill_pyappify():
|
|
55
|
-
if pid:
|
|
56
|
-
if logger:
|
|
57
|
-
logger.info(f"Attempting to terminate process with PID: {pid}")
|
|
58
|
-
try:
|
|
59
|
-
os.kill(pid, signal.SIGTERM)
|
|
60
|
-
except Exception as e:
|
|
61
|
-
if logger:
|
|
62
|
-
logger.error(f"Failed to terminate process with PID {pid}: {e}")
|
|
63
|
-
pass
|
|
64
|
-
|
|
65
|
-
def hide_pyappify():
|
|
66
|
-
if pid:
|
|
67
|
-
if logger:
|
|
68
|
-
logger.info(f"Attempting to minimize window for process with PID: {pid}")
|
|
69
|
-
try:
|
|
70
|
-
minimize_window_by_pid(pid)
|
|
71
|
-
except Exception as e:
|
|
72
|
-
if logger:
|
|
73
|
-
logger.error(f"Failed to minimize window for process with PID {pid}: {e}")
|
|
74
|
-
pass
|
|
75
|
-
|
|
76
|
-
def upgrade(to_version, executable_sha256, executable_zip_urls, stop_event=None):
|
|
77
|
-
if not pyappify_upgradeable or not is_greater_version(to_version, pyappify_version):
|
|
78
|
-
if logger:
|
|
79
|
-
logger.info(f"pyappify no need to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
80
|
-
return
|
|
81
|
-
if logger:
|
|
82
|
-
logger.info(
|
|
83
|
-
f"pyappify start to upgrade {pyappify_upgradeable} {to_version} {executable_sha256} {executable_zip_urls}")
|
|
84
|
-
def _do_upgrade():
|
|
85
|
-
tmp_dir = os.path.join(os.getcwd(), "pyappify_tmp")
|
|
86
|
-
try:
|
|
87
|
-
os.makedirs(tmp_dir, exist_ok=True)
|
|
88
|
-
downloaded_zip_path = None
|
|
89
|
-
for url in executable_zip_urls:
|
|
90
|
-
try:
|
|
91
|
-
if logger:
|
|
92
|
-
logger.info(
|
|
93
|
-
f"pyappify start to download {url}")
|
|
94
|
-
local_zip_path = os.path.join(tmp_dir, os.path.basename(url))
|
|
95
|
-
with urllib.request.urlopen(url) as response, open(local_zip_path, 'wb') as out_file:
|
|
96
|
-
while True:
|
|
97
|
-
if stop_event and stop_event.is_set():
|
|
98
|
-
if logger:
|
|
99
|
-
logger.info("pyappify Upgrade download cancelled by stop event.")
|
|
100
|
-
return
|
|
101
|
-
chunk = response.read(8192)
|
|
102
|
-
if not chunk:
|
|
103
|
-
break
|
|
104
|
-
out_file.write(chunk)
|
|
105
|
-
downloaded_zip_path = local_zip_path
|
|
106
|
-
if logger:
|
|
107
|
-
logger.info(
|
|
108
|
-
f"pyappify download success {url}")
|
|
109
|
-
break
|
|
110
|
-
except Exception as e:
|
|
111
|
-
if logger:
|
|
112
|
-
logger.warning(f"pyappify Failed to download from {url}: {e}")
|
|
113
|
-
continue
|
|
114
|
-
|
|
115
|
-
if not downloaded_zip_path:
|
|
116
|
-
if logger:
|
|
117
|
-
logger.error("pyappify Failed to download upgrade.")
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
with zipfile.ZipFile(downloaded_zip_path, 'r') as zip_ref:
|
|
121
|
-
zip_ref.extractall(tmp_dir)
|
|
122
|
-
|
|
123
|
-
new_executable_name = os.path.basename(pyappify_executable)
|
|
124
|
-
found_executable_path = None
|
|
125
|
-
for root, _, files in os.walk(tmp_dir):
|
|
126
|
-
if new_executable_name in files:
|
|
127
|
-
found_executable_path = os.path.join(root, new_executable_name)
|
|
128
|
-
break
|
|
129
|
-
|
|
130
|
-
if not found_executable_path:
|
|
131
|
-
if logger:
|
|
132
|
-
logger.error("pyappify Executable not found in zip.")
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
sha256_hash = hashlib.sha256()
|
|
136
|
-
with open(found_executable_path, "rb") as f:
|
|
137
|
-
for byte_block in iter(lambda: f.read(4096), b""):
|
|
138
|
-
sha256_hash.update(byte_block)
|
|
139
|
-
|
|
140
|
-
if executable_sha256 and sha256_hash.hexdigest() != executable_sha256:
|
|
141
|
-
if logger:
|
|
142
|
-
logger.error("pyappify SHA256 checksum mismatch.")
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
kill_pyappify()
|
|
146
|
-
shutil.move(found_executable_path, pyappify_executable)
|
|
147
|
-
if logger:
|
|
148
|
-
logger.info(f"pyappify Upgrade success")
|
|
149
|
-
except Exception as e:
|
|
150
|
-
if logger:
|
|
151
|
-
logger.error(f"pyappify Upgrade failed: {e}")
|
|
152
|
-
finally:
|
|
153
|
-
if os.path.exists(tmp_dir):
|
|
154
|
-
shutil.rmtree(tmp_dir)
|
|
155
|
-
|
|
156
|
-
thread = threading.Thread(target=_do_upgrade)
|
|
157
|
-
thread.daemon = True
|
|
158
|
-
thread.start()
|
|
159
|
-
|
|
160
|
-
def is_greater_version(version1, version2):
|
|
161
|
-
"""
|
|
162
|
-
Compares two semantic version strings.
|
|
163
|
-
|
|
164
|
-
Args:
|
|
165
|
-
version1 (str): The first version string.
|
|
166
|
-
version2 (str): The second version string.
|
|
167
|
-
|
|
168
|
-
Returns:
|
|
169
|
-
bool: True if version1 is strictly greater than version2,
|
|
170
|
-
False otherwise or if parsing fails.
|
|
171
|
-
"""
|
|
172
|
-
try:
|
|
173
|
-
version1 = version1.lstrip('v')
|
|
174
|
-
version2 = version2.lstrip('v')
|
|
175
|
-
v1_parts = [int(p) for p in version1.split('.')]
|
|
176
|
-
v2_parts = [int(p) for p in version2.split('.')]
|
|
177
|
-
return v1_parts > v2_parts
|
|
178
|
-
except (ValueError, AttributeError):
|
|
179
|
-
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|