fz-manager-plus 0.1.2__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.
- fz_manager_plus/__init__.py +7 -0
- fz_manager_plus/application/__init__.py +1 -0
- fz_manager_plus/application/ports.py +35 -0
- fz_manager_plus/application/session.py +455 -0
- fz_manager_plus/application/transfers.py +56 -0
- fz_manager_plus/cli.py +5 -0
- fz_manager_plus/config.py +134 -0
- fz_manager_plus/domain/__init__.py +1 -0
- fz_manager_plus/domain/errors.py +24 -0
- fz_manager_plus/domain/messages.py +116 -0
- fz_manager_plus/domain/state.py +83 -0
- fz_manager_plus/infrastructure/__init__.py +0 -0
- fz_manager_plus/infrastructure/factorio_zone/__init__.py +0 -0
- fz_manager_plus/infrastructure/factorio_zone/client.py +194 -0
- fz_manager_plus/infrastructure/factorio_zone/mods.py +43 -0
- fz_manager_plus/infrastructure/factorio_zone/session.py +4 -0
- fz_manager_plus/infrastructure/factorio_zone/socket.py +34 -0
- fz_manager_plus/runtime.py +16 -0
- fz_manager_plus/terminal.py +45 -0
- fz_manager_plus/tui/__init__.py +0 -0
- fz_manager_plus/tui/app.py +409 -0
- fz_manager_plus/tui/components/__init__.py +35 -0
- fz_manager_plus/tui/components/choice_screen.py +59 -0
- fz_manager_plus/tui/components/confirm_screen.py +52 -0
- fz_manager_plus/tui/components/log_pane.py +43 -0
- fz_manager_plus/tui/components/menu_pane.py +36 -0
- fz_manager_plus/tui/components/mods_pane.py +100 -0
- fz_manager_plus/tui/components/mods_upload_screen.py +155 -0
- fz_manager_plus/tui/components/multi_choice_screen.py +83 -0
- fz_manager_plus/tui/components/saves_pane.py +72 -0
- fz_manager_plus/tui/components/selectable_list.py +51 -0
- fz_manager_plus/tui/components/status_bar.py +25 -0
- fz_manager_plus/tui/components/token_screen.py +40 -0
- fz_manager_plus/tui/flows/__init__.py +13 -0
- fz_manager_plus/tui/flows/host.py +26 -0
- fz_manager_plus/tui/flows/mod_flows.py +29 -0
- fz_manager_plus/tui/flows/save_flows.py +57 -0
- fz_manager_plus/tui/flows/server_flows.py +68 -0
- fz_manager_plus/tui/flows/sync_flows.py +45 -0
- fz_manager_plus/tui/progress.py +26 -0
- fz_manager_plus/tui/run.py +4 -0
- fz_manager_plus/utils/__init__.py +0 -0
- fz_manager_plus/utils/api_router/__init__.py +0 -0
- fz_manager_plus/utils/api_router/http.py +86 -0
- fz_manager_plus/utils/api_router/ws.py +67 -0
- fz_manager_plus/utils/async_io.py +18 -0
- fz_manager_plus/utils/concurrency.py +17 -0
- fz_manager_plus/utils/files.py +34 -0
- fz_manager_plus-0.1.2.dist-info/METADATA +16 -0
- fz_manager_plus-0.1.2.dist-info/RECORD +53 -0
- fz_manager_plus-0.1.2.dist-info/WHEEL +4 -0
- fz_manager_plus-0.1.2.dist-info/entry_points.txt +3 -0
- fz_manager_plus-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application services, independent of the terminal UI."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Callable
|
|
2
|
+
from typing import BinaryIO, Protocol
|
|
3
|
+
|
|
4
|
+
from fz_manager_plus.domain.messages import BlankMessage, FzMessage, LoginResponse
|
|
5
|
+
|
|
6
|
+
type Progress = Callable[[int], None] | None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZoneAPI(Protocol):
|
|
10
|
+
visit_secret: str | None
|
|
11
|
+
user_token: str | None
|
|
12
|
+
|
|
13
|
+
async def login(self, reconnected: bool = False) -> LoginResponse: ...
|
|
14
|
+
async def start_instance(self, region: str, version: str, save: str) -> None: ...
|
|
15
|
+
async def stop_instance(self, launch_id: int) -> None: ...
|
|
16
|
+
async def send_command(self, launch_id: int, command: str) -> None: ...
|
|
17
|
+
async def toggle_mod(self, mod_id: int, enabled: bool) -> None: ...
|
|
18
|
+
async def delete_mod(self, mod_id: int) -> None: ...
|
|
19
|
+
async def upload_mod(
|
|
20
|
+
self, name: str, file: BinaryIO, size: int, progress: Progress = None
|
|
21
|
+
) -> None: ...
|
|
22
|
+
async def upload_save(
|
|
23
|
+
self, name: str, file: BinaryIO, size: int, slot: str, progress: Progress = None
|
|
24
|
+
) -> None: ...
|
|
25
|
+
async def delete_save_slot(self, slot: str) -> None: ...
|
|
26
|
+
async def download_save_slot(
|
|
27
|
+
self, slot: str, file_path: str, progress: Progress = None
|
|
28
|
+
) -> None: ...
|
|
29
|
+
async def aclose(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ZoneSocket(Protocol):
|
|
33
|
+
async def connect(self) -> None: ...
|
|
34
|
+
def messages(self) -> AsyncIterator[FzMessage | BlankMessage]: ...
|
|
35
|
+
async def close(self) -> None: ...
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
|
+
from inspect import isawaitable
|
|
8
|
+
|
|
9
|
+
from fz_manager_plus.application.ports import ZoneAPI, ZoneSocket
|
|
10
|
+
from fz_manager_plus.config import Settings
|
|
11
|
+
from fz_manager_plus.domain.errors import AuthenticationError, DisconnectedError, OperationError
|
|
12
|
+
from fz_manager_plus.domain.messages import (
|
|
13
|
+
BlankMessage,
|
|
14
|
+
ConsoleMessage,
|
|
15
|
+
ErrorMessage,
|
|
16
|
+
IdleMessage,
|
|
17
|
+
InfoMessage,
|
|
18
|
+
LogMessage,
|
|
19
|
+
ModsMessage,
|
|
20
|
+
OptionsMessage,
|
|
21
|
+
RunningMessage,
|
|
22
|
+
SlotMessage,
|
|
23
|
+
StartingMessage,
|
|
24
|
+
StoppingMessage,
|
|
25
|
+
VisitMessage,
|
|
26
|
+
WarnMessage,
|
|
27
|
+
)
|
|
28
|
+
from fz_manager_plus.domain.state import (
|
|
29
|
+
AuthenticationRequired,
|
|
30
|
+
ConnectionStatus,
|
|
31
|
+
LogEvent,
|
|
32
|
+
Mod,
|
|
33
|
+
ServerStatus,
|
|
34
|
+
SessionEvent,
|
|
35
|
+
SessionState,
|
|
36
|
+
StateChanged,
|
|
37
|
+
TokenReceived,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
type Listener = Callable[[SessionEvent], Awaitable[None] | None]
|
|
41
|
+
_ADDRESS = re.compile(r"selecting connection (\d+\.\d+\.\d+\.\d+:\d+)")
|
|
42
|
+
_LOG_LIMIT = 10_000
|
|
43
|
+
_logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FactorioZoneSession:
|
|
47
|
+
def __init__(self, api: ZoneAPI, socket: ZoneSocket, settings: Settings):
|
|
48
|
+
self.api, self.socket, self.settings = api, socket, settings
|
|
49
|
+
self.state = SessionState()
|
|
50
|
+
self.user_token = api.user_token
|
|
51
|
+
self._listeners: list[Listener] = []
|
|
52
|
+
self._changed = asyncio.Event()
|
|
53
|
+
self._disconnected = asyncio.Event()
|
|
54
|
+
self._disconnected.set()
|
|
55
|
+
self._generation = 0
|
|
56
|
+
self._closed = False
|
|
57
|
+
self._runner: asyncio.Task | None = None
|
|
58
|
+
self._operations: set[asyncio.Task] = set()
|
|
59
|
+
self._logs: OrderedDict[tuple[object, int], None] = OrderedDict()
|
|
60
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
61
|
+
self._ever_authenticated = False
|
|
62
|
+
self._close_task: asyncio.Task | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def regions(self):
|
|
66
|
+
return self.state.regions
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def versions(self):
|
|
70
|
+
return self.state.versions
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def saves(self):
|
|
74
|
+
return self.state.saves
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def mods(self):
|
|
78
|
+
return self.state.mods
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def launch_id(self):
|
|
82
|
+
return self.state.launch_id
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def server_status(self):
|
|
86
|
+
return self.state.server_status
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def server_address(self):
|
|
90
|
+
return self.state.server_address
|
|
91
|
+
|
|
92
|
+
def subscribe(self, listener: Listener) -> None:
|
|
93
|
+
if listener not in self._listeners:
|
|
94
|
+
self._listeners.append(listener)
|
|
95
|
+
|
|
96
|
+
def unsubscribe(self, listener: Listener) -> None:
|
|
97
|
+
if listener in self._listeners:
|
|
98
|
+
self._listeners.remove(listener)
|
|
99
|
+
|
|
100
|
+
async def emit(self, event: SessionEvent) -> None:
|
|
101
|
+
for listener in tuple(self._listeners):
|
|
102
|
+
try:
|
|
103
|
+
result = listener(event)
|
|
104
|
+
if isawaitable(result):
|
|
105
|
+
await result
|
|
106
|
+
except Exception:
|
|
107
|
+
# A broken observer must not tear down the transport.
|
|
108
|
+
_logger.exception("Session event listener failed")
|
|
109
|
+
|
|
110
|
+
async def _publish_state(self) -> None:
|
|
111
|
+
self.state.revision += 1
|
|
112
|
+
previous, self._changed = self._changed, asyncio.Event()
|
|
113
|
+
previous.set()
|
|
114
|
+
await self.emit(StateChanged(self.state.revision))
|
|
115
|
+
|
|
116
|
+
async def _disconnect(self, status=ConnectionStatus.DISCONNECTED) -> None:
|
|
117
|
+
self._disconnected.set()
|
|
118
|
+
self.state.ready.clear()
|
|
119
|
+
self.state.connection = status
|
|
120
|
+
self.api.visit_secret = None
|
|
121
|
+
await self._publish_state()
|
|
122
|
+
|
|
123
|
+
async def connect(self) -> None:
|
|
124
|
+
if self._closed:
|
|
125
|
+
raise DisconnectedError("Session is closed")
|
|
126
|
+
self._generation += 1
|
|
127
|
+
self._disconnected = asyncio.Event()
|
|
128
|
+
self.state.ready.clear()
|
|
129
|
+
self.state.launch_id = None
|
|
130
|
+
self.state.server_address = None
|
|
131
|
+
self.state.connection = ConnectionStatus.CONNECTING
|
|
132
|
+
await self._publish_state()
|
|
133
|
+
async with asyncio.timeout(self.settings.sync_timeout):
|
|
134
|
+
await self.socket.connect()
|
|
135
|
+
self.state.connection = ConnectionStatus.SYNCING
|
|
136
|
+
await self._publish_state()
|
|
137
|
+
|
|
138
|
+
async def _consume(self) -> None:
|
|
139
|
+
async for message in self.socket.messages():
|
|
140
|
+
await self.handle_message(message)
|
|
141
|
+
raise DisconnectedError("Connection closed by the server")
|
|
142
|
+
|
|
143
|
+
async def _connected_run(self) -> None:
|
|
144
|
+
consumer = asyncio.create_task(self._consume())
|
|
145
|
+
ready = asyncio.create_task(self.wait_ready("auth", "mods", "saves", "server"))
|
|
146
|
+
try:
|
|
147
|
+
completed, _ = await asyncio.wait(
|
|
148
|
+
(consumer, ready), return_when=asyncio.FIRST_COMPLETED
|
|
149
|
+
)
|
|
150
|
+
if consumer in completed:
|
|
151
|
+
await consumer
|
|
152
|
+
await ready
|
|
153
|
+
await consumer
|
|
154
|
+
finally:
|
|
155
|
+
for task in (consumer, ready):
|
|
156
|
+
task.cancel()
|
|
157
|
+
await asyncio.gather(consumer, ready, return_exceptions=True)
|
|
158
|
+
|
|
159
|
+
async def run(self) -> None:
|
|
160
|
+
if self._runner is not None:
|
|
161
|
+
raise RuntimeError("Session is already running")
|
|
162
|
+
self._runner = asyncio.current_task()
|
|
163
|
+
delay = 1
|
|
164
|
+
try:
|
|
165
|
+
while not self._closed:
|
|
166
|
+
try:
|
|
167
|
+
await self.connect()
|
|
168
|
+
await self._connected_run()
|
|
169
|
+
except AuthenticationError as error:
|
|
170
|
+
await self._disconnect(ConnectionStatus.AUTH_REQUIRED)
|
|
171
|
+
await self.emit(AuthenticationRequired(str(error)))
|
|
172
|
+
return
|
|
173
|
+
except (DisconnectedError, OperationError, OSError, TimeoutError) as error:
|
|
174
|
+
was_ready = self.state.connection == ConnectionStatus.CONNECTED
|
|
175
|
+
await self._disconnect()
|
|
176
|
+
await self.emit(
|
|
177
|
+
LogEvent(
|
|
178
|
+
f"Connection lost: {error}. Retrying in {1 if was_ready else delay}s.",
|
|
179
|
+
"warn",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if was_ready:
|
|
183
|
+
delay = 1
|
|
184
|
+
finally:
|
|
185
|
+
with suppress(Exception):
|
|
186
|
+
await self.socket.close()
|
|
187
|
+
if not self._closed:
|
|
188
|
+
await asyncio.sleep(delay)
|
|
189
|
+
delay = min(delay * 2, 30)
|
|
190
|
+
finally:
|
|
191
|
+
if self.state.connection != ConnectionStatus.AUTH_REQUIRED:
|
|
192
|
+
await self._disconnect()
|
|
193
|
+
self._runner = None
|
|
194
|
+
|
|
195
|
+
async def wait_for(self, predicate: Callable[[SessionState], bool], timeout: float) -> None:
|
|
196
|
+
generation = self._generation
|
|
197
|
+
async with asyncio.timeout(timeout):
|
|
198
|
+
while True:
|
|
199
|
+
changed = self._changed
|
|
200
|
+
if self._closed or self._disconnected.is_set() or generation != self._generation:
|
|
201
|
+
raise DisconnectedError("Connection was interrupted; operation was not retried")
|
|
202
|
+
if predicate(self.state):
|
|
203
|
+
return
|
|
204
|
+
await changed.wait()
|
|
205
|
+
|
|
206
|
+
async def wait_ready(self, *parts: str) -> None:
|
|
207
|
+
await self.wait_for(lambda state: set(parts) <= state.ready, self.settings.sync_timeout)
|
|
208
|
+
|
|
209
|
+
def require_ready(self, *parts: str) -> None:
|
|
210
|
+
if self._closed or self._disconnected.is_set() or not {"auth", *parts} <= self.state.ready:
|
|
211
|
+
raise DisconnectedError("Connection is not ready; wait for synchronization")
|
|
212
|
+
|
|
213
|
+
async def _call[T](self, operation: Callable[[], Awaitable[T]]) -> T:
|
|
214
|
+
self.require_ready()
|
|
215
|
+
disconnected = self._disconnected
|
|
216
|
+
|
|
217
|
+
async def execute() -> T:
|
|
218
|
+
return await operation()
|
|
219
|
+
|
|
220
|
+
task = asyncio.create_task(execute())
|
|
221
|
+
lost = asyncio.create_task(disconnected.wait())
|
|
222
|
+
self._operations.add(task)
|
|
223
|
+
try:
|
|
224
|
+
await asyncio.wait((task, lost), return_when=asyncio.FIRST_COMPLETED)
|
|
225
|
+
if disconnected.is_set():
|
|
226
|
+
raise DisconnectedError("Connection was interrupted; operation was not retried")
|
|
227
|
+
return await task
|
|
228
|
+
finally:
|
|
229
|
+
task.cancel()
|
|
230
|
+
lost.cancel()
|
|
231
|
+
await asyncio.gather(task, lost, return_exceptions=True)
|
|
232
|
+
self._operations.discard(task)
|
|
233
|
+
|
|
234
|
+
@asynccontextmanager
|
|
235
|
+
async def operation(self, resource: str, *ready: str):
|
|
236
|
+
self.require_ready(*ready)
|
|
237
|
+
generation = self._generation
|
|
238
|
+
lock = self._locks.setdefault(resource, asyncio.Lock())
|
|
239
|
+
async with lock:
|
|
240
|
+
if generation != self._generation:
|
|
241
|
+
raise DisconnectedError("Connection changed while the operation was waiting")
|
|
242
|
+
self.require_ready(*ready)
|
|
243
|
+
yield
|
|
244
|
+
|
|
245
|
+
async def start_instance(self, region: str, version: str, save: str) -> None:
|
|
246
|
+
async with self.operation("server", "server", "regions", "versions", "saves"):
|
|
247
|
+
if self.state.server_status != ServerStatus.OFFLINE:
|
|
248
|
+
raise OperationError("Server is already active")
|
|
249
|
+
if region not in self.regions or version not in self.versions or save not in self.saves:
|
|
250
|
+
raise OperationError("Server options changed; choose them again")
|
|
251
|
+
async with asyncio.timeout(self.settings.start_timeout):
|
|
252
|
+
await self._call(lambda: self.api.start_instance(region, version, save))
|
|
253
|
+
await self.wait_for(
|
|
254
|
+
lambda state: (
|
|
255
|
+
state.server_status == ServerStatus.RUNNING and bool(state.server_address)
|
|
256
|
+
),
|
|
257
|
+
self.settings.start_timeout,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def stop_instance(self) -> None:
|
|
261
|
+
async with self.operation("server", "server"):
|
|
262
|
+
launch_id = self._launch_id()
|
|
263
|
+
async with asyncio.timeout(self.settings.stop_timeout):
|
|
264
|
+
await self._call(lambda: self.api.stop_instance(launch_id))
|
|
265
|
+
await self.wait_for(
|
|
266
|
+
lambda state: state.server_status == ServerStatus.OFFLINE,
|
|
267
|
+
self.settings.stop_timeout,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _launch_id(self) -> int:
|
|
271
|
+
launch_id = self.launch_id
|
|
272
|
+
if launch_id is None:
|
|
273
|
+
raise OperationError("No running server")
|
|
274
|
+
return launch_id
|
|
275
|
+
|
|
276
|
+
async def send_command(self, command: str) -> None:
|
|
277
|
+
self.require_ready("server")
|
|
278
|
+
if self.server_status != ServerStatus.RUNNING:
|
|
279
|
+
raise OperationError("Server is not running yet")
|
|
280
|
+
launch_id = self._launch_id()
|
|
281
|
+
await self._call(lambda: self.api.send_command(launch_id, command))
|
|
282
|
+
|
|
283
|
+
async def toggle_mod(self, mod_id: int, enabled: bool) -> None:
|
|
284
|
+
async with self.operation("mods", "mods"):
|
|
285
|
+
await self._call(lambda: self.api.toggle_mod(mod_id, enabled))
|
|
286
|
+
|
|
287
|
+
@asynccontextmanager
|
|
288
|
+
async def mod_uploads(self):
|
|
289
|
+
"""Reserve mod mutations for a batch; uploads inside may run concurrently."""
|
|
290
|
+
async with self.operation("mods", "mods"):
|
|
291
|
+
generation = self._generation
|
|
292
|
+
|
|
293
|
+
async def upload(name, file, size, progress=None):
|
|
294
|
+
if generation != self._generation:
|
|
295
|
+
raise DisconnectedError("Connection changed; upload was not retried")
|
|
296
|
+
await self._call(lambda: self.api.upload_mod(name, file, size, progress))
|
|
297
|
+
|
|
298
|
+
yield upload
|
|
299
|
+
|
|
300
|
+
async def delete_mod(self, mod_id: int) -> None:
|
|
301
|
+
async with self.operation("mods", "mods"):
|
|
302
|
+
await self._call(lambda: self.api.delete_mod(mod_id))
|
|
303
|
+
|
|
304
|
+
async def delete_all_mods(self) -> None:
|
|
305
|
+
async with self.operation("mods", "mods"):
|
|
306
|
+
for mod in tuple(self.mods):
|
|
307
|
+
try:
|
|
308
|
+
await self._call(lambda entry=mod: self.api.delete_mod(entry.id))
|
|
309
|
+
await self.emit(LogEvent(f"Deleted {mod.text}", "info"))
|
|
310
|
+
except DisconnectedError:
|
|
311
|
+
raise
|
|
312
|
+
except OperationError as error:
|
|
313
|
+
await self.emit(LogEvent(f"{mod.text}: {error}", "error"))
|
|
314
|
+
|
|
315
|
+
def used_save_slots(self) -> list[tuple[int, str]]:
|
|
316
|
+
return sorted(
|
|
317
|
+
(int(slot.removeprefix("slot")), description)
|
|
318
|
+
for slot, description in self.saves.items()
|
|
319
|
+
if re.fullmatch(r"slot\d+", slot)
|
|
320
|
+
and description
|
|
321
|
+
and not description.endswith("(empty)")
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def is_save_slot_used(self, slot_index: int) -> bool:
|
|
325
|
+
description = self.saves.get(f"slot{slot_index}")
|
|
326
|
+
return description is not None and bool(description) and not description.endswith("(empty)")
|
|
327
|
+
|
|
328
|
+
def _check_slot(self, slot: str, *, used: bool = False) -> None:
|
|
329
|
+
if slot not in self.saves or not re.fullmatch(r"slot\d+", slot):
|
|
330
|
+
raise OperationError("Unknown save slot")
|
|
331
|
+
if used and not self.is_save_slot_used(int(slot.removeprefix("slot"))):
|
|
332
|
+
raise OperationError("Save slot is empty")
|
|
333
|
+
|
|
334
|
+
async def upload_save(self, name, file, size, slot, progress=None) -> None:
|
|
335
|
+
async with self.operation(f"save:{slot}", "saves"):
|
|
336
|
+
self._check_slot(slot)
|
|
337
|
+
await self._call(lambda: self.api.upload_save(name, file, size, slot, progress))
|
|
338
|
+
|
|
339
|
+
async def delete_save_slot(self, slot: str) -> None:
|
|
340
|
+
async with self.operation(f"save:{slot}", "saves"):
|
|
341
|
+
self._check_slot(slot, used=True)
|
|
342
|
+
await self._call(lambda: self.api.delete_save_slot(slot))
|
|
343
|
+
|
|
344
|
+
async def download_save_slot(self, slot: str, file_path: str, progress=None) -> None:
|
|
345
|
+
async with self.operation(f"save:{slot}", "saves"):
|
|
346
|
+
self._check_slot(slot, used=True)
|
|
347
|
+
await self._call(lambda: self.api.download_save_slot(slot, file_path, progress))
|
|
348
|
+
|
|
349
|
+
async def handle_message(self, message) -> None:
|
|
350
|
+
state = self.state
|
|
351
|
+
match message:
|
|
352
|
+
case VisitMessage():
|
|
353
|
+
self.api.visit_secret = message.secret
|
|
354
|
+
response = await self.api.login(reconnected=self._ever_authenticated)
|
|
355
|
+
self._ever_authenticated = True
|
|
356
|
+
self.user_token = response.user_token
|
|
357
|
+
self.api.user_token = response.user_token
|
|
358
|
+
state.ready.add("auth")
|
|
359
|
+
await self.emit(TokenReceived(response.user_token))
|
|
360
|
+
case OptionsMessage():
|
|
361
|
+
options = message.options
|
|
362
|
+
if message.name in ("regions", "saves"):
|
|
363
|
+
if not isinstance(options, dict) or not all(
|
|
364
|
+
isinstance(k, str) and isinstance(v, str) for k, v in options.items()
|
|
365
|
+
):
|
|
366
|
+
await self.emit(LogEvent(f"Invalid {message.name} options", "warn"))
|
|
367
|
+
return
|
|
368
|
+
setattr(state, message.name, dict(options))
|
|
369
|
+
state.ready.add(message.name)
|
|
370
|
+
elif message.name == "versions":
|
|
371
|
+
if not isinstance(options, (dict, list)) or not all(
|
|
372
|
+
isinstance(v, str) for v in options
|
|
373
|
+
):
|
|
374
|
+
await self.emit(LogEvent("Invalid version options", "warn"))
|
|
375
|
+
return
|
|
376
|
+
state.versions = tuple(options)
|
|
377
|
+
state.ready.add("versions")
|
|
378
|
+
case ModsMessage():
|
|
379
|
+
state.mods = tuple(Mod(mod.id, mod.text, mod.enabled) for mod in message.mods)
|
|
380
|
+
state.ready.add("mods")
|
|
381
|
+
case IdleMessage():
|
|
382
|
+
state.server_status = ServerStatus.OFFLINE
|
|
383
|
+
state.launch_id = state.server_address = None
|
|
384
|
+
state.ready.add("server")
|
|
385
|
+
case StartingMessage() | StoppingMessage() | RunningMessage():
|
|
386
|
+
previous = state.launch_id
|
|
387
|
+
if message.launch_id is not None:
|
|
388
|
+
state.launch_id = message.launch_id
|
|
389
|
+
if previous != state.launch_id:
|
|
390
|
+
state.server_address = None
|
|
391
|
+
state.server_status = {
|
|
392
|
+
"starting": ServerStatus.STARTING,
|
|
393
|
+
"stopping": ServerStatus.STOPPING,
|
|
394
|
+
"running": ServerStatus.RUNNING,
|
|
395
|
+
}[message.type]
|
|
396
|
+
if isinstance(message, RunningMessage):
|
|
397
|
+
state.server_address = message.socket or state.server_address
|
|
398
|
+
state.ready.add("server")
|
|
399
|
+
case LogMessage():
|
|
400
|
+
launch = message.launch_id if message.launch_id is not None else state.launch_id
|
|
401
|
+
key = (
|
|
402
|
+
launch if launch is not None else f"connection:{self._generation}",
|
|
403
|
+
message.num,
|
|
404
|
+
)
|
|
405
|
+
if key in self._logs:
|
|
406
|
+
return
|
|
407
|
+
self._logs[key] = None
|
|
408
|
+
if len(self._logs) > _LOG_LIMIT:
|
|
409
|
+
self._logs.popitem(last=False)
|
|
410
|
+
await self.emit(LogEvent(message.line or ""))
|
|
411
|
+
if (
|
|
412
|
+
state.launch_id is None
|
|
413
|
+
and message.launch_id is not None
|
|
414
|
+
and state.server_status != ServerStatus.OFFLINE
|
|
415
|
+
):
|
|
416
|
+
state.launch_id = message.launch_id
|
|
417
|
+
await self._publish_state()
|
|
418
|
+
return
|
|
419
|
+
case InfoMessage() | WarnMessage() | ErrorMessage():
|
|
420
|
+
line = message.line or ""
|
|
421
|
+
if isinstance(message, InfoMessage) and (found := _ADDRESS.search(line)):
|
|
422
|
+
state.server_address = found[1]
|
|
423
|
+
await self.emit(LogEvent(line, message.type))
|
|
424
|
+
case BlankMessage():
|
|
425
|
+
await self.emit(LogEvent(f"Unrecognized WS message type: {message.type}", "warn"))
|
|
426
|
+
return
|
|
427
|
+
case ConsoleMessage() | SlotMessage():
|
|
428
|
+
# Console is an acknowledgement; save labels arrive through options.
|
|
429
|
+
return
|
|
430
|
+
case _:
|
|
431
|
+
return
|
|
432
|
+
if {"auth", "server", "mods", "saves"} <= state.ready:
|
|
433
|
+
state.connection = ConnectionStatus.CONNECTED
|
|
434
|
+
await self._publish_state()
|
|
435
|
+
|
|
436
|
+
async def aclose(self) -> None:
|
|
437
|
+
task = self._close_task
|
|
438
|
+
if task is None:
|
|
439
|
+
task = self._close_task = asyncio.create_task(self._close())
|
|
440
|
+
await asyncio.shield(task)
|
|
441
|
+
|
|
442
|
+
async def _close(self) -> None:
|
|
443
|
+
self._closed = True
|
|
444
|
+
await self._disconnect()
|
|
445
|
+
tasks = list(self._operations)
|
|
446
|
+
runner = self._runner
|
|
447
|
+
if runner is not None and runner is not asyncio.current_task():
|
|
448
|
+
tasks.append(runner)
|
|
449
|
+
for task in tasks:
|
|
450
|
+
task.cancel()
|
|
451
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
452
|
+
try:
|
|
453
|
+
await self.socket.close()
|
|
454
|
+
finally:
|
|
455
|
+
await self.api.aclose()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fz_manager_plus.application.session import FactorioZoneSession
|
|
5
|
+
from fz_manager_plus.domain.state import TransferEvent, TransferProgress, TransferResult, UploadItem
|
|
6
|
+
from fz_manager_plus.utils.async_io import blocking_io
|
|
7
|
+
from fz_manager_plus.utils.concurrency import run_batched
|
|
8
|
+
from fz_manager_plus.utils.files import find_by_extension
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModTransferService:
|
|
12
|
+
def __init__(self, session: FactorioZoneSession):
|
|
13
|
+
self.session = session
|
|
14
|
+
|
|
15
|
+
async def prepare(self, directory: str) -> list[UploadItem]:
|
|
16
|
+
self.session.require_ready("mods")
|
|
17
|
+
files = await blocking_io(find_by_extension, directory, ".zip")
|
|
18
|
+
names = {mod.text for mod in self.session.mods}
|
|
19
|
+
|
|
20
|
+
def inspect_files():
|
|
21
|
+
return [
|
|
22
|
+
UploadItem(name, Path(file).stat().st_size, Path(file))
|
|
23
|
+
for name, file in sorted(files.items())
|
|
24
|
+
if name not in names
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
return await blocking_io(inspect_files)
|
|
28
|
+
|
|
29
|
+
async def upload(
|
|
30
|
+
self, items: list[UploadItem], notify: Callable[[TransferEvent], None]
|
|
31
|
+
) -> list[str]:
|
|
32
|
+
failed: list[str] = []
|
|
33
|
+
async with self.session.mod_uploads() as upload:
|
|
34
|
+
|
|
35
|
+
async def run_one(index: int) -> None:
|
|
36
|
+
item = items[index]
|
|
37
|
+
try:
|
|
38
|
+
self.session.require_ready("mods")
|
|
39
|
+
# Metadata open/close are short; all payload reads happen in the transport thread.
|
|
40
|
+
with item.path.open("rb") as stream:
|
|
41
|
+
await upload(
|
|
42
|
+
item.label,
|
|
43
|
+
stream,
|
|
44
|
+
item.size,
|
|
45
|
+
lambda done: notify(TransferProgress(index, done)),
|
|
46
|
+
)
|
|
47
|
+
notify(TransferResult(index))
|
|
48
|
+
except Exception as error:
|
|
49
|
+
failed.append(item.label)
|
|
50
|
+
notify(TransferResult(index, str(error) or type(error).__name__))
|
|
51
|
+
|
|
52
|
+
await run_batched(
|
|
53
|
+
[lambda i=i: run_one(i) for i in range(len(items))],
|
|
54
|
+
self.session.settings.sync_batch_size,
|
|
55
|
+
)
|
|
56
|
+
return failed
|