-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
237 lines (188 loc) · 8.81 KB
/
Copy pathmain.py
File metadata and controls
237 lines (188 loc) · 8.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""
TuneOS Desktop — Application entry point.
Orchestrates the startup sequence:
1. Enforce single-instance via QLockFile
2. Show splash screen
3. Check Docker availability (prompt if missing)
4. Start backend services (docker-compose + Reflex) in a QThread
5. Wait for the server to become ready
6. Show the main window and system tray
7. Close the splash
8. Cleanup on exit
Uses QTimer so all UI updates remain responsive while the backend
is booting in the background.
"""
from __future__ import annotations
import logging
import os
import sys
import tempfile
from PyQt6.QtCore import QLockFile, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from PyQt6.QtWidgets import QApplication, QMessageBox
from desktop.docker_check import DockerRequiredDialog
from desktop.main_window import MainWindow
from desktop.process_manager import ProcessManager
from desktop.splash_screen import SplashScreen
from desktop.system_tray import SystemTray
# ── Logging ──────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
log = logging.getLogger("tuneos.main")
def _build_app_icon() -> QIcon:
"""Create a simple blue-hex pixmap icon for the application."""
size = 128
pixmap = QPixmap(size, size)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QColor("#3b82f6"))
font = QFont("Segoe UI Symbol", 80)
font.setWeight(QFont.Weight.Bold)
painter.setFont(font)
painter.drawText(pixmap.rect(), 0x0084, "⬢") # Qt.AlignCenter
painter.end()
return QIcon(pixmap)
class _StartupThread(QThread):
"""Runs process_manager.start() off the main thread to avoid freezing the UI."""
finished = pyqtSignal(bool) # True = started OK
def __init__(self, process_manager: ProcessManager) -> None:
super().__init__()
self._pm = process_manager
def run(self) -> None:
ok = self._pm.start()
self.finished.emit(ok)
class _Launcher:
"""Coordinates the multi-step, timer-driven startup sequence.
Keeps references to every long-lived object so they are not
garbage-collected until the application exits.
"""
def __init__(self, app: QApplication) -> None:
self._app = app
self._splash: SplashScreen | None = None
self._process_manager: ProcessManager | None = None
self._main_window: MainWindow | None = None
self._tray: SystemTray | None = None
self._docker_available = False
# ── Step 1: Splash ───────────────────────────────────────────
def start(self) -> None:
"""Show the splash and kick off step 2 after a short delay."""
self._splash = SplashScreen()
self._splash.show()
self._splash.set_status("Initialising…")
self._splash.set_progress(5)
# Give the event loop one tick to paint the splash
QTimer.singleShot(100, self._step_docker_check)
# ── Step 2: Docker Check ─────────────────────────────────────
def _step_docker_check(self) -> None:
if self._splash:
self._splash.set_status("Checking Docker…")
self._splash.set_progress(15)
self._docker_available = DockerRequiredDialog.check_and_prompt()
log.info("Docker available: %s", self._docker_available)
QTimer.singleShot(50, self._step_start_backend)
# ── Step 3: Start Backend ────────────────────────────────────
def _step_start_backend(self) -> None:
if self._splash:
self._splash.set_status("Starting services…")
self._splash.set_progress(30)
self._process_manager = ProcessManager()
self._process_manager.status_changed.connect(self._on_status_changed)
self._process_manager.server_ready.connect(self._on_server_ready)
self._process_manager.server_failed.connect(self._on_server_failed)
# Run start() in a background thread so the Qt event loop stays
# responsive (subprocess.run can block for up to 60 s).
self._startup_thread = _StartupThread(self._process_manager)
self._startup_thread.finished.connect(self._on_startup_thread_done)
self._startup_thread.start()
def _on_startup_thread_done(self, ok: bool) -> None:
if not ok:
log.warning("Backend start reported failure; polling anyway.")
# Begin polling for the server in a non-blocking fashion
self._poll_timer = QTimer()
self._poll_timer.setInterval(500)
self._poll_count = 0
self._poll_max = 60 # 30 seconds at 500 ms intervals
self._poll_timer.timeout.connect(self._poll_server)
self._poll_timer.start()
def _poll_server(self) -> None:
"""Check if the Reflex server is responding; update splash."""
self._poll_count += 1
progress = min(30 + int(self._poll_count / self._poll_max * 60), 90)
if self._splash:
self._splash.set_progress(progress)
self._splash.set_status("Loading UI…")
if self._process_manager and self._process_manager.is_server_ready():
self._poll_timer.stop()
self._on_server_ready()
return
if self._poll_count >= self._poll_max:
self._poll_timer.stop()
self._on_server_failed()
# ── Step 4: Server Ready → show window ───────────────────────
def _on_server_ready(self) -> None:
log.info("Server is ready.")
if self._splash:
self._splash.set_status("Ready!")
self._splash.set_progress(100)
self._show_main_window()
def _on_server_failed(self) -> None:
log.warning("Server did not start in time — showing window anyway.")
if self._splash:
self._splash.set_status("Server slow — opening anyway…")
self._splash.set_progress(100)
self._show_main_window()
def _show_main_window(self) -> None:
self._main_window = MainWindow()
self._main_window.closing.connect(self._cleanup)
self._main_window.show()
if self._process_manager and self._process_manager.is_server_ready():
self._main_window.status_bar_widget.set_server_status("Running", is_ready=True)
else:
self._main_window.status_bar_widget.set_server_status("Starting…", is_ready=False)
# System tray
self._tray = SystemTray(self._main_window)
# Close splash
if self._splash:
self._splash.close_splash()
self._splash = None
# ── Signal Handlers ──────────────────────────────────────────
def _on_status_changed(self, text: str) -> None:
if self._splash:
self._splash.set_status(text)
# ── Cleanup ──────────────────────────────────────────────────
def _cleanup(self) -> None:
"""Gracefully stop backend services."""
log.info("Cleaning up…")
if self._process_manager:
self._process_manager.stop()
# ── Main ─────────────────────────────────────────────────────────
def main() -> None:
"""Application entry point."""
app = QApplication(sys.argv)
app.setApplicationName("TuneOS")
app.setOrganizationName("TuneOS")
app.setWindowIcon(_build_app_icon())
app.setQuitOnLastWindowClosed(False)
# ── Single-instance guard ────────────────────────────────────
lock_path = os.path.join(tempfile.gettempdir(), "tuneos.lock")
lock_file = QLockFile(lock_path)
if not lock_file.tryLock(100):
QMessageBox.warning(
None,
"TuneOS already running",
"Another instance of TuneOS is already open.\nPlease use the existing window.",
)
sys.exit(0)
launcher = _Launcher(app)
launcher.start()
exit_code = app.exec()
# Ensure backend processes are cleaned up even if the window was
# force-closed without the closeEvent signal firing.
launcher._cleanup()
lock_file.unlock()
sys.exit(exit_code)
if __name__ == "__main__":
main()