From a7783671f2a6788ddea2d98720d8dc4423b947d6 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sat, 26 Sep 2026 10:17:13 +0200 Subject: [PATCH] Add bulk multi-photo upload to the desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Android bulk feature in the Qt uploader: choose several photos at once, each a card with its own caption, date, and upload status, and post the batch. - Per-photo cards (PhotoItemWidget) in a scrollable list; retry re-posts only the failures. - One upload per photo with a per-item UUID slug suffix so a batch posted in the same second cannot collide. - Image prep runs off the UI thread via QtConcurrent so the window stays responsive. - A "Simultaneous uploads" setting (1–8, default 3) caps how many photos are prepared/uploaded at once; the rest queue and fill freed slots. --- desktop/CMakeLists.txt | 4 +- desktop/mainwindow.cpp | 296 ++++++++++++++++++++++++------------ desktop/mainwindow.h | 32 +++- desktop/photoitemwidget.cpp | 101 ++++++++++++ desktop/photoitemwidget.h | 40 +++++ desktop/settings.cpp | 13 ++ desktop/settings.h | 6 + 7 files changed, 387 insertions(+), 105 deletions(-) create mode 100644 desktop/photoitemwidget.cpp create mode 100644 desktop/photoitemwidget.h diff --git a/desktop/CMakeLists.txt b/desktop/CMakeLists.txt index 7913910..cb3bf7a 100644 --- a/desktop/CMakeLists.txt +++ b/desktop/CMakeLists.txt @@ -5,12 +5,13 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets Network) +find_package(Qt6 REQUIRED COMPONENTS Widgets Network Concurrent) find_package(Qt6Keychain REQUIRED) add_executable(uploader main.cpp mainwindow.cpp + photoitemwidget.cpp settings.cpp uploadclient.cpp imageprep.cpp @@ -19,5 +20,6 @@ add_executable(uploader target_link_libraries(uploader PRIVATE Qt6::Widgets Qt6::Network + Qt6::Concurrent Qt6Keychain::Qt6Keychain ) diff --git a/desktop/mainwindow.cpp b/desktop/mainwindow.cpp index c842d45..0cfb829 100644 --- a/desktop/mainwindow.cpp +++ b/desktop/mainwindow.cpp @@ -1,27 +1,25 @@ #include "mainwindow.h" #include "imageprep.h" +#include "photoitemwidget.h" +#include "uploadclient.h" -#include #include -#include #include #include #include -#include -#include +#include +#include #include #include -#include #include +#include +#include #include #include -#include +#include #include - -namespace { -constexpr int kPreviewSize = 360; -} +#include MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) @@ -33,7 +31,6 @@ MainWindow::MainWindow(QWidget *parent) m_stack->addWidget(buildPostPage()); // index 1 setCentralWidget(m_stack); - // Land on settings until there is somewhere to post to. if (m_settings.isConfigured()) showPost(); else @@ -52,9 +49,12 @@ QWidget *MainWindow::buildSettingsPage() m_username = new QLineEdit; m_password = new QLineEdit; m_password->setEchoMode(QLineEdit::Password); + m_concurrency = new QSpinBox; + m_concurrency->setRange(1, Settings::kMaxConcurrency); form->addRow(QStringLiteral("Base URL"), m_baseUrl); form->addRow(QStringLiteral("Username"), m_username); form->addRow(QStringLiteral("Password"), m_password); + form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency); layout->addLayout(form); m_saveButton = new QPushButton(QStringLiteral("Save && test connection")); @@ -79,47 +79,43 @@ QWidget *MainWindow::buildSettingsPage() QWidget *MainWindow::buildPostPage() { auto *page = new QWidget; - auto *layout = new QVBoxLayout(page); - layout->setContentsMargins(24, 24, 24, 24); + auto *outer = new QVBoxLayout(page); + outer->setContentsMargins(24, 24, 24, 24); - m_preview = new QLabel(QStringLiteral("No photo selected")); - m_preview->setAlignment(Qt::AlignCenter); - m_preview->setFixedSize(kPreviewSize, kPreviewSize); - m_preview->setFrameShape(QFrame::Box); - layout->addWidget(m_preview, 0, Qt::AlignHCenter); + auto *choose = new QPushButton(QStringLiteral("Choose photos…")); + connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages); + outer->addWidget(choose); - m_chooseButton = new QPushButton(QStringLiteral("Choose a photo…")); - connect(m_chooseButton, &QPushButton::clicked, this, &MainWindow::chooseImage); - layout->addWidget(m_chooseButton); - - auto *form = new QFormLayout; - m_caption = new QLineEdit; - m_date = new QDateEdit(QDate::currentDate()); - m_date->setCalendarPopup(true); - m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); - form->addRow(QStringLiteral("Caption"), m_caption); - form->addRow(QStringLiteral("Date"), m_date); - layout->addLayout(form); + // Scrollable list of per-photo cards. + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + auto *container = new QWidget; + m_itemsLayout = new QVBoxLayout(container); + m_itemsLayout->setAlignment(Qt::AlignTop); + m_emptyHint = new QLabel(QStringLiteral("No photos selected")); + m_emptyHint->setAlignment(Qt::AlignCenter); + m_itemsLayout->addWidget(m_emptyHint); + scroll->setWidget(container); + outer->addWidget(scroll, 1); m_postButton = new QPushButton(QStringLiteral("Post")); m_postButton->setEnabled(false); connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit); - layout->addWidget(m_postButton); + outer->addWidget(m_postButton); - m_postStatus = new QLabel; - m_postStatus->setWordWrap(true); - layout->addWidget(m_postStatus); + m_postSummary = new QLabel; + m_postSummary->setWordWrap(true); + outer->addWidget(m_postSummary); m_lastPost = new QLabel; m_lastPost->setOpenExternalLinks(true); m_lastPost->setWordWrap(true); - layout->addWidget(m_lastPost); + outer->addWidget(m_lastPost); auto *settingsButton = new QPushButton(QStringLiteral("Settings")); connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings); - layout->addWidget(settingsButton); + outer->addWidget(settingsButton); - layout->addStretch(); return page; } @@ -128,6 +124,7 @@ void MainWindow::showSettings() m_baseUrl->setText(m_settings.baseUrl()); m_username->setText(m_settings.username()); m_password->setText(m_settings.readPassword()); + m_concurrency->setValue(m_settings.concurrency()); m_settingsStatus->clear(); m_settingsBack->setVisible(m_settings.isConfigured()); m_stack->setCurrentIndex(0); @@ -147,6 +144,9 @@ void MainWindow::showPost() void MainWindow::saveAndTest() { + // Concurrency is not a credential; persist it regardless of URL validity. + m_settings.setConcurrency(m_concurrency->value()); + const QString urlError = Settings::validateBaseUrl(m_baseUrl->text()); if (!urlError.isEmpty()) { m_settingsStatus->setText(urlError); @@ -160,7 +160,7 @@ void MainWindow::saveAndTest() m_settings.setBaseUrl(m_baseUrl->text()); m_settings.setUsername(m_username->text()); m_settings.writePassword(m_password->text()); - m_baseUrl->setText(m_settings.baseUrl()); // show normalised value + m_baseUrl->setText(m_settings.baseUrl()); m_saveButton->setEnabled(false); m_settingsStatus->setText(QStringLiteral("Testing…")); @@ -178,80 +178,184 @@ void MainWindow::saveAndTest() client->testConnection(); } -void MainWindow::chooseImage() +void MainWindow::chooseImages() { - const QString path = QFileDialog::getOpenFileName( - this, QStringLiteral("Choose a photo"), QString(), + const QStringList paths = QFileDialog::getOpenFileNames( + this, QStringLiteral("Choose photos"), QString(), QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)")); - if (path.isEmpty()) - return; + for (const QString &path : paths) + addItem(path); + refreshPosting(); +} - m_imagePath = path; +void MainWindow::addItem(const QString &path) +{ + m_emptyHint->hide(); + auto *item = new PhotoItemWidget(path); + connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem); + m_items.append(item); + m_itemsLayout->addWidget(item); +} - QImageReader reader(path); - reader.setAutoTransform(true); - const QImage image = reader.read(); - if (image.isNull()) { - m_preview->setText(QStringLiteral("Could not preview this image")); - } else { - m_preview->setPixmap(QPixmap::fromImage(image).scaled( - kPreviewSize, kPreviewSize, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - } - m_postStatus->clear(); - m_postButton->setEnabled(true); +void MainWindow::removeItem(PhotoItemWidget *item) +{ + m_items.removeOne(item); + m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting + item->deleteLater(); + if (m_items.isEmpty()) + m_emptyHint->show(); + refreshPosting(); +} + +void MainWindow::clearItems() +{ + for (PhotoItemWidget *item : std::as_const(m_items)) + item->deleteLater(); + m_items.clear(); + m_queue.clear(); + m_emptyHint->show(); + m_postSummary->clear(); + refreshPosting(); } void MainWindow::submit() { - if (m_imagePath.isEmpty()) + if (m_items.isEmpty()) return; if (!m_settings.isConfigured()) { - m_postStatus->setText(QStringLiteral("Set the server URL and credentials first")); + m_postSummary->setText(QStringLiteral("Set the server URL and credentials first")); return; } - m_postButton->setEnabled(false); - m_postStatus->setText(QStringLiteral("Preparing…")); + // Capture the batch's credentials once, then feed the queue through a + // concurrency cap rather than firing every photo at the server at once. + m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + m_batchBaseUrl = m_settings.baseUrl(); + m_batchUsername = m_settings.username(); + m_batchPassword = m_settings.readPassword(); - // Slug from wall-clock time: posts are made one at a time, so this cannot - // collide, and re-sending the same filename overwrites the same post. The - // real capture date still travels in the date field. - const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss")); - const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); - - const ImagePrep::Prepared prepared = ImagePrep::prepare(m_imagePath, cacheDir, slug); - if (!prepared.ok()) { - m_postStatus->setText(prepared.error); - m_postButton->setEnabled(true); - return; + m_queue.clear(); + for (PhotoItemWidget *item : std::as_const(m_items)) { + if (item->status() != PhotoItemWidget::Status::Success) + m_queue.append(item); // re-posting retries only failures } + if (m_queue.isEmpty()) + return; - m_postStatus->setText(QStringLiteral("Uploading…")); - - const QString caption = m_caption->text(); - const QString date = m_date->date().toString(QStringLiteral("yyyy-MM-dd")); - const QString fileName = QFileInfo(prepared.filePath).fileName(); - - auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(), - m_settings.readPassword(), this); - connect(client, &UploadClient::uploadFinished, this, - [this, client, prepared, caption](UploadClient::Result result, const QString &slug, - const QString &url, const QString &message) { - QFile::remove(prepared.filePath); // no retry queue in this MVP - m_postButton->setEnabled(true); - - if (result == UploadClient::Result::Success) { - Settings::LastPost last{slug, url, caption, QDateTime::currentDateTime()}; - m_settings.setLastPost(last); - QString msg = QStringLiteral("Posted. View").arg(url); - if (!prepared.note.isEmpty()) - msg += QStringLiteral("
%1").arg(prepared.note); - m_postStatus->setText(msg); - m_postStatus->setOpenExternalLinks(true); - } else { - m_postStatus->setText(message); + m_postSummary->clear(); + pumpQueue(); + refreshPosting(); +} + +int MainWindow::inFlightCount() const +{ + int n = 0; + for (PhotoItemWidget *item : std::as_const(m_items)) { + const PhotoItemWidget::Status s = item->status(); + if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading) + ++n; + } + return n; +} + +void MainWindow::pumpQueue() +{ + const int cap = m_settings.concurrency(); + while (inFlightCount() < cap && !m_queue.isEmpty()) + startItem(m_queue.takeFirst()); +} + +void MainWindow::startItem(PhotoItemWidget *item) +{ + item->setStatus(PhotoItemWidget::Status::Preparing); + + // Slug from wall-clock time plus a per-item suffix so a batch posted in the + // same second cannot collide (server derives the stored name from the + // uploaded filename). The real capture date rides in the date field. + const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss")) + + QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6); + const QString source = item->sourcePath(); + const QString cacheDir = m_batchCacheDir; + const QString baseUrl = m_batchBaseUrl; + const QString username = m_batchUsername; + const QString password = m_batchPassword; + + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, item, baseUrl, username, password]() { + const ImagePrep::Prepared prepared = watcher->result(); + watcher->deleteLater(); + + if (!prepared.ok()) { + item->setStatus(PhotoItemWidget::Status::Error, prepared.error); + pumpQueue(); // free the slot for the next photo + refreshPosting(); + return; } - client->deleteLater(); + + item->setStatus(PhotoItemWidget::Status::Uploading); + const QString caption = item->caption(); + + auto *client = new UploadClient(baseUrl, username, password, this); + connect(client, &UploadClient::uploadFinished, this, + [this, client, item, prepared, caption](UploadClient::Result result, + const QString &slug, const QString &url, + const QString &message) { + QFile::remove(prepared.filePath); // no retry queue on the desktop + if (result == UploadClient::Result::Success) { + m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()}); + item->setStatus(PhotoItemWidget::Status::Success, {}, url); + } else { + item->setStatus(PhotoItemWidget::Status::Error, message); + } + client->deleteLater(); + pumpQueue(); // free the slot for the next photo + refreshPosting(); + }); + client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(), + caption, item->date()); }); - client->upload(prepared.filePath, fileName, caption, date); + watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() { + return ImagePrep::prepare(source, cacheDir, slug); + })); +} + +void MainWindow::refreshPosting() +{ + int pending = 0; + int inFlight = 0; + int succeeded = 0; + int failed = 0; + for (PhotoItemWidget *item : std::as_const(m_items)) { + switch (item->status()) { + case PhotoItemWidget::Status::Pending: + ++pending; + break; + case PhotoItemWidget::Status::Preparing: + case PhotoItemWidget::Status::Uploading: + ++inFlight; + break; + case PhotoItemWidget::Status::Success: + ++succeeded; + break; + case PhotoItemWidget::Status::Error: + ++failed; + ++pending; // a failure is re-postable + break; + } + } + + const bool posting = inFlight > 0; + m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0); + m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size()) + : pending == 1 ? QStringLiteral("Post 1 photo") + : pending > 1 ? QStringLiteral("Post %1 photos").arg(pending) + : QStringLiteral("All posted")); + + if (!posting && (succeeded + failed) > 0) { + QString summary = QStringLiteral("%1 posted").arg(succeeded); + if (failed > 0) + summary += QStringLiteral(", %1 failed").arg(failed); + m_postSummary->setText(summary); + } } diff --git a/desktop/mainwindow.h b/desktop/mainwindow.h index 2cd4a89..fab575c 100644 --- a/desktop/mainwindow.h +++ b/desktop/mainwindow.h @@ -1,15 +1,18 @@ #pragma once +#include #include #include "settings.h" -#include "uploadclient.h" class QDateEdit; class QLabel; class QLineEdit; class QPushButton; +class QSpinBox; class QStackedWidget; +class QVBoxLayout; +class PhotoItemWidget; class MainWindow : public QMainWindow { @@ -25,8 +28,15 @@ private: void showPost(); void saveAndTest(); - void chooseImage(); + void chooseImages(); + void addItem(const QString &path); + void removeItem(PhotoItemWidget *item); + void clearItems(); void submit(); + void pumpQueue(); // starts queued items up to the concurrency cap + void startItem(PhotoItemWidget *item); + int inFlightCount() const; // items currently preparing or uploading + void refreshPosting(); // updates the Post button + summary from item states Settings m_settings; @@ -36,18 +46,24 @@ private: QLineEdit *m_baseUrl = nullptr; QLineEdit *m_username = nullptr; QLineEdit *m_password = nullptr; + QSpinBox *m_concurrency = nullptr; QPushButton *m_saveButton = nullptr; QPushButton *m_settingsBack = nullptr; QLabel *m_settingsStatus = nullptr; // Post page - QLabel *m_preview = nullptr; - QLineEdit *m_caption = nullptr; - QDateEdit *m_date = nullptr; - QPushButton *m_chooseButton = nullptr; + QVBoxLayout *m_itemsLayout = nullptr; // holds the PhotoItemWidgets + QList m_items; + QLabel *m_emptyHint = nullptr; QPushButton *m_postButton = nullptr; - QLabel *m_postStatus = nullptr; + QLabel *m_postSummary = nullptr; QLabel *m_lastPost = nullptr; - QString m_imagePath; // currently selected source image + // Bulk-post state: items waiting for a free upload slot, plus the batch's + // credentials captured once at submit time. + QList m_queue; + QString m_batchBaseUrl; + QString m_batchUsername; + QString m_batchPassword; + QString m_batchCacheDir; }; diff --git a/desktop/photoitemwidget.cpp b/desktop/photoitemwidget.cpp new file mode 100644 index 0000000..1943d0c --- /dev/null +++ b/desktop/photoitemwidget.cpp @@ -0,0 +1,101 @@ +#include "photoitemwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kThumbSize = 72; +} + +PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent) + : QWidget(parent) + , m_sourcePath(std::move(sourcePath)) +{ + auto *row = new QHBoxLayout(this); + row->setContentsMargins(0, 4, 0, 4); + + m_thumb = new QLabel; + m_thumb->setFixedSize(kThumbSize, kThumbSize); + m_thumb->setAlignment(Qt::AlignCenter); + m_thumb->setFrameShape(QFrame::Box); + { + QImageReader reader(m_sourcePath); + reader.setAutoTransform(true); + const QImage image = reader.read(); + if (!image.isNull()) + m_thumb->setPixmap(QPixmap::fromImage(image).scaled( + kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + else + m_thumb->setText(QStringLiteral("?")); + } + row->addWidget(m_thumb); + + auto *fields = new QVBoxLayout; + m_caption = new QLineEdit; + m_caption->setPlaceholderText(QStringLiteral("Caption (defaults to the filename)")); + fields->addWidget(m_caption); + + m_date = new QDateEdit(QDate::currentDate()); + m_date->setCalendarPopup(true); + m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); + fields->addWidget(m_date); + + m_statusLabel = new QLabel; + m_statusLabel->setOpenExternalLinks(true); + m_statusLabel->setWordWrap(true); + fields->addWidget(m_statusLabel); + row->addLayout(fields, 1); + + m_remove = new QPushButton(QStringLiteral("✕")); + m_remove->setFlat(true); + m_remove->setFixedWidth(32); + connect(m_remove, &QPushButton::clicked, this, [this]() { emit removeRequested(this); }); + row->addWidget(m_remove, 0, Qt::AlignTop); +} + +QString PhotoItemWidget::caption() const +{ + return m_caption->text(); +} + +QString PhotoItemWidget::date() const +{ + return m_date->date().toString(QStringLiteral("yyyy-MM-dd")); +} + +void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url) +{ + m_status = status; + + // Inputs are frozen once the photo is in flight; the remove button stays + // usable except mid-transfer. + const bool editable = status == Status::Pending || status == Status::Error; + m_caption->setEnabled(editable); + m_date->setEnabled(editable); + m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading); + + switch (status) { + case Status::Pending: + m_statusLabel->clear(); + break; + case Status::Preparing: + m_statusLabel->setText(QStringLiteral("Preparing…")); + break; + case Status::Uploading: + m_statusLabel->setText(QStringLiteral("Uploading…")); + break; + case Status::Success: + m_statusLabel->setText(QStringLiteral("Posted — View").arg(url)); + break; + case Status::Error: + m_statusLabel->setText(QStringLiteral("%1").arg(message)); + break; + } +} diff --git a/desktop/photoitemwidget.h b/desktop/photoitemwidget.h new file mode 100644 index 0000000..21f7023 --- /dev/null +++ b/desktop/photoitemwidget.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +class QDateEdit; +class QLabel; +class QLineEdit; +class QPushButton; + +// One photo in the batch: thumbnail, caption, date, and its own upload status. +// A single-photo post is just a batch of one, so the window runs one code path. +class PhotoItemWidget : public QWidget +{ + Q_OBJECT +public: + enum class Status { Pending, Preparing, Uploading, Success, Error }; + + explicit PhotoItemWidget(QString sourcePath, QWidget *parent = nullptr); + + QString sourcePath() const { return m_sourcePath; } + QString caption() const; + QString date() const; // yyyy-MM-dd + Status status() const { return m_status; } + + void setStatus(Status status, const QString &message = {}, const QString &url = {}); + +signals: + void removeRequested(PhotoItemWidget *self); + +private: + QString m_sourcePath; + Status m_status = Status::Pending; + + QLabel *m_thumb = nullptr; + QLineEdit *m_caption = nullptr; + QDateEdit *m_date = nullptr; + QLabel *m_statusLabel = nullptr; + QPushButton *m_remove = nullptr; +}; diff --git a/desktop/settings.cpp b/desktop/settings.cpp index e9906bd..8664999 100644 --- a/desktop/settings.cpp +++ b/desktop/settings.cpp @@ -3,6 +3,8 @@ #include #include +#include + #include namespace { @@ -35,6 +37,17 @@ void Settings::setUsername(const QString &value) m_settings.setValue(QStringLiteral("username"), value.trimmed()); } +int Settings::concurrency() const +{ + const int v = m_settings.value(QStringLiteral("concurrency"), kDefaultConcurrency).toInt(); + return std::clamp(v, 1, kMaxConcurrency); +} + +void Settings::setConcurrency(int value) +{ + m_settings.setValue(QStringLiteral("concurrency"), std::clamp(value, 1, kMaxConcurrency)); +} + QString Settings::readPassword() const { QKeychain::ReadPasswordJob job(kService); diff --git a/desktop/settings.h b/desktop/settings.h index 86f644a..ac492e6 100644 --- a/desktop/settings.h +++ b/desktop/settings.h @@ -27,6 +27,12 @@ public: QString username() const; void setUsername(const QString &value); + // Max photos uploaded at once in a bulk post, clamped to [1, kMaxConcurrency]. + static constexpr int kMaxConcurrency = 8; + static constexpr int kDefaultConcurrency = 3; + int concurrency() const; + void setConcurrency(int value); + // Keychain access is synchronous here (a local event loop): the app does // nothing until the credential is in hand, and there is no background retry // worker as on Android that would need it off the UI thread.