exp-runtime-player 0.5.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.
- exp_runtime/app_icon.py +12 -0
- exp_runtime/app_info.py +16 -0
- exp_runtime/application.py +825 -0
- exp_runtime/assets/kiwi.svg +49 -0
- exp_runtime/desktop.py +726 -0
- exp_runtime/desktop_choice.py +202 -0
- exp_runtime/desktop_dialogue.py +271 -0
- exp_runtime/desktop_input.py +176 -0
- exp_runtime/desktop_loading.py +45 -0
- exp_runtime/desktop_menu.py +448 -0
- exp_runtime/desktop_message.py +123 -0
- exp_runtime/desktop_pause.py +105 -0
- exp_runtime/desktop_picker.py +68 -0
- exp_runtime/desktop_text.py +88 -0
- exp_runtime/desktop_title.py +82 -0
- exp_runtime/graphics.py +79 -0
- exp_runtime/platforms/__init__.py +1 -0
- exp_runtime/platforms/drawing.py +612 -0
- exp_runtime/platforms/ios.py +225 -0
- exp_runtime/pointer.py +25 -0
- exp_runtime/projection.py +68 -0
- exp_runtime/settings.py +46 -0
- exp_runtime/updates.py +144 -0
- exp_runtime_player-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_player-0.5.0.dist-info/RECORD +29 -0
- exp_runtime_player-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_player-0.5.0.dist-info/entry_points.txt +3 -0
- exp_runtime_player-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_player-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Serial JSON bridge between UIKit and the shared application/presentation.
|
|
2
|
+
|
|
3
|
+
Swift owns device services only. Imports, menus, hit testing, clocks, game
|
|
4
|
+
sessions, layout and saves all run through the desktop application's code.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from concurrent.futures import Future
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
from .drawing import Backend, FrameEncoder
|
|
14
|
+
|
|
15
|
+
_host = None
|
|
16
|
+
_verifying = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Host:
|
|
20
|
+
def __init__(self, directory, *, backend=None, library_root=None, check_updates=True):
|
|
21
|
+
from .. import graphics
|
|
22
|
+
if backend is None:
|
|
23
|
+
import exp_platform
|
|
24
|
+
def font(request):
|
|
25
|
+
return json.loads(exp_platform.font(json.dumps(request)))
|
|
26
|
+
backend = Backend(font, Path(__file__).resolve().parents[3] / 'kiwi.png')
|
|
27
|
+
self.backend = backend
|
|
28
|
+
graphics.install(backend)
|
|
29
|
+
# Import after installing the backend: pygame is not bundled on iOS.
|
|
30
|
+
from ..application import Application
|
|
31
|
+
self.directory = Path(directory)
|
|
32
|
+
self.update_future = None
|
|
33
|
+
self.update_request = None
|
|
34
|
+
settings = (Path(library_root).parent if library_root is not None else self.directory / 'EXP Runtime') / 'settings.json'
|
|
35
|
+
self.app = Application(file_picker=self.pick, library_root=library_root, url_opener=self.open_url,
|
|
36
|
+
settings_path=settings, update_fetcher=self.fetch_release, check_updates=check_updates)
|
|
37
|
+
self.encoder = FrameEncoder()
|
|
38
|
+
self.picker = None
|
|
39
|
+
self.url = None
|
|
40
|
+
self.staging = []
|
|
41
|
+
self.last_active = True
|
|
42
|
+
self.app.render()
|
|
43
|
+
self.input_stamp = self._input_stamp()
|
|
44
|
+
self.generation = 0
|
|
45
|
+
|
|
46
|
+
def _input_stamp(self):
|
|
47
|
+
game = self.app.game if self.app.screen == 'game' else None
|
|
48
|
+
action = game.session.pending if game else None
|
|
49
|
+
session = game.session if game else None
|
|
50
|
+
return (self.app.input_token(), id(game.session) if game else None, game.menu_open if game else False,
|
|
51
|
+
id(action), action.details.get('page_start') if action else None,
|
|
52
|
+
(session.scene, session.vm.steps_executed) if session else None)
|
|
53
|
+
|
|
54
|
+
def handle_events(self, events):
|
|
55
|
+
screen = self.app.screen
|
|
56
|
+
token = self.app.input_token()
|
|
57
|
+
entry = self.app.game.session.pending if screen == 'game' else None
|
|
58
|
+
for event in events:
|
|
59
|
+
if event.get('generation', self.generation) != self.generation:
|
|
60
|
+
continue # A touch begun on an expired timed choice cannot answer its successor.
|
|
61
|
+
self._event(event)
|
|
62
|
+
if self.app.input_token() != token:
|
|
63
|
+
break
|
|
64
|
+
if screen == 'game' and self.app.game.screen_token != self.app.game._screen_token():
|
|
65
|
+
# Keep all queued characters for this same text field. The
|
|
66
|
+
# one-input gate still applies when it submits or raises an alert.
|
|
67
|
+
editing = (entry is not None and entry.name == 'text_input'
|
|
68
|
+
and self.app.game.session.pending is entry and not self.app.game.input_error
|
|
69
|
+
and (event['kind'] == 'text' or event.get('key') == 'backspace'))
|
|
70
|
+
if not editing:
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
def pick(self, kind):
|
|
74
|
+
self.picker = kind
|
|
75
|
+
|
|
76
|
+
def open_url(self, url):
|
|
77
|
+
self.url = url
|
|
78
|
+
|
|
79
|
+
def fetch_release(self):
|
|
80
|
+
# The host handles HTTPS with URLSession/system certificate trust.
|
|
81
|
+
# Shared Python still owns versions, settings and the Yes/No prompt.
|
|
82
|
+
from ..updates import RELEASE_API, REQUEST_HEADERS, TIMEOUT, MAX_RESPONSE
|
|
83
|
+
self.update_future = Future()
|
|
84
|
+
self.update_request = dict(url=RELEASE_API, headers=REQUEST_HEADERS,
|
|
85
|
+
timeout=TIMEOUT, max_bytes=MAX_RESPONSE)
|
|
86
|
+
return self.update_future
|
|
87
|
+
|
|
88
|
+
def _event(self, event):
|
|
89
|
+
b = self.backend
|
|
90
|
+
kind = event['kind']
|
|
91
|
+
if kind in ('down', 'up', 'move'):
|
|
92
|
+
position = tuple(event['point'])
|
|
93
|
+
b.pointer = position if kind != 'up' else (-1, -1)
|
|
94
|
+
event = SimpleNamespace(type={'down': b.MOUSEBUTTONDOWN, 'up': b.MOUSEBUTTONUP,
|
|
95
|
+
'move': b.MOUSEMOTION}[kind], pos=position, button=1)
|
|
96
|
+
elif kind == 'scroll':
|
|
97
|
+
event = SimpleNamespace(type=b.MOUSEWHEEL, y=event['delta'])
|
|
98
|
+
elif kind == 'text':
|
|
99
|
+
event = SimpleNamespace(type=b.TEXTINPUT, text=event['text'])
|
|
100
|
+
elif kind == 'key':
|
|
101
|
+
event = SimpleNamespace(type=b.KEYDOWN, key={'return': b.K_RETURN, 'backspace': b.K_BACKSPACE,
|
|
102
|
+
'escape': b.K_ESCAPE}[event['key']])
|
|
103
|
+
elif kind == 'cancel':
|
|
104
|
+
b.pointer = (-1, -1)
|
|
105
|
+
self.app.cancel_pointer()
|
|
106
|
+
return
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(f'Unsupported iOS input: {kind}')
|
|
109
|
+
self.app.handle_event(event)
|
|
110
|
+
|
|
111
|
+
def active(self, value):
|
|
112
|
+
if value == self.last_active:
|
|
113
|
+
return
|
|
114
|
+
self.last_active = value
|
|
115
|
+
b = self.backend
|
|
116
|
+
self.app.handle_event(SimpleNamespace(type=b.WINDOWFOCUSGAINED if value else b.WINDOWFOCUSLOST))
|
|
117
|
+
if not value:
|
|
118
|
+
b.mixer.stop()
|
|
119
|
+
if self.app.game:
|
|
120
|
+
self.app._attempt(lambda: self.app.state.checkpoint(self.app.game.session))
|
|
121
|
+
|
|
122
|
+
def request(self, message):
|
|
123
|
+
operation = message.get('operation', 'frame')
|
|
124
|
+
verification = {}
|
|
125
|
+
if operation in ('verify_start', 'verify_next'):
|
|
126
|
+
import exp_ios_probe
|
|
127
|
+
if operation == 'verify_start':
|
|
128
|
+
self.test_steps = exp_ios_probe.presentation_steps(self)
|
|
129
|
+
verification['drawing_test'] = exp_ios_probe.drawing_fixture()
|
|
130
|
+
verification['audio_test'] = exp_ios_probe.audio_fixture()
|
|
131
|
+
try:
|
|
132
|
+
verification['verification_check'] = next(self.test_steps)
|
|
133
|
+
except StopIteration:
|
|
134
|
+
verification['verification_done'] = True
|
|
135
|
+
message = dict(message, elapsed=50)
|
|
136
|
+
elif operation == 'import':
|
|
137
|
+
kind = message['kind']
|
|
138
|
+
paths = [Path(path) for path in message['paths']]
|
|
139
|
+
if kind == 'library':
|
|
140
|
+
if len(paths) != 1 or not paths[0].is_dir():
|
|
141
|
+
raise ValueError('Choose one EXP Runtime library folder')
|
|
142
|
+
self.app._attempt(lambda: self.app.open_library(paths[0], remember=True))
|
|
143
|
+
else:
|
|
144
|
+
self.app._attempt(lambda: self.app.import_paths(paths))
|
|
145
|
+
self.staging.extend(paths)
|
|
146
|
+
elif operation == 'message':
|
|
147
|
+
self.app.message = message['text']
|
|
148
|
+
elif operation == 'update_result':
|
|
149
|
+
if self.update_future is not None and not self.update_future.done():
|
|
150
|
+
self.update_future.set_result(message.get('payload'))
|
|
151
|
+
self.update_future = None
|
|
152
|
+
elif operation == 'active':
|
|
153
|
+
self.active(bool(message['active']))
|
|
154
|
+
elif operation == 'close':
|
|
155
|
+
self.app.close()
|
|
156
|
+
return {'status': 'closed'}
|
|
157
|
+
elif operation != 'frame':
|
|
158
|
+
raise ValueError(f'Unsupported iOS operation: {operation}')
|
|
159
|
+
elapsed = max(0, int(message.get('elapsed', 0))) if self.last_active else 0
|
|
160
|
+
self.backend.ticks += elapsed
|
|
161
|
+
self.handle_events(message.get('events', ()))
|
|
162
|
+
self.app.tick(elapsed)
|
|
163
|
+
if not self.app.busy and self.staging:
|
|
164
|
+
# Swift copied imports into a disposable, app-owned directory.
|
|
165
|
+
# Never remove a selected original or a library being played.
|
|
166
|
+
imports = (self.directory / 'Imports').resolve()
|
|
167
|
+
for path in self.staging:
|
|
168
|
+
if path.resolve().is_relative_to(imports):
|
|
169
|
+
if path.is_dir():
|
|
170
|
+
shutil.rmtree(path)
|
|
171
|
+
else:
|
|
172
|
+
path.unlink(missing_ok=True)
|
|
173
|
+
parent = path.parent.resolve()
|
|
174
|
+
while parent != imports and parent.is_relative_to(imports):
|
|
175
|
+
try:
|
|
176
|
+
parent.rmdir()
|
|
177
|
+
except OSError:
|
|
178
|
+
break
|
|
179
|
+
parent = parent.parent
|
|
180
|
+
self.staging.clear()
|
|
181
|
+
self.app.render()
|
|
182
|
+
stamp = self._input_stamp()
|
|
183
|
+
if stamp != self.input_stamp:
|
|
184
|
+
self.input_stamp = stamp
|
|
185
|
+
self.generation += 1
|
|
186
|
+
frame = self.encoder.encode(self.backend.window)
|
|
187
|
+
audio, self.backend.mixer.commands = self.backend.mixer.commands, []
|
|
188
|
+
picker, self.picker = self.picker, None
|
|
189
|
+
url, self.url = self.url, None
|
|
190
|
+
update_request, self.update_request = self.update_request, None
|
|
191
|
+
if self.update_future is None or self.update_future.cancelled():
|
|
192
|
+
update_request = None
|
|
193
|
+
game = self.app.game if self.app.screen == 'game' else None
|
|
194
|
+
pending = game.session.pending if game else None
|
|
195
|
+
if (operation.startswith('verify_') and verification.get('verification_check')
|
|
196
|
+
and self.app.screen == 'main' and self.app.menu_music_loaded):
|
|
197
|
+
verification['verify_menu_audio'] = True
|
|
198
|
+
if (operation.startswith('verify_')
|
|
199
|
+
and str(verification.get('verification_check', '')).endswith('menu click')):
|
|
200
|
+
verification['verify_click_audio'] = True
|
|
201
|
+
return dict(verification, status='ok', frame=frame, audio=audio, keyboard=self.backend.keyboard, picker=picker, open_url=url,
|
|
202
|
+
update_request=update_request,
|
|
203
|
+
generation=self.generation,
|
|
204
|
+
scrollable=self.app.screen in ('episodes', 'browser') or bool(game and game.max_scroll),
|
|
205
|
+
busy=self.app.busy, screen=self.app.screen, game=self.app.selected_game,
|
|
206
|
+
pending=pending.name if pending else None, scene=game.session.scene if game else None,
|
|
207
|
+
error=game.error if game else self.app.message or None)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def request(payload, directory):
|
|
211
|
+
global _host, _verifying
|
|
212
|
+
message = json.loads(payload)
|
|
213
|
+
if message.get('operation') == 'verify':
|
|
214
|
+
_verifying = True
|
|
215
|
+
import exp_ios_probe
|
|
216
|
+
return exp_ios_probe.request(payload, directory)
|
|
217
|
+
if _host is None:
|
|
218
|
+
# Launch-argument diagnostics never open or change a player's library.
|
|
219
|
+
root = None
|
|
220
|
+
if _verifying:
|
|
221
|
+
parent = Path(directory) / 'Verification'
|
|
222
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
root = Path(tempfile.mkdtemp(prefix='libraries-', dir=parent))
|
|
224
|
+
_host = Host(directory, library_root=root, check_updates=not _verifying)
|
|
225
|
+
return json.dumps(_host.request(message), separators=(',', ':'))
|
exp_runtime/pointer.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Button tracking shared by the desktop and native touch hosts."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ButtonPress:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.cancel()
|
|
7
|
+
|
|
8
|
+
def cancel(self):
|
|
9
|
+
self.origin = self.pressed = self.token = None
|
|
10
|
+
|
|
11
|
+
def validate(self, token):
|
|
12
|
+
if self.token != token:
|
|
13
|
+
self.cancel()
|
|
14
|
+
|
|
15
|
+
def update(self, phase, hit, token=None):
|
|
16
|
+
if phase == 'down':
|
|
17
|
+
self.origin, self.pressed, self.token = hit, hit, token
|
|
18
|
+
else:
|
|
19
|
+
self.validate(token)
|
|
20
|
+
self.pressed = hit if hit == self.origin else None
|
|
21
|
+
if phase == 'up':
|
|
22
|
+
command = self.pressed
|
|
23
|
+
self.cancel()
|
|
24
|
+
return command
|
|
25
|
+
return None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Perspective texture mapping shared by software and native drawing hosts."""
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def inverse_quad(points):
|
|
7
|
+
"""Map a convex screen quad to the unit texture square (TL/TR/BR/BL)."""
|
|
8
|
+
(x0, y0), (x1, y1), (x2, y2), (x3, y3) = points
|
|
9
|
+
dx1, dx2, dx3 = x1 - x2, x3 - x2, x0 - x1 + x2 - x3
|
|
10
|
+
dy1, dy2, dy3 = y1 - y2, y3 - y2, y0 - y1 + y2 - y3
|
|
11
|
+
denominator = dx1 * dy2 - dx2 * dy1
|
|
12
|
+
if abs(denominator) < 1e-9:
|
|
13
|
+
return None
|
|
14
|
+
g = (dx3 * dy2 - dx2 * dy3) / denominator
|
|
15
|
+
h = (dx1 * dy3 - dx3 * dy1) / denominator
|
|
16
|
+
a, b, c = x1 - x0 + g * x1, x3 - x0 + h * x3, x0
|
|
17
|
+
d, e, f = y1 - y0 + g * y1, y3 - y0 + h * y3, y0
|
|
18
|
+
k = a * e - b * d
|
|
19
|
+
if abs(k) < 1e-9:
|
|
20
|
+
return None
|
|
21
|
+
return tuple(v / k for v in (e - f*h, c*h - b, b*f - c*e,
|
|
22
|
+
f*g - d, a - c*g, c*d - a*f, d*h - e*g, b*g - a*h))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def local_map(coefficients, x, y):
|
|
26
|
+
a, b, c, d, e, f, g, h = coefficients
|
|
27
|
+
k = g*x + h*y + 1
|
|
28
|
+
return tuple(v / k for v in (a, b, a*x + b*y + c, d, e, d*x + e*y + f, g, h))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def warp(source, size, coefficients):
|
|
32
|
+
from PIL import Image
|
|
33
|
+
factors = (source.width,) * 3 + (source.height,) * 3 + (1, 1)
|
|
34
|
+
return source.transform(size, Image.Transform.PERSPECTIVE,
|
|
35
|
+
tuple(v * scale for v, scale in zip(coefficients, factors)),
|
|
36
|
+
Image.Resampling.BILINEAR)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=256)
|
|
40
|
+
def _desktop_texture(image):
|
|
41
|
+
# Grid face images are immutable cached assets; only their transforms move.
|
|
42
|
+
from PIL import Image
|
|
43
|
+
from . import graphics
|
|
44
|
+
return Image.frombytes('RGBA', image.get_size(), graphics.image.tobytes(image, 'RGBA'))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def draw_quad(target, image, points):
|
|
48
|
+
from . import graphics
|
|
49
|
+
coefficients = inverse_quad(points)
|
|
50
|
+
if coefficients is None:
|
|
51
|
+
return
|
|
52
|
+
clip = target.get_clip()
|
|
53
|
+
left = max(clip.left, math.floor(min(p[0] for p in points)))
|
|
54
|
+
top = max(clip.top, math.floor(min(p[1] for p in points)))
|
|
55
|
+
right = min(clip.right, math.ceil(max(p[0] for p in points)))
|
|
56
|
+
bottom = min(clip.bottom, math.ceil(max(p[1] for p in points)))
|
|
57
|
+
if right <= left or bottom <= top:
|
|
58
|
+
return
|
|
59
|
+
coefficients = local_map(coefficients, left, top)
|
|
60
|
+
bounds = left, top, right - left, bottom - top
|
|
61
|
+
native = getattr(graphics.draw, 'textured_quad', None)
|
|
62
|
+
if native is not None:
|
|
63
|
+
native(target, image, bounds, coefficients)
|
|
64
|
+
else:
|
|
65
|
+
pixels = warp(_desktop_texture(image), bounds[2:], coefficients)
|
|
66
|
+
layer = graphics.image.frombytes(pixels.tobytes(), pixels.size, 'RGBA')
|
|
67
|
+
layer.set_alpha(image.get_alpha())
|
|
68
|
+
target.blit(layer, (left, top))
|
exp_runtime/settings.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""App-wide preferences, independent of either game's imported library."""
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from .launcher import user_data_directory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AppSettings:
|
|
12
|
+
def __init__(self, path=None):
|
|
13
|
+
self.path = Path(path) if path is not None else user_data_directory() / 'settings.json'
|
|
14
|
+
self.check_for_updates = True
|
|
15
|
+
try:
|
|
16
|
+
data = json.loads(self.path.read_text(encoding='utf-8'))
|
|
17
|
+
if (not isinstance(data, dict) or data.get('version') != 1
|
|
18
|
+
or type(data.get('check_for_updates')) is not bool):
|
|
19
|
+
raise ValueError('Invalid application settings')
|
|
20
|
+
self.check_for_updates = data['check_for_updates']
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
pass
|
|
23
|
+
except (OSError, ValueError) as error:
|
|
24
|
+
# A damaged/unreadable preference must not silently re-enable
|
|
25
|
+
# networking the player may have disabled.
|
|
26
|
+
self.check_for_updates = False
|
|
27
|
+
logging.warning('Could not read application settings: %s', error)
|
|
28
|
+
|
|
29
|
+
def set_update_check(self, enabled):
|
|
30
|
+
if type(enabled) is not bool:
|
|
31
|
+
raise ValueError('Update preference must be a boolean')
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
temporary = None
|
|
34
|
+
try:
|
|
35
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=self.path.parent,
|
|
36
|
+
prefix='.settings-', delete=False) as stream:
|
|
37
|
+
temporary = Path(stream.name)
|
|
38
|
+
json.dump(dict(version=1, check_for_updates=enabled), stream)
|
|
39
|
+
stream.write('\n')
|
|
40
|
+
stream.flush()
|
|
41
|
+
os.fsync(stream.fileno())
|
|
42
|
+
temporary.replace(self.path)
|
|
43
|
+
self.check_for_updates = enabled
|
|
44
|
+
finally:
|
|
45
|
+
if temporary is not None:
|
|
46
|
+
temporary.unlink(missing_ok=True)
|
exp_runtime/updates.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""One optional, nonblocking public-release check per application launch.
|
|
2
|
+
|
|
3
|
+
Only metadata is fetched. The browser opens a release page after an explicit
|
|
4
|
+
Yes; this module never downloads, installs or executes release assets.
|
|
5
|
+
"""
|
|
6
|
+
from concurrent.futures import Future
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from http.client import HTTPException
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import ssl
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from threading import Thread
|
|
16
|
+
from urllib.error import HTTPError
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
from .app_info import PROJECT_REPOSITORY, PROJECT_URL, runtime_version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
RELEASE_API = f'https://api.github.com/repos/{PROJECT_REPOSITORY}/releases/latest'
|
|
24
|
+
MAX_RESPONSE = 1024 * 1024
|
|
25
|
+
TIMEOUT = 8
|
|
26
|
+
REQUEST_HEADERS = {'Accept': 'application/vnd.github+json',
|
|
27
|
+
'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'EXP-Runtime-update-check'}
|
|
28
|
+
UPDATE_MESSAGE = 'Update available. Download here.'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Release:
|
|
33
|
+
version: str
|
|
34
|
+
url: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version(text):
|
|
38
|
+
# Released app versions are three numeric components. Recognize the
|
|
39
|
+
# project's possible PEP 440 prerelease/dev builds without lexicographic
|
|
40
|
+
# comparisons (0.10.0 > 0.9.0) or guessing about unknown tag formats.
|
|
41
|
+
if not isinstance(text, str) or len(text) > 100:
|
|
42
|
+
return None
|
|
43
|
+
match = re.fullmatch(r'v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
|
44
|
+
r'(?:(a|b|rc)\d+)?(?:\.(dev|post)\d+)?(?:\+[a-zA-Z0-9.-]+)?', text)
|
|
45
|
+
if match is None:
|
|
46
|
+
return None
|
|
47
|
+
return (*map(int, match.group(1, 2, 3)), int(match[4] is None and match[5] != 'dev'))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def newer_release(payload, current):
|
|
51
|
+
installed = _version(current)
|
|
52
|
+
if installed is None or not isinstance(payload, (str, bytes)) or len(payload) > MAX_RESPONSE:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(payload)
|
|
56
|
+
except (ValueError, UnicodeError):
|
|
57
|
+
return None
|
|
58
|
+
if (not isinstance(data, dict) or data.get('draft') is not False
|
|
59
|
+
or data.get('prerelease') is not False):
|
|
60
|
+
return None
|
|
61
|
+
tag = data.get('tag_name')
|
|
62
|
+
# The endpoint excludes prereleases; independently require a stable
|
|
63
|
+
# release tag before constructing a URL on our own project's domain.
|
|
64
|
+
if not isinstance(tag, str) or re.fullmatch(r'v?\d+\.\d+\.\d+', tag) is None:
|
|
65
|
+
return None
|
|
66
|
+
latest = _version(tag)
|
|
67
|
+
if latest is None or latest <= installed:
|
|
68
|
+
return None
|
|
69
|
+
return Release(tag.removeprefix('v'), f'{PROJECT_URL}/releases/tag/{quote(tag, safe="")}')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ssl_context():
|
|
73
|
+
context = ssl.create_default_context()
|
|
74
|
+
if sys.platform == 'darwin':
|
|
75
|
+
# Frozen macOS apps cannot depend on the build machine's Homebrew
|
|
76
|
+
# CA path. Read Apple's public root certificates using its system tool.
|
|
77
|
+
roots = subprocess.run(['/usr/bin/security', 'find-certificate', '-a', '-p',
|
|
78
|
+
'/System/Library/Keychains/SystemRootCertificates.keychain'],
|
|
79
|
+
check=True, capture_output=True, text=True, timeout=TIMEOUT)
|
|
80
|
+
context.load_verify_locations(cadata=roots.stdout)
|
|
81
|
+
return context
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_release():
|
|
85
|
+
"""Desktop transport; a daemon worker never delays startup or shutdown."""
|
|
86
|
+
result = Future()
|
|
87
|
+
|
|
88
|
+
def work():
|
|
89
|
+
payload = None
|
|
90
|
+
try:
|
|
91
|
+
request = Request(RELEASE_API, headers=REQUEST_HEADERS)
|
|
92
|
+
with urlopen(request, timeout=TIMEOUT, context=_ssl_context()) as response:
|
|
93
|
+
if response.status == 200:
|
|
94
|
+
data = response.read(MAX_RESPONSE + 1)
|
|
95
|
+
if len(data) <= MAX_RESPONSE:
|
|
96
|
+
payload = data
|
|
97
|
+
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as error:
|
|
98
|
+
if isinstance(error, HTTPError):
|
|
99
|
+
error.close()
|
|
100
|
+
logging.debug('Update check unavailable: %s', error)
|
|
101
|
+
# set_running_or_notify_cancel also makes cancellation during shutdown
|
|
102
|
+
# race-free, without joining a DNS/TLS operation on the UI thread.
|
|
103
|
+
if result.set_running_or_notify_cancel():
|
|
104
|
+
result.set_result(payload)
|
|
105
|
+
|
|
106
|
+
Thread(target=work, name='exp-update-check', daemon=True).start()
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class UpdateCheck:
|
|
111
|
+
def __init__(self, fetcher=None, *, current=None):
|
|
112
|
+
self.fetcher = fetcher or fetch_release
|
|
113
|
+
self.current = runtime_version() if current is None else current
|
|
114
|
+
self.started = False
|
|
115
|
+
self.future = None
|
|
116
|
+
self.available = None
|
|
117
|
+
|
|
118
|
+
def start(self):
|
|
119
|
+
if self.started or _version(self.current) is None:
|
|
120
|
+
return
|
|
121
|
+
self.started = True
|
|
122
|
+
try:
|
|
123
|
+
self.future = self.fetcher()
|
|
124
|
+
except (OSError, RuntimeError) as error:
|
|
125
|
+
logging.debug('Update check could not start: %s', error)
|
|
126
|
+
|
|
127
|
+
def poll(self):
|
|
128
|
+
if self.future is not None and self.future.done():
|
|
129
|
+
future, self.future = self.future, None
|
|
130
|
+
if not future.cancelled():
|
|
131
|
+
try:
|
|
132
|
+
self.available = newer_release(future.result(), self.current)
|
|
133
|
+
except Exception as error:
|
|
134
|
+
logging.debug('Update check unavailable: %s', error)
|
|
135
|
+
return self.available
|
|
136
|
+
|
|
137
|
+
def dismiss(self):
|
|
138
|
+
self.available = None
|
|
139
|
+
|
|
140
|
+
def cancel(self):
|
|
141
|
+
if self.future is not None:
|
|
142
|
+
self.future.cancel()
|
|
143
|
+
self.future = None
|
|
144
|
+
self.dismiss()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exp-runtime-player
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Shared EXP Runtime player and native/desktop rendering
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Josh Mcdaniel
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: exp-runtime-engine[fonts]==0.5.0
|
|
31
|
+
Provides-Extra: desktop
|
|
32
|
+
Requires-Dist: pygame-ce<3,>=2.5.7; extra == "desktop"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# EXP Runtime — player
|
|
36
|
+
|
|
37
|
+
Shared EXP Runtime player and native/desktop rendering. This is a component of the single
|
|
38
|
+
[EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
|
|
39
|
+
and released together with its other wheels. Python imports use the shared
|
|
40
|
+
`exp_runtime` namespace.
|
|
41
|
+
|
|
42
|
+
Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
|
|
43
|
+
`exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
|
|
44
|
+
the desktop player with both games. Individual component wheels also support
|
|
45
|
+
selective installs. See the project's `docs/SDK.md` for APIs and examples.
|
|
46
|
+
|
|
47
|
+
No original game content is included. Users supply their own game files.
|
|
48
|
+
The inherited MIT license and attribution are included in LICENSE; see
|
|
49
|
+
[provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
exp_runtime/app_icon.py,sha256=cxZFBiN9KmJK9K-rMfaNxBg_0MGcu5fSvhF95wZ-e6o,394
|
|
2
|
+
exp_runtime/app_info.py,sha256=ES5AesX8Ht1Z230tjOSCQJMVQpZylQ0Thic8iuy4eoE,539
|
|
3
|
+
exp_runtime/application.py,sha256=pLHbjkuae_9xc7gaos017RZvV3Jd2ck2lg-YmdoC1cI,37163
|
|
4
|
+
exp_runtime/desktop.py,sha256=um8WIY1-NcXAjtqwoe2gvE4uio8fDcevPkKm4NZTnFg,39134
|
|
5
|
+
exp_runtime/desktop_choice.py,sha256=Ru6gUtt3kXeXXoIOLZChYM-5Kv4EyS-BCto3o_F7YRw,11501
|
|
6
|
+
exp_runtime/desktop_dialogue.py,sha256=ifnYQ9YcpUL0Pyt6RYrNrfDBDX6U4CTm1RTIbEMaDJE,14215
|
|
7
|
+
exp_runtime/desktop_input.py,sha256=gQMmYCpGx-xN6ED6Sc5ROYIkymt4FxWqVAk3gG642v8,9811
|
|
8
|
+
exp_runtime/desktop_loading.py,sha256=GoFQfteMWDLHYznGcdd9IS7PWsLgOB8spYWFcGid858,2439
|
|
9
|
+
exp_runtime/desktop_menu.py,sha256=P0JT1IRgYBEyPn-MJ3dFZzWUK45ssGI2oVjQLnFP2Dw,26874
|
|
10
|
+
exp_runtime/desktop_message.py,sha256=Cmgl7Jv9HLTVNsHYaHgvhZCmw2GV8FRs4rqXj4luDGY,6706
|
|
11
|
+
exp_runtime/desktop_pause.py,sha256=3RUBo-PXYe3OrlZB_xqHEgcstqsIE57eCNh5j8m7Yzs,5197
|
|
12
|
+
exp_runtime/desktop_picker.py,sha256=LkyIQX2hwncOYcKLW4qWnHuQRBeWaXWVs1cGzKXLUzA,3604
|
|
13
|
+
exp_runtime/desktop_text.py,sha256=36KK2qwcd3z86-HGTvWoo4Id9bM1W1SjJX_aax1Q9C8,4312
|
|
14
|
+
exp_runtime/desktop_title.py,sha256=c8PwFhZWxWlRB1YEBz6dIZVgHXeioevk0ilJ4nu5gdU,3875
|
|
15
|
+
exp_runtime/graphics.py,sha256=1i8_6B-1Ef1bX0qm6xfTdSDTwmj3DZ5eBHBm6-newoQ,2460
|
|
16
|
+
exp_runtime/pointer.py,sha256=smhXTZ9yGhJTID263qSMZUSKhwt6LH-uXvZE_oZ_QLI,706
|
|
17
|
+
exp_runtime/projection.py,sha256=i7WkGPrPOsWyO3OQ44E7eqVrG1WqZpnUk-50Fy5njDc,2680
|
|
18
|
+
exp_runtime/settings.py,sha256=TYPf0nphdai2Gk6Q8mNydu9jb_rfS8uwcubMfoyIjgY,1943
|
|
19
|
+
exp_runtime/updates.py,sha256=S64XNhJM3rHuT9LAjIt0776i-wmDAaej-zx6Uo-OcyI,5437
|
|
20
|
+
exp_runtime/assets/kiwi.svg,sha256=1ecGyaJjPteO9xlsONT3b5ILWO6eq3hvkOeo_XqCtqA,4420
|
|
21
|
+
exp_runtime/platforms/__init__.py,sha256=IEWH1SUQ1uY-djD8Wzhhkm_CV3gUtvlZLN6Ss6tRN9w,74
|
|
22
|
+
exp_runtime/platforms/drawing.py,sha256=OEegHvPNdS-Mnds9stk2J930z2zqz95jnzQUGLigxuw,23464
|
|
23
|
+
exp_runtime/platforms/ios.py,sha256=xbdDXkaqidsH2V_wk72s4VB7Z-9X928SUMyp-Mk8it8,10779
|
|
24
|
+
exp_runtime_player-0.5.0.dist-info/licenses/LICENSE,sha256=y2xxdaMMD9iKqLFR2Lp8sM_0b3qZWxU4_vW0zgufh2k,1070
|
|
25
|
+
exp_runtime_player-0.5.0.dist-info/METADATA,sha256=HsU8bGDLtzkXzVJAhCjDklCuykzMiKLrwCLMnUCnklo,2443
|
|
26
|
+
exp_runtime_player-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
27
|
+
exp_runtime_player-0.5.0.dist-info/entry_points.txt,sha256=ALTaa-N7w-MrIBrWCV78YNuguS-O8I_hKbgscRXfIt8,92
|
|
28
|
+
exp_runtime_player-0.5.0.dist-info/top_level.txt,sha256=1uRgkxqLAgYpITLluWh9qs6TpAD83zFoh0E5lbic4RA,12
|
|
29
|
+
exp_runtime_player-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Josh Mcdaniel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exp_runtime
|