diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 18f814a9..883c438e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -39,6 +39,9 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# One place to define the Windows callback IPC port +set(IMAGER_CALLBACK_PORT "49629" CACHE STRING "TCP port for rpi-imager callback relay on Windows") + # Apply optimization flags globally to all targets (including bundled dependencies) if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo" OR CMAKE_BUILD_TYPE STREQUAL "MinSizeRel") # Use -Os instead of -O3 for better code size vs performance balance @@ -371,6 +374,13 @@ endif() if (WIN32) # Adding WIN32 prevents a console window being opened on Windows add_executable(${PROJECT_NAME} WIN32 ${SOURCES} ${DEPENDENCIES}) + + # Inject the callback port macro for Windows builds + target_compile_definitions(${PROJECT_NAME} + PRIVATE RPI_IMAGER_CALLBACK_PORT=${IMAGER_CALLBACK_PORT}) + + # Ensure the relay builds with the app + add_dependencies(${PROJECT_NAME} rpi-imager-callback-relay) else() add_executable(${PROJECT_NAME} ${SOURCES} ${DEPENDENCIES}) endif() diff --git a/src/imagewriter.cpp b/src/imagewriter.cpp index 80bc20f5..5d1a761d 100644 --- a/src/imagewriter.cpp +++ b/src/imagewriter.cpp @@ -43,7 +43,12 @@ #include #include #include +#include #endif +#include +#include +#include +#include #include #include #include @@ -2547,18 +2552,87 @@ void ImageWriter::openUrl(const QUrl &url) } } +bool ImageWriter::verifyAuthKey(const QString &s, bool strict) const +{ + // Base58 (no 0 O I l) + static const QRegularExpression base58OnlyRe(QStringLiteral("^[1-9A-HJ-NP-Za-km-z]+$")); + + // Required prefix + bool hasPrefix = s.startsWith(QStringLiteral("rpuak_")) || s.startsWith(QStringLiteral("rpoak_")); + if (!hasPrefix) + return false; + + const QString payload = s.mid(6); + bool base58Match = base58OnlyRe.match(payload).hasMatch(); + + if (payload.isEmpty() || !base58Match) + return false; + + if (strict) { + // Exactly 24 Base58 chars today → total length 30 + return payload.size() == 24; + } else { + // Future-proof: accept >=24 Base58 chars + return payload.size() >= 24; + } +} + +QString ImageWriter::parseTokenFromUrl(const QUrl &url, bool strictAuthKey) const { + // Handle QUrl or string, accept auth_key + if (!url.isValid()) + return {}; + + QUrlQuery q(url); + const QString val = q.queryItemValue(QStringLiteral("auth_key"), QUrl::FullyDecoded); + if (!val.isEmpty()) { + if (verifyAuthKey(val, strictAuthKey)) { + return val; + } + + qWarning() << "Ignoring auth_key with invalid format/length:" << val; + } + + return {}; +} + void ImageWriter::handleIncomingUrl(const QUrl &url) { qDebug() << "Incoming URL:" << url; - emit connectCallbackReceived(QVariant::fromValue(url)); + + auto token = parseTokenFromUrl(url); + if (!token.isEmpty()) { + if (!_piConnectToken.isEmpty()) { + if (_piConnectToken != token) { + // Let QML decide whether to overwrite + emit connectTokenConflictDetected(token); + } + + return; + } + + overwriteConnectToken(token); + } } -void ImageWriter::setRuntimeConnectToken(const QString &token) +void ImageWriter::overwriteConnectToken(const QString &token) { + // Ephemeral session-only Connect token (never persisted) _piConnectToken = token; + emit connectTokenReceived(token); } QString ImageWriter::getRuntimeConnectToken() const { return _piConnectToken; } + +QString ImageWriter::getClipboardText() const +{ +#ifndef CLI_ONLY_BUILD + QClipboard *clipboard = QGuiApplication::clipboard(); + if (clipboard) { + return clipboard->text(); + } +#endif + return QString(); +} diff --git a/src/imagewriter.h b/src/imagewriter.h index 97c54887..b4dbc298 100644 --- a/src/imagewriter.h +++ b/src/imagewriter.h @@ -267,9 +267,10 @@ class ImageWriter : public QObject Q_INVOKABLE void reboot(); Q_INVOKABLE void openUrl(const QUrl &url); Q_INVOKABLE void handleIncomingUrl(const QUrl &url); - // Ephemeral session-only Connect token (never persisted) - Q_INVOKABLE void setRuntimeConnectToken(const QString &token); + Q_INVOKABLE void overwriteConnectToken(const QString &token); Q_INVOKABLE QString getRuntimeConnectToken() const; + Q_INVOKABLE QString getClipboardText() const; + Q_INVOKABLE bool verifyAuthKey(const QString &token, bool strict = false) const; /* Override OS list refresh schedule (in minutes); pass negative to clear override */ Q_INVOKABLE void setOsListRefreshOverride(int intervalMinutes, int jitterMinutes); @@ -299,7 +300,8 @@ class ImageWriter : public QObject void keychainPermissionRequested(); void keychainPermissionResponseReceived(); void writeStateChanged(); - void connectCallbackReceived(QVariant url); + void connectTokenReceived(const QString &token); + void connectTokenConflictDetected(const QString &token); void cacheStatusChanged(); protected slots: @@ -339,6 +341,8 @@ protected slots: bool _deviceFilterIsInclusive; std::shared_ptr _device_info; + QString parseTokenFromUrl(const QUrl &url, bool strictAuthKey = false) const; + protected: QUrl _src, _repo; QString _dst, _parentCategory, _osName, _osReleaseDate, _currentLang, _currentLangcode, _currentKeyboard; diff --git a/src/main.cpp b/src/main.cpp index ed21f503..e1490e39 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,8 +37,8 @@ #ifdef Q_OS_WIN #include #include -#include -#include +#include +#include #endif #include "imageadvancedoptions.h" @@ -55,6 +55,15 @@ static QTextStream cerr(stderr); static void consoleMsgHandler(QtMsgType, const QMessageLogContext &, const QString &str) { cerr << str << endl; } + +// If CMake didn't inject it for some reason, fall back to a sensible default. +#ifndef RPI_IMAGER_CALLBACK_PORT +#define RPI_IMAGER_CALLBACK_PORT 49629 +#endif +static_assert(RPI_IMAGER_CALLBACK_PORT > 0 && RPI_IMAGER_CALLBACK_PORT <= 65535, + "RPI_IMAGER_CALLBACK_PORT must be a valid TCP port"); +static constexpr quint16 kPort = + static_cast(RPI_IMAGER_CALLBACK_PORT); #endif @@ -311,55 +320,25 @@ int main(int argc, char *argv[]) } #ifdef Q_OS_WIN - // ---- single-instance + URL forwarding ---- - // Build a stable per-user name by hashing the per-user AppData path. - // This is consistent across elevated/non-elevated tokens for the same user. - auto base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - if (base.isEmpty()) base = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); - QByteArray hash = QCryptographicHash::hash(base.toUtf8(), QCryptographicHash::Sha1); - const QString serverName = QStringLiteral("rpi-imager-urlpipe-%1").arg(QString::fromLatin1(hash.toHex())); - qDebug() << "Using IPC Server name:" << serverName; - - // First, try to connect to an existing instance (client-first). - { - QLocalSocket probe; - probe.connectToServer(serverName); - if (probe.waitForConnected(150)) { - // A primary instance exists; forward the URL (if any) and exit. - if (!callbackUrl.isEmpty()) { - const QByteArray msg = callbackUrl.toString(QUrl::FullyEncoded).toUtf8(); - probe.write(msg); - probe.flush(); - probe.waitForBytesWritten(200); - } - return 0; // secondary: do not open another window - } - } - - // No instance reachable. Become the server. - // Remove stale endpoint left by a crash (safe even if it doesn’t exist). - QLocalServer::removeServer(serverName); - - static QLocalServer server; // must outlive the lambda - if (!server.listen(serverName)) { - // As a last resort: if listen still fails, we’re better off continuing as a single instance. - qWarning() << "Failed to listen on" << serverName << ":" << server.errorString(); - } else { - QObject::connect(&server, &QLocalServer::newConnection, &app, [&imageWriter]() { - while (QLocalSocket *s = server.nextPendingConnection()) { - s->waitForReadyRead(1000); + // callback server + QTcpServer server; + QObject::connect(&server, &QTcpServer::newConnection, &app, [&]() { + while (auto *s = server.nextPendingConnection()) { + QObject::connect(s, &QTcpSocket::readyRead, s, [s, &imageWriter]() { const QByteArray payload = s->readAll(); - s->close(); - s->deleteLater(); + s->disconnectFromHost(); QMetaObject::invokeMethod( &imageWriter, [payload, &imageWriter] { imageWriter.handleIncomingUrl(QUrl(QString::fromUtf8(payload))); }, Qt::QueuedConnection - ); - } - }); + ); + }); + } + }); + if (!server.listen(QHostAddress::LocalHost, kPort)) { + qWarning() << "TCP listen failed:" << server.errorString(); } #endif diff --git a/src/windows/CallbackRelay.cpp b/src/windows/CallbackRelay.cpp new file mode 100644 index 00000000..e4ab4d72 --- /dev/null +++ b/src/windows/CallbackRelay.cpp @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +// Tiny Windows callback URL relay for rpi-imager + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#pragma comment(lib, "ws2_32.lib") + +#ifndef RPI_IMAGER_PORT +#define RPI_IMAGER_PORT 49629 +#endif +#ifndef RPI_IMAGER_EXE_NAME +#define RPI_IMAGER_EXE_NAME L"rpi-imager.exe" +#endif +// If nonzero: on IPC failure, start Imager with the URL as positional arg. +#ifndef RPI_IMAGER_START_ON_FAIL +#define RPI_IMAGER_START_ON_FAIL 1 +#endif + +static int send_url_over_tcp_utf8(const wchar_t* urlW) +{ + int rc = 1; + WSADATA wsa{}; + if (WSAStartup(MAKEWORD(2,2), &wsa) != 0) return 1; + + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (s == INVALID_SOCKET) { WSACleanup(); return 1; } + + // short connect timeout using non-blocking + select + u_long nb = 1; + ioctlsocket(s, FIONBIO, &nb); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(RPI_IMAGER_PORT); + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + + int c = connect(s, (sockaddr*)&addr, sizeof(addr)); + if (c == SOCKET_ERROR) { + fd_set wfds; FD_ZERO(&wfds); FD_SET(s, &wfds); + timeval tv{}; tv.tv_sec = 0; tv.tv_usec = 300 * 1000; // 300ms + if (select(0, nullptr, &wfds, nullptr, &tv) <= 0) { closesocket(s); WSACleanup(); return 2; } + int err = 0; socklen_t len = sizeof(err); + getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&err, &len); + if (err != 0) { closesocket(s); WSACleanup(); return 2; } + } + + // back to blocking for send + nb = 0; ioctlsocket(s, FIONBIO, &nb); + + // Convert URL (UTF-16) -> UTF-8 + int need = WideCharToMultiByte(CP_UTF8, 0, urlW, -1, nullptr, 0, nullptr, nullptr); + if (need <= 1 || need > 8192) { // Sanity check: reasonable max size + closesocket(s); WSACleanup(); return 1; + } + + // Send without trailing null + int bytes = need - 1; + char* buf = (char*)HeapAlloc(GetProcessHeap(), 0, bytes); + if (!buf) { closesocket(s); WSACleanup(); return 1; } + + int converted = WideCharToMultiByte(CP_UTF8, 0, urlW, -1, buf, need, nullptr, nullptr); + if (converted == 0) { + // Conversion failed + HeapFree(GetProcessHeap(), 0, buf); + closesocket(s); + WSACleanup(); + return 1; + } + + // Write all + int sentTot = 0; + while (sentTot < bytes) { + int n = send(s, buf + sentTot, bytes - sentTot, 0); + if (n <= 0) { rc = 2; break; } + sentTot += n; + } + + HeapFree(GetProcessHeap(), 0, buf); + closesocket(s); + WSACleanup(); + return rc == 2 ? 2 : 0; +} + +static void start_imager_with_url(const wchar_t* urlW) +{ + // Launch {app}\rpi-imager.exe "" + // Working dir = directory containing this relay + wchar_t exePath[MAX_PATH]; + DWORD pathLen = GetModuleFileNameW(nullptr, exePath, MAX_PATH); + + // Check for errors and truncation + if (pathLen == 0 || pathLen >= MAX_PATH) { + return; // Failed or path too long + } + + wchar_t* lastSlash = wcsrchr(exePath, L'\\'); + if (!lastSlash) return; + *lastSlash = L'\0'; // strip exe name, leave folder + + wchar_t imagerExe[MAX_PATH]; + HRESULT hr = StringCchPrintfW(imagerExe, _countof(imagerExe), L"%s\\%s", exePath, RPI_IMAGER_EXE_NAME); + if (FAILED(hr)) { + return; // Path construction failed (too long) + } + + // Verify the target executable exists before launching + DWORD attrs = GetFileAttributesW(imagerExe); + if (attrs == INVALID_FILE_ATTRIBUTES || (attrs & FILE_ATTRIBUTE_DIRECTORY)) { + return; // File doesn't exist or is a directory + } + + // Build command line: "rpi-imager.exe" "" + // Use ShellExecuteEx to respect UAC manifest of rpi-imager.exe + SHELLEXECUTEINFOW sei{}; + sei.cbSize = sizeof(sei); + sei.fMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; // No error UI on failure + sei.hwnd = nullptr; + sei.lpVerb = L"open"; + sei.lpFile = imagerExe; + sei.lpParameters = urlW; // single positional argument + sei.lpDirectory = exePath; // working dir = app folder + sei.nShow = SW_SHOWNORMAL; + + ShellExecuteExW(&sei); +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, LPWSTR cmdLine, int) +{ + // Windows passes the URL as %1, which appears in cmdLine for ShellExecute-based calls. + const wchar_t* urlW = cmdLine; + + // If cmdLine is empty, parse GetCommandLineW() to extract first argument + if (!urlW || !*urlW) { + urlW = GetCommandLineW(); + // Skip executable path (handles quoted and unquoted paths) + if (urlW && *urlW == L'"') { + urlW = wcschr(urlW + 1, L'"'); + if (urlW) urlW++; + } else if (urlW) { + while (*urlW && *urlW != L' ') urlW++; + } + // Skip spaces to get to first argument + while (urlW && *urlW == L' ') urlW++; + } + + if (!urlW || !*urlW) return 0; + + // Trim surrounding quotes if present and copy to buffer for safety + wchar_t urlBuf[2048]; + if (*urlW == L'"') { + // Copy and strip quotes + const wchar_t* start = urlW + 1; + const wchar_t* end = wcsrchr(start, L'"'); + if (end) { + size_t len = end - start; + if (len > 0 && len < _countof(urlBuf)) { + wcsncpy_s(urlBuf, _countof(urlBuf), start, len); + urlBuf[len] = L'\0'; + urlW = urlBuf; + } else { + return 0; // URL too long or empty after quote stripping + } + } else { + return 0; // Malformed: opening quote but no closing quote + } + } else { + // Not quoted - copy to buffer for consistent handling and length validation + size_t len = wcsnlen_s(urlW, _countof(urlBuf)); + if (len == 0 || len >= _countof(urlBuf)) { + return 0; // Empty or too long + } + wcsncpy_s(urlBuf, _countof(urlBuf), urlW, len); + urlBuf[len] = L'\0'; + urlW = urlBuf; + } + + // Validate: must start with rpi-imager:// + if (wcsncmp(urlW, L"rpi-imager://", 13) != 0) { + return 0; + } + + // Additional validation: check for reasonable URL length (after protocol) + size_t totalLen = wcslen(urlW); + if (totalLen < 14 || totalLen > 2000) { // min: rpi-imager://x, max: reasonable limit + return 0; + } + + // Validate: no control characters or unexpected chars in URL + for (size_t i = 0; i < totalLen; i++) { + wchar_t c = urlW[i]; + // Allow only printable ASCII, common URL chars, and some UTF-8 + // Block control chars (0x00-0x1F, 0x7F), and dangerous shell chars if any slip through + if (c < 0x20 || c == 0x7F) { + return 0; // Control character detected + } + } + + int r = send_url_over_tcp_utf8(urlW); +#if RPI_IMAGER_START_ON_FAIL + if (r != 0) { + start_imager_with_url(urlW); + return 0; + } +#endif + return 0; +} diff --git a/src/windows/Platform.cmake b/src/windows/Platform.cmake index 876022c6..7976f3c7 100644 --- a/src/windows/Platform.cmake +++ b/src/windows/Platform.cmake @@ -56,4 +56,13 @@ set(DEPENDENCIES ) set(EXTRALIBS setupapi ${CMAKE_BINARY_DIR}/wlanapi_delayed.lib Bcrypt.dll ole32 oleaut32 wbemuuid) - +# ---- Relay exe ---- +add_executable(rpi-imager-callback-relay WIN32 windows/CallbackRelay.cpp) +target_compile_definitions(rpi-imager-callback-relay + PRIVATE RPI_IMAGER_PORT=${IMAGER_CALLBACK_PORT} + RPI_IMAGER_EXE_NAME=L"rpi-imager.exe" + RPI_IMAGER_START_ON_FAIL=1) +target_link_libraries(rpi-imager-callback-relay PRIVATE ws2_32) +if (MINGW) + target_link_options(rpi-imager-callback-relay PRIVATE -municode) +endif() diff --git a/src/windows/PlatformPackaging.cmake b/src/windows/PlatformPackaging.cmake index 492639a6..a3cfab79 100644 --- a/src/windows/PlatformPackaging.cmake +++ b/src/windows/PlatformPackaging.cmake @@ -47,9 +47,13 @@ if (IMAGER_SIGNED_APP) endif() add_definitions(-DSIGNTOOL="${SIGNTOOL}") - add_custom_command(TARGET ${PROJECT_NAME} - POST_BUILD - COMMAND "${SIGNTOOL}" sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a "${CMAKE_BINARY_DIR}/${PROJECT_NAME}.exe") + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND "${SIGNTOOL}" sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a + "${CMAKE_BINARY_DIR}/${PROJECT_NAME}.exe") + + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND "${SIGNTOOL}" sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a + "${CMAKE_BINARY_DIR}/rpi-imager-callback-relay.exe") endif() # windeployqt @@ -64,7 +68,9 @@ add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_BINARY_DIR}/${PROJECT_NAME}.exe" - "${CMAKE_SOURCE_DIR}/../license.txt" "${CMAKE_SOURCE_DIR}/windows/rpi-imager-cli.cmd" + "${CMAKE_SOURCE_DIR}/../license.txt" + "${CMAKE_SOURCE_DIR}/windows/rpi-imager-cli.cmd" + "${CMAKE_BINARY_DIR}/rpi-imager-callback-relay.exe" "${CMAKE_BINARY_DIR}/deploy") add_custom_command(TARGET ${PROJECT_NAME} diff --git a/src/windows/platformquirks_windows.cpp b/src/windows/platformquirks_windows.cpp index 26732532..60659a57 100644 --- a/src/windows/platformquirks_windows.cpp +++ b/src/windows/platformquirks_windows.cpp @@ -165,6 +165,15 @@ void applyQuirks() { if (hasNvidiaGraphicsCard()) { SetEnvironmentVariableA("QSG_RHI_PREFER_SOFTWARE_RENDERER", "1"); } + + // make imager single instance because of rpi-connect callback server + // will be automatically released once the process exits cleanly or crashes + HANDLE hMutex = CreateMutexW(nullptr, TRUE, L"Global\\RaspberryPiImagerMutex"); + if (GetLastError() == ERROR_ALREADY_EXISTS) { + // Another instance running + MessageBoxW(nullptr, L"Raspberry Pi Imager is already running.", L"Raspberry Pi Imager", MB_OK | MB_ICONINFORMATION); + exit(0); + } } void beep() { @@ -172,4 +181,4 @@ void beep() { MessageBeep(MB_OK); } -} // namespace PlatformQuirks \ No newline at end of file +} // namespace PlatformQuirks diff --git a/src/windows/rpi-imager.iss.in b/src/windows/rpi-imager.iss.in index c6c57f39..84679835 100644 --- a/src/windows/rpi-imager.iss.in +++ b/src/windows/rpi-imager.iss.in @@ -66,13 +66,14 @@ Root: HKLM; Subkey: "Software\Classes\RPI_IMAGINGUTILITY\shell\open\command"; Va Root: HKCR; Subkey: "rpi-imager"; ValueType: string; ValueName: ""; ValueData: "URL:Raspberry Pi Imager"; Flags: uninsdeletekey Root: HKCR; Subkey: "rpi-imager"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey Root: HKCR; Subkey: "rpi-imager\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},1"; Flags: uninsdeletekey -Root: HKCR; Subkey: "rpi-imager\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCR; Subkey: "rpi-imager\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\rpi-imager-callback-relay.exe"" ""%1"""; Flags: uninsdeletekey [Files] ; Main program files Source: "@CMAKE_BINARY_DIR@\deploy\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion signonce Source: "@CMAKE_BINARY_DIR@\deploy\rpi-imager-cli.cmd"; DestDir: "{app}"; Flags: ignoreversion +Source: "@CMAKE_BINARY_DIR@\deploy\rpi-imager-callback-relay.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "@CMAKE_BINARY_DIR@\deploy\license.txt"; DestDir: "{app}"; Flags: ignoreversion ; Core DLLs diff --git a/src/wizard/PiConnectCustomizationStep.qml b/src/wizard/PiConnectCustomizationStep.qml index 3a935f0d..add40cef 100644 --- a/src/wizard/PiConnectCustomizationStep.qml +++ b/src/wizard/PiConnectCustomizationStep.qml @@ -3,9 +3,9 @@ * Copyright (C) 2025 Raspberry Pi Ltd */ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts import "../qmlcomponents" import "components" @@ -62,18 +62,55 @@ WizardStepBase { } } - // Status line (only when enabled) - RowLayout { + // Token input field (only when enabled) + ImTextField { + id: fieldConnectToken Layout.fillWidth: true - spacing: Style.spacingSmall + font.pixelSize: Style.fontSizeInput visible: useTokenPill.checked - WizardFormLabel { text: qsTr("Status:") } - Text { - Layout.fillWidth: true - font.pixelSize: Style.fontSizeDescription - font.family: Style.fontFamily - color: Style.textDescriptionColor - text: root.connectTokenReceived ? qsTr("Token received from browser") : qsTr("Waiting for token") + enabled: root.tokenFieldEnabled + persistentSelection: true + mouseSelectionMode: TextInput.SelectCharacters + placeholderText: { + if (root.connectTokenReceived) { + return qsTr("Token received from browser") + } else if (root.countdownSeconds > 0) { + return qsTr("Waiting for token (%1s)").arg(root.countdownSeconds) + } else { + return qsTr("Paste token here") + } + } + text: root.connectToken + onTextChanged: { + var token = text.trim() + if (token && token.length > 0) { + root.connectToken = token + countdownTimer.stop() + // Don't automatically validate or set as received - that happens on Next click + } else { + root.connectTokenReceived = false + root.connectToken = "" + } + } + + ContextMenu.menu: Menu { + MenuItem { + text: qsTr("Paste") + enabled: fieldConnectToken.enabled + onTriggered: { + var clipboardText = root.imageWriter.getClipboardText() + if (clipboardText && clipboardText.length > 0) { + fieldConnectToken.text = clipboardText.trim() + fieldConnectToken.forceActiveFocus() + } + } + } + + MenuItem { + text: qsTr("Select All") + enabled: fieldConnectToken.enabled && fieldConnectToken.text.length > 0 + onTriggered: fieldConnectToken.selectAll() + } } } } @@ -83,62 +120,180 @@ WizardStepBase { // Token state and parsing helpers property bool connectTokenReceived: false property string connectToken: "" + property int countdownSeconds: 90 + property bool tokenFieldEnabled: false + property bool tokenFromBrowser: false + property bool isValid: false + + // Countdown timer + Timer { + id: countdownTimer + interval: 1000 + repeat: true + running: false + onTriggered: { + if (root.countdownSeconds > 0) { + root.countdownSeconds-- + } + if (root.countdownSeconds === 0) { + stop() + root.tokenFieldEnabled = true + } + } + } - function parseTokenFromUrl(u) { - // Handle QUrl or string, accept token/code/deploy_key/auth_key - var s = "" - try { - if (u && typeof u.toString === 'function') s = u.toString(); else s = String(u) - } catch(e) { s = String(u) } - if (!s || s.length === 0) return "" - var qIndex = s.indexOf('?') - if (qIndex === -1) return "" - var query = s.substring(qIndex+1) - var parts = query.split('&') - var token = "" - for (var i = 0; i < parts.length; i++) { - var kv = parts[i].split('=') - if (kv.length >= 2) { - var key = decodeURIComponent(kv[0]) - var val = decodeURIComponent(kv.slice(1).join('=')) - if (key === 'token' || key === 'code' || key === 'deploy_key' || key === 'auth_key') { - token = val - break + Connections { + target: root.imageWriter + + // Listen for callback with token + function onConnectTokenReceived(token){ + if (token && token.length > 0) { + root.connectTokenReceived = true + root.connectToken = token + root.tokenFromBrowser = true + // Stop the countdown but KEEP field disabled forever when from browser + countdownTimer.stop() + root.tokenFieldEnabled = false + // Update the text field when token is received from browser + if (fieldConnectToken) { + fieldConnectToken.text = token } } } - return token } Component.onCompleted: { - root.registerFocusGroup("pi_connect", function(){ return [btnOpenConnect, useTokenPill.focusItem] }, 0) - var saved = imageWriter.getSavedCustomizationSettings() + root.registerFocusGroup("pi_connect", function(){ + var items = [useTokenPill.focusItem] + if (useTokenPill.checked) + items.push(btnOpenConnect) + if (fieldConnectToken.enabled) + items.push(fieldConnectToken) + return items + }, 0) + + var token = root.imageWriter.getRuntimeConnectToken() + if (token && token.length > 0) { + root.connectTokenReceived = true + root.connectToken = token + // If token already exists, assume it came from browser (keep disabled) + root.tokenFromBrowser = true + root.tokenFieldEnabled = false + // Update the text field with the existing token + if (fieldConnectToken) { + fieldConnectToken.text = token + } + } + // Never load token from persistent settings; token is session-only - if (saved.piConnectEnabled === true || saved.piConnectEnabled === "true") { + // auto enable if token has already been provided + if (root.connectTokenReceived) { useTokenPill.checked = true - wizardContainer.piConnectEnabled = true + root.wizardContainer.piConnectEnabled = true } - // Listen for callback with token - root.imageWriter.connectCallbackReceived.connect(function(url){ - var t = parseTokenFromUrl(url) - if (t && t.length > 0) { - connectTokenReceived = true - connectToken = t - imageWriter.setRuntimeConnectToken(t) + } + + // Start countdown when user clicks "Open Raspberry Pi Connect" + Connections { + target: btnOpenConnect + function onClicked() { + if (!root.connectTokenReceived && !countdownTimer.running) { + root.countdownSeconds = 90 + countdownTimer.start() } - }) + } } onNextClicked: { - var saved = imageWriter.getSavedCustomizationSettings() + // Reset validation state + root.isValid = false + if (useTokenPill.checked) { - saved.piConnectEnabled = true - wizardContainer.piConnectEnabled = true + // Validate the token if user entered it manually (not from browser) + if (!root.tokenFromBrowser) { + var tokenToValidate = root.connectToken.trim() + if (!tokenToValidate || tokenToValidate.length === 0) { + // No token provided + invalidTokenDialog.open() + return + } + + // Validate token format + var tokenIsValid = root.imageWriter.verifyAuthKey(tokenToValidate, true) + //console.log("Token validation result:", tokenIsValid, "for token:", tokenToValidate) + + if (!tokenIsValid) { + // Token is invalid + invalidTokenDialog.open() + return + } + // Token is valid, set it in imageWriter + root.imageWriter.overwriteConnectToken(tokenToValidate) + } + root.wizardContainer.piConnectEnabled = true + root.isValid = true } else { - delete saved.piConnectEnabled - wizardContainer.piConnectEnabled = false + // Not checked, just allow to proceed + root.wizardContainer.piConnectEnabled = false + root.isValid = true + } + } + + // Invalid token dialog + BaseDialog { + id: invalidTokenDialog + parent: root.wizardContainer && root.wizardContainer.overlayRootRef ? root.wizardContainer.overlayRootRef : undefined + anchors.centerIn: parent + visible: false + + function escapePressed() { + invalidTokenDialog.close() + } + + Component.onCompleted: { + registerFocusGroup("buttons", function(){ + return [okBtn] + }, 0) + } + + // Dialog content + Text { + text: qsTr("Invalid Token") + font.pixelSize: Style.fontSizeHeading + font.family: Style.fontFamilyBold + font.bold: true + color: Style.formLabelErrorColor + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + text: qsTr("The token you entered is not valid. Please check the token and try again, or use the 'Open Raspberry Pi Connect' button to get a valid token.") + font.pixelSize: Style.fontSizeFormLabel + font.family: Style.fontFamily + color: Style.formLabelColor + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.spacingMedium + Item { Layout.fillWidth: true } + + ImButton { + id: okBtn + text: qsTr("OK") + activeFocusOnTab: true + onClicked: { + invalidTokenDialog.close() + // Clear the invalid token + fieldConnectToken.text = "" + root.connectToken = "" + root.connectTokenReceived = false + } + } } - imageWriter.setSavedCustomizationSettings(saved) } // Handle skip button diff --git a/src/wizard/WifiCustomizationStep.qml b/src/wizard/WifiCustomizationStep.qml index caef2796..dfd59427 100644 --- a/src/wizard/WifiCustomizationStep.qml +++ b/src/wizard/WifiCustomizationStep.qml @@ -47,6 +47,9 @@ WizardStepBase { }, 1) root.registerFocusGroup("wifi_options", function(){ return [chkWifiHidden] }, 2) + // Set SSID placeholder before prefilling text content + fieldWifiSSID.placeholderText = qsTr("Network name") + // Prefill from saved settings var saved = imageWriter.getSavedCustomizationSettings() diff --git a/src/wizard/WizardContainer.qml b/src/wizard/WizardContainer.qml index 650a5db0..94f0b7f7 100644 --- a/src/wizard/WizardContainer.qml +++ b/src/wizard/WizardContainer.qml @@ -886,7 +886,13 @@ Item { imageWriter: root.imageWriter wizardContainer: root appOptionsButton: optionsButton - onNextClicked: root.nextStep() + onNextClicked: { + // Only advance if the step indicates it's ready + if (isValid) { + root.nextStep() + } + // Otherwise, let the step handle the action internally (showing dialog, etc.) + } onBackClicked: root.previousStep() onSkipClicked: { // Skip functionality is handled in the step itself @@ -944,6 +950,120 @@ Item { onNextClicked: root.wizardCompleted() } } + + // Token conflict dialog — based on your BaseDialog pattern + BaseDialog { + id: tokenConflictDialog + parent: root + anchors.centerIn: parent + + // carry the new token we just received + property string newToken: "" + property bool allowAccept: false + + // small safety delay before enabling "Replace" + Timer { + id: acceptEnableDelay + interval: 1500 + running: false + repeat: false + onTriggered: tokenConflictDialog.allowAccept = true + } + + function openWithToken(tok) { + newToken = tok + allowAccept = false + acceptEnableDelay.start() + tokenConflictDialog.open() + } + + // ESC closes + function escapePressed() { tokenConflictDialog.close() } + + Component.onCompleted: { + // match your focus group style + registerFocusGroup("token_conflict_buttons", function() { + return [keepBtn, replaceBtn] + }, 0) + } + + onClosed: { + acceptEnableDelay.stop() + allowAccept = false + newToken = "" + } + + // ----- CONTENT ----- + Text { + id: titleText + text: qsTr("Replace existing Raspberry Pi Connect token?") + font.pixelSize: Style.fontSizeHeading + font.family: Style.fontFamilyBold + font.bold: true + color: Style.formLabelColor + wrapMode: Text.WordWrap + Layout.fillWidth: true + Accessible.role: Accessible.Heading + Accessible.name: text + Accessible.ignored: false + } + + // Body / security note + Text { + id: bodyText + text: qsTr("A new Raspberry Pi Connect token was received that differs from your current one.\n\n") + + qsTr("Do you want to overwrite the existing token?\n\n") + + qsTr("Warning: Only replace the token if you initiated this action. ") + + qsTr("If you didn't, someone could be trying to push a bad token to RPi Imager.") + font.pixelSize: Style.fontSizeFormLabel + font.family: Style.fontFamily + color: Style.formLabelColor + wrapMode: Text.WordWrap + Layout.fillWidth: true + Accessible.role: Accessible.StaticText + Accessible.name: text + Accessible.ignored: false + } + + // Buttons row + RowLayout { + id: btnRow + Layout.fillWidth: true + Layout.topMargin: Style.spacingSmall + spacing: Style.spacingMedium + + Item { Layout.fillWidth: true } + + ImButton { + id: replaceBtn + text: tokenConflictDialog.allowAccept ? qsTr("Replace token") : qsTr("Please wait…") + accessibleDescription: qsTr("Replace the current token with the newly received one") + enabled: tokenConflictDialog.allowAccept + activeFocusOnTab: true + onClicked: { + tokenConflictDialog.close() + // Overwrite in C++ and re-emit to existing listeners + root.imageWriter.overwriteConnectToken(tokenConflictDialog.newToken) + } + } + + ImButtonRed { + id: keepBtn + text: qsTr("Keep existing") + accessibleDescription: qsTr("Keep your current Raspberry Pi Connect token") + activeFocusOnTab: true + onClicked: tokenConflictDialog.close() + } + } + } + + Connections { + target: root.imageWriter + function onConnectTokenConflictDetected(newToken) { + tokenConflictDialog.openWithToken(newToken) + } + } + function onFinalizing() { // Forward to the WritingStep if currently active