From d4c76a4e37715a952c671ffe090d55b5a9068d6a Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Thu, 4 Jun 2026 23:19:25 +0200 Subject: [PATCH] feat(ui): login + conflict dialogs (Qt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login dialog (prompt_login_qt): Modal asking for a discord_id:password token. Echo masked. Validates format before accepting. 'Skip cloud sync' returns None so caller can bypass sync without blocking the launch. Conflict dialog (prompt_conflict_qt): Modal with three radio options — keep_local, use_remote, cancel. Shown when local files were modified since the last pulled snapshot AND the remote has a newer snapshot from another host (divergence state.json detection happens in sync.pull; not wired yet). Both reuse the Prism dark palette via _apply_prism_dark. Tk fallbacks not implemented — Qt is the path most players will hit. Next: state.json (last_pulled_snapshot_id), divergence detection in sync.pull, integration with these dialogs. --- cloud_sync/ui_qt.py | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/cloud_sync/ui_qt.py b/cloud_sync/ui_qt.py index da3e8ef..7878784 100644 --- a/cloud_sync/ui_qt.py +++ b/cloud_sync/ui_qt.py @@ -184,3 +184,171 @@ class QtProgressWindow: def _on_finished(self, _rc: int) -> None: self._dialog.accept() + + +# --------------------------------------------------------------------------- +# Login dialog (first-run token capture) +# --------------------------------------------------------------------------- + + +def prompt_login_qt() -> str | None: + """Modal dialog to collect a fresh ``discord_id:password`` token. + + Returned string is the validated raw token (caller writes to disk). + Returns ``None`` if the user picked "Skip cloud sync" or closed the + dialog — caller should treat that as "don't sync, don't block launch." + """ + QtWidgets, QtCore, _ = import_qt() + app_existed = QtWidgets.QApplication.instance() is not None + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + if not app_existed: + _apply_prism_dark(app) + + dialog = QtWidgets.QDialog() + dialog.setWindowTitle("Cloud sync — first time setup") + dialog.setFixedSize(480, 260) + + layout = QtWidgets.QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + title = QtWidgets.QLabel("Connect to cloud save") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) + layout.addWidget(title) + + body = QtWidgets.QLabel( + "In Discord, message the bot:\n" + " /cloud register\n\n" + "It will DM you a one-line token. Paste it below:" + ) + body.setWordWrap(True) + layout.addWidget(body) + + field = QtWidgets.QLineEdit() + field.setPlaceholderText("123456789012345678:a1b2c3d4…") + field.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password) + layout.addWidget(field) + + error = QtWidgets.QLabel("") + error.setStyleSheet("color: #ff6b6b;") + layout.addWidget(error) + + layout.addStretch(1) + + button_row = QtWidgets.QHBoxLayout() + skip = QtWidgets.QPushButton("Skip cloud sync") + button_row.addWidget(skip) + button_row.addStretch(1) + save = QtWidgets.QPushButton("Save and continue") + save.setDefault(True) + button_row.addWidget(save) + layout.addLayout(button_row) + + chosen: dict[str, str | None] = {"value": None} + + def on_save() -> None: + token = field.text().strip() + if ":" not in token: + error.setText("Token must be discord_id:password") + return + head, sep, tail = token.partition(":") + if not head.isdigit() or not tail: + error.setText("discord_id must be numeric; password must be non-empty") + return + chosen["value"] = token + dialog.accept() + + def on_skip() -> None: + chosen["value"] = None + dialog.reject() + + save.clicked.connect(on_save) + skip.clicked.connect(on_skip) + field.returnPressed.connect(on_save) + + dialog.exec() + return chosen["value"] + + +# --------------------------------------------------------------------------- +# Conflict dialog (local-modified-since-last-sync + remote-newer) +# --------------------------------------------------------------------------- + + +def prompt_conflict_qt( + local_summary: str, + remote_summary: str, +) -> str: + """Ask how to resolve a divergence. + + Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``. + """ + QtWidgets, QtCore, _ = import_qt() + app_existed = QtWidgets.QApplication.instance() is not None + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + if not app_existed: + _apply_prism_dark(app) + + dialog = QtWidgets.QDialog() + dialog.setWindowTitle("Cloud sync — conflict") + dialog.setFixedSize(520, 320) + + layout = QtWidgets.QVBoxLayout(dialog) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + title = QtWidgets.QLabel("Local and cloud diverged") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) + layout.addWidget(title) + + summary = QtWidgets.QLabel( + f"Local changes since last sync:\n {local_summary}\n\n" + f"Cloud has newer changes from another machine:\n {remote_summary}\n\n" + f"What should we do?" + ) + summary.setWordWrap(True) + layout.addWidget(summary) + + keep_local = QtWidgets.QRadioButton( + "Keep local — skip pull. Next exit will overwrite cloud." + ) + use_remote = QtWidgets.QRadioButton( + "Use cloud — overwrite local files with the cloud version." + ) + cancel_launch = QtWidgets.QRadioButton( + "Cancel launch — don't start Minecraft until I resolve manually." + ) + keep_local.setChecked(True) + layout.addWidget(keep_local) + layout.addWidget(use_remote) + layout.addWidget(cancel_launch) + + layout.addStretch(1) + + button_row = QtWidgets.QHBoxLayout() + button_row.addStretch(1) + confirm = QtWidgets.QPushButton("Continue") + confirm.setDefault(True) + button_row.addWidget(confirm) + layout.addLayout(button_row) + + chosen: dict[str, str] = {"value": "cancel"} + + def on_confirm() -> None: + if keep_local.isChecked(): + chosen["value"] = "keep_local" + elif use_remote.isChecked(): + chosen["value"] = "use_remote" + else: + chosen["value"] = "cancel" + dialog.accept() + + confirm.clicked.connect(on_confirm) + dialog.exec() + return chosen["value"]